Skip to content

Commit f1021e5

Browse files
committed
feat(forge): add ListRepoVariables, DeleteRepoVariable, DeleteRepoSecret
Add three methods to the forge.Client interface for managing repo-level Actions variables and secrets. Needed by fullsend repos status, sync, and remove commands (ADR 0057). GitHub implementation uses REST API with pagination for list and idempotent deletes (204/404 both succeed). FakeClient tracks deletions for test assertions. Signed-off-by: Claude <noreply@anthropic.com> Signed-off-by: Greg Allen <gallen@redhat.com>
1 parent 6cfeae9 commit f1021e5

5 files changed

Lines changed: 343 additions & 0 deletions

File tree

internal/forge/fake.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,9 @@ type FakeClient struct {
200200
DeletedRepos []string // "owner/repo"
201201
DeletedFiles []FileRecord
202202
CreatedSecrets []SecretRecord
203+
DeletedSecrets []SecretRecord
203204
Variables []VariableRecord
205+
DeletedVariables []VariableRecord
204206
DeletedOrgSecrets []string // "org/name"
205207
CreatedOrgSecrets []OrgSecretRecord
206208
CreatedOrgVariables []OrgVariableRecord
@@ -770,6 +772,26 @@ func (f *FakeClient) CreateRepoSecret(_ context.Context, owner, repo, name, valu
770772
return nil
771773
}
772774

775+
func (f *FakeClient) DeleteRepoSecret(_ context.Context, owner, repo, name string) error {
776+
f.mu.Lock()
777+
defer f.mu.Unlock()
778+
779+
if e := f.err("DeleteRepoSecret"); e != nil {
780+
return e
781+
}
782+
783+
key := owner + "/" + repo + "/" + name
784+
if f.Secrets != nil {
785+
delete(f.Secrets, key)
786+
}
787+
f.DeletedSecrets = append(f.DeletedSecrets, SecretRecord{
788+
Owner: owner,
789+
Repo: repo,
790+
Name: name,
791+
})
792+
return nil
793+
}
794+
773795
func (f *FakeClient) RepoSecretExists(_ context.Context, owner, repo, name string) (bool, error) {
774796
f.mu.Lock()
775797
defer f.mu.Unlock()
@@ -792,6 +814,15 @@ func (f *FakeClient) CreateOrUpdateRepoVariable(_ context.Context, owner, repo,
792814
return e
793815
}
794816

817+
key := owner + "/" + repo + "/" + name
818+
if f.VariableValues == nil {
819+
f.VariableValues = make(map[string]string)
820+
}
821+
f.VariableValues[key] = value
822+
if f.VariablesExist == nil {
823+
f.VariablesExist = make(map[string]bool)
824+
}
825+
f.VariablesExist[key] = true
795826
f.Variables = append(f.Variables, VariableRecord{
796827
Owner: owner,
797828
Repo: repo,
@@ -831,6 +862,25 @@ func (f *FakeClient) GetRepoVariable(_ context.Context, owner, repo, name string
831862
return "", false, nil
832863
}
833864

865+
func (f *FakeClient) ListRepoVariables(_ context.Context, owner, repo string) (map[string]string, error) {
866+
f.mu.Lock()
867+
defer f.mu.Unlock()
868+
869+
if e := f.err("ListRepoVariables"); e != nil {
870+
return nil, e
871+
}
872+
873+
prefix := owner + "/" + repo + "/"
874+
result := make(map[string]string)
875+
for key, val := range f.VariableValues {
876+
if strings.HasPrefix(key, prefix) {
877+
name := strings.TrimPrefix(key, prefix)
878+
result[name] = val
879+
}
880+
}
881+
return result, nil
882+
}
883+
834884
func (f *FakeClient) DeleteRepoVariable(_ context.Context, owner, repo, name string) error {
835885
f.mu.Lock()
836886
defer f.mu.Unlock()
@@ -846,6 +896,11 @@ func (f *FakeClient) DeleteRepoVariable(_ context.Context, owner, repo, name str
846896
if f.VariablesExist != nil {
847897
delete(f.VariablesExist, key)
848898
}
899+
f.DeletedVariables = append(f.DeletedVariables, VariableRecord{
900+
Owner: owner,
901+
Repo: repo,
902+
Name: name,
903+
})
849904
return nil
850905
}
851906

internal/forge/fake_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,82 @@ func TestFakeClient_DeleteRepoVariable(t *testing.T) {
524524
assert.False(t, existsInExists)
525525
}
526526

527+
func TestFakeClient_ListRepoVariables(t *testing.T) {
528+
ctx := context.Background()
529+
530+
t.Run("returns matching variables", func(t *testing.T) {
531+
fc := &FakeClient{
532+
VariableValues: map[string]string{
533+
"org/repo/FOO": "bar",
534+
"org/repo/BAZ": "qux",
535+
"org/other-repo/FOO": "other",
536+
},
537+
}
538+
539+
vars, err := fc.ListRepoVariables(ctx, "org", "repo")
540+
require.NoError(t, err)
541+
assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, vars)
542+
})
543+
544+
t.Run("empty when no variables", func(t *testing.T) {
545+
fc := &FakeClient{}
546+
vars, err := fc.ListRepoVariables(ctx, "org", "repo")
547+
require.NoError(t, err)
548+
assert.Empty(t, vars)
549+
})
550+
551+
t.Run("error injection", func(t *testing.T) {
552+
fc := &FakeClient{Errors: map[string]error{"ListRepoVariables": errors.New("fail")}}
553+
_, err := fc.ListRepoVariables(ctx, "org", "repo")
554+
assert.Error(t, err)
555+
})
556+
}
557+
558+
func TestFakeClient_DeleteRepoSecret(t *testing.T) {
559+
ctx := context.Background()
560+
561+
t.Run("deletes existing secret", func(t *testing.T) {
562+
fc := &FakeClient{
563+
Secrets: map[string]bool{
564+
"org/repo/MY_SECRET": true,
565+
},
566+
}
567+
568+
err := fc.DeleteRepoSecret(ctx, "org", "repo", "MY_SECRET")
569+
require.NoError(t, err)
570+
assert.False(t, fc.Secrets["org/repo/MY_SECRET"])
571+
require.Len(t, fc.DeletedSecrets, 1)
572+
assert.Equal(t, "MY_SECRET", fc.DeletedSecrets[0].Name)
573+
})
574+
575+
t.Run("idempotent when nil secrets map", func(t *testing.T) {
576+
fc := &FakeClient{}
577+
err := fc.DeleteRepoSecret(ctx, "org", "repo", "NONEXISTENT")
578+
require.NoError(t, err)
579+
require.Len(t, fc.DeletedSecrets, 1)
580+
})
581+
582+
t.Run("error injection", func(t *testing.T) {
583+
fc := &FakeClient{Errors: map[string]error{"DeleteRepoSecret": errors.New("fail")}}
584+
err := fc.DeleteRepoSecret(ctx, "org", "repo", "SECRET")
585+
assert.Error(t, err)
586+
})
587+
}
588+
589+
func TestFakeClient_CreateThenListVariables(t *testing.T) {
590+
ctx := context.Background()
591+
fc := &FakeClient{}
592+
593+
err := fc.CreateOrUpdateRepoVariable(ctx, "org", "repo", "FOO", "bar")
594+
require.NoError(t, err)
595+
err = fc.CreateOrUpdateRepoVariable(ctx, "org", "repo", "BAZ", "qux")
596+
require.NoError(t, err)
597+
598+
vars, err := fc.ListRepoVariables(ctx, "org", "repo")
599+
require.NoError(t, err)
600+
assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, vars)
601+
}
602+
527603
func TestFakeClient_ErrorInjection(t *testing.T) {
528604
ctx := context.Background()
529605
injected := errors.New("injected error")

internal/forge/forge.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,9 +342,11 @@ type Client interface {
342342
// Secrets and variables
343343
CreateRepoSecret(ctx context.Context, owner, repo, name, value string) error
344344
RepoSecretExists(ctx context.Context, owner, repo, name string) (bool, error)
345+
DeleteRepoSecret(ctx context.Context, owner, repo, name string) error
345346
CreateOrUpdateRepoVariable(ctx context.Context, owner, repo, name, value string) error
346347
RepoVariableExists(ctx context.Context, owner, repo, name string) (bool, error)
347348
GetRepoVariable(ctx context.Context, owner, repo, name string) (string, bool, error)
349+
ListRepoVariables(ctx context.Context, owner, repo string) (map[string]string, error)
348350
DeleteRepoVariable(ctx context.Context, owner, repo, name string) error
349351

350352
// Org-level secrets (for cross-repo dispatch tokens)

internal/forge/github/github.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1834,6 +1834,61 @@ func (c *LiveClient) DeleteRepoVariable(ctx context.Context, owner, repo, name s
18341834
return &APIError{StatusCode: resp.StatusCode, Message: "unexpected status deleting repo variable"}
18351835
}
18361836

1837+
// ListRepoVariables returns all Actions variables for a repository as a
1838+
// name-to-value map. Results are paginated; the method follows pagination
1839+
// until all variables are fetched or the safety page cap is reached.
1840+
func (c *LiveClient) ListRepoVariables(ctx context.Context, owner, repo string) (map[string]string, error) {
1841+
const maxPages = 100
1842+
result := make(map[string]string)
1843+
var totalCount, fetched int
1844+
1845+
for page := 1; page <= maxPages; page++ {
1846+
path := fmt.Sprintf("/repos/%s/%s/actions/variables?per_page=100&page=%d", owner, repo, page)
1847+
resp, err := c.get(ctx, path)
1848+
if err != nil {
1849+
return nil, fmt.Errorf("list repo variables page %d: %w", page, err)
1850+
}
1851+
1852+
var body struct {
1853+
TotalCount int `json:"total_count"`
1854+
Variables []struct {
1855+
Name string `json:"name"`
1856+
Value string `json:"value"`
1857+
} `json:"variables"`
1858+
}
1859+
if err := decodeJSON(resp, &body); err != nil {
1860+
return nil, fmt.Errorf("decode repo variables page %d: %w", page, err)
1861+
}
1862+
1863+
totalCount = body.TotalCount
1864+
for _, v := range body.Variables {
1865+
result[v.Name] = v.Value
1866+
}
1867+
fetched += len(body.Variables)
1868+
1869+
if fetched >= totalCount || len(body.Variables) == 0 {
1870+
return result, nil
1871+
}
1872+
}
1873+
1874+
return nil, fmt.Errorf("list repo variables: pagination exceeded %d pages (fetched %d of %d variables)", maxPages, len(result), totalCount)
1875+
}
1876+
1877+
// DeleteRepoSecret deletes a repository Actions secret. It is idempotent:
1878+
// a 404 (secret already gone) is not treated as an error.
1879+
func (c *LiveClient) DeleteRepoSecret(ctx context.Context, owner, repo, name string) error {
1880+
resp, err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/repos/%s/%s/actions/secrets/%s", owner, repo, name), nil)
1881+
if err != nil {
1882+
return fmt.Errorf("delete repo secret %s: %w", name, err)
1883+
}
1884+
defer resp.Body.Close()
1885+
1886+
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
1887+
return nil
1888+
}
1889+
return &APIError{StatusCode: resp.StatusCode, Message: "unexpected status deleting repo secret"}
1890+
}
1891+
18371892
// GetWorkflow returns a workflow definition by filename (e.g. repo-maintenance.yml).
18381893
func (c *LiveClient) GetWorkflow(ctx context.Context, owner, repo, workflowFile string) (*forge.Workflow, error) {
18391894
resp, err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/actions/workflows/%s", owner, repo, workflowFile))

0 commit comments

Comments
 (0)