Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions internal/forge/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ type FakeClient struct {
DeletedRepos []string // "owner/repo"
DeletedFiles []FileRecord
CreatedSecrets []SecretRecord
DeletedSecrets []SecretRecord
Variables []VariableRecord
DeletedVariables []VariableRecord
DeletedOrgSecrets []string // "org/name"
CreatedOrgSecrets []OrgSecretRecord
CreatedOrgVariables []OrgVariableRecord
Expand Down Expand Up @@ -770,6 +772,26 @@ func (f *FakeClient) CreateRepoSecret(_ context.Context, owner, repo, name, valu
return nil
}

func (f *FakeClient) DeleteRepoSecret(_ context.Context, owner, repo, name string) error {
f.mu.Lock()
defer f.mu.Unlock()

if e := f.err("DeleteRepoSecret"); e != nil {
return e
}

key := owner + "/" + repo + "/" + name
if f.Secrets != nil {
delete(f.Secrets, key)
}
f.DeletedSecrets = append(f.DeletedSecrets, SecretRecord{
Owner: owner,
Repo: repo,
Name: name,
})
return nil
}

func (f *FakeClient) RepoSecretExists(_ context.Context, owner, repo, name string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
Expand All @@ -792,6 +814,15 @@ func (f *FakeClient) CreateOrUpdateRepoVariable(_ context.Context, owner, repo,
return e
}

key := owner + "/" + repo + "/" + name
if f.VariableValues == nil {
f.VariableValues = make(map[string]string)
}
f.VariableValues[key] = value
if f.VariablesExist == nil {
f.VariablesExist = make(map[string]bool)
}
f.VariablesExist[key] = true
f.Variables = append(f.Variables, VariableRecord{
Owner: owner,
Repo: repo,
Expand Down Expand Up @@ -831,6 +862,25 @@ func (f *FakeClient) GetRepoVariable(_ context.Context, owner, repo, name string
return "", false, nil
}

func (f *FakeClient) ListRepoVariables(_ context.Context, owner, repo string) (map[string]string, error) {
f.mu.Lock()
defer f.mu.Unlock()

if e := f.err("ListRepoVariables"); e != nil {
return nil, e
}

prefix := owner + "/" + repo + "/"
result := make(map[string]string)
for key, val := range f.VariableValues {
if strings.HasPrefix(key, prefix) {
name := strings.TrimPrefix(key, prefix)
result[name] = val
}
}
return result, nil
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

func (f *FakeClient) DeleteRepoVariable(_ context.Context, owner, repo, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
Expand All @@ -846,6 +896,11 @@ func (f *FakeClient) DeleteRepoVariable(_ context.Context, owner, repo, name str
if f.VariablesExist != nil {
delete(f.VariablesExist, key)
}
f.DeletedVariables = append(f.DeletedVariables, VariableRecord{
Owner: owner,
Repo: repo,
Name: name,
})
return nil
}

Expand Down
76 changes: 76 additions & 0 deletions internal/forge/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,82 @@ func TestFakeClient_DeleteRepoVariable(t *testing.T) {
assert.False(t, existsInExists)
}

func TestFakeClient_ListRepoVariables(t *testing.T) {
ctx := context.Background()

t.Run("returns matching variables", func(t *testing.T) {
fc := &FakeClient{
VariableValues: map[string]string{
"org/repo/FOO": "bar",
"org/repo/BAZ": "qux",
"org/other-repo/FOO": "other",
},
}

vars, err := fc.ListRepoVariables(ctx, "org", "repo")
require.NoError(t, err)
assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, vars)
})

t.Run("empty when no variables", func(t *testing.T) {
fc := &FakeClient{}
vars, err := fc.ListRepoVariables(ctx, "org", "repo")
require.NoError(t, err)
assert.Empty(t, vars)
})

t.Run("error injection", func(t *testing.T) {
fc := &FakeClient{Errors: map[string]error{"ListRepoVariables": errors.New("fail")}}
_, err := fc.ListRepoVariables(ctx, "org", "repo")
assert.Error(t, err)
})
}

func TestFakeClient_DeleteRepoSecret(t *testing.T) {
ctx := context.Background()

t.Run("deletes existing secret", func(t *testing.T) {
fc := &FakeClient{
Secrets: map[string]bool{
"org/repo/MY_SECRET": true,
},
}

err := fc.DeleteRepoSecret(ctx, "org", "repo", "MY_SECRET")
require.NoError(t, err)
assert.False(t, fc.Secrets["org/repo/MY_SECRET"])
require.Len(t, fc.DeletedSecrets, 1)
assert.Equal(t, "MY_SECRET", fc.DeletedSecrets[0].Name)
})

t.Run("idempotent when nil secrets map", func(t *testing.T) {
fc := &FakeClient{}
err := fc.DeleteRepoSecret(ctx, "org", "repo", "NONEXISTENT")
require.NoError(t, err)
require.Len(t, fc.DeletedSecrets, 1)
})

t.Run("error injection", func(t *testing.T) {
fc := &FakeClient{Errors: map[string]error{"DeleteRepoSecret": errors.New("fail")}}
err := fc.DeleteRepoSecret(ctx, "org", "repo", "SECRET")
assert.Error(t, err)
})
}

func TestFakeClient_CreateThenListVariables(t *testing.T) {
ctx := context.Background()
fc := &FakeClient{}

err := fc.CreateOrUpdateRepoVariable(ctx, "org", "repo", "FOO", "bar")
require.NoError(t, err)
err = fc.CreateOrUpdateRepoVariable(ctx, "org", "repo", "BAZ", "qux")
require.NoError(t, err)

vars, err := fc.ListRepoVariables(ctx, "org", "repo")
require.NoError(t, err)
assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, vars)
}

func TestFakeClient_ErrorInjection(t *testing.T) {
ctx := context.Background()
injected := errors.New("injected error")
Expand Down
2 changes: 2 additions & 0 deletions internal/forge/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,11 @@ type Client interface {
// Secrets and variables
CreateRepoSecret(ctx context.Context, owner, repo, name, value string) error
RepoSecretExists(ctx context.Context, owner, repo, name string) (bool, error)
DeleteRepoSecret(ctx context.Context, owner, repo, name string) error
CreateOrUpdateRepoVariable(ctx context.Context, owner, repo, name, value string) error
RepoVariableExists(ctx context.Context, owner, repo, name string) (bool, error)
GetRepoVariable(ctx context.Context, owner, repo, name string) (string, bool, error)
ListRepoVariables(ctx context.Context, owner, repo string) (map[string]string, error)
DeleteRepoVariable(ctx context.Context, owner, repo, name string) error

// Org-level secrets (for cross-repo dispatch tokens)
Expand Down
55 changes: 55 additions & 0 deletions internal/forge/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -1834,6 +1834,61 @@ func (c *LiveClient) DeleteRepoVariable(ctx context.Context, owner, repo, name s
return &APIError{StatusCode: resp.StatusCode, Message: "unexpected status deleting repo variable"}
}

// ListRepoVariables returns all Actions variables for a repository as a
// name-to-value map. Results are paginated; the method follows pagination
// until all variables are fetched or the safety page cap is reached.
func (c *LiveClient) ListRepoVariables(ctx context.Context, owner, repo string) (map[string]string, error) {
const maxPages = 100
result := make(map[string]string)
var totalCount, fetched int

for page := 1; page <= maxPages; page++ {
path := fmt.Sprintf("/repos/%s/%s/actions/variables?per_page=100&page=%d", owner, repo, page)
resp, err := c.get(ctx, path)
if err != nil {
return nil, fmt.Errorf("list repo variables page %d: %w", page, err)
}

var body struct {
TotalCount int `json:"total_count"`
Variables []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"variables"`
}
if err := decodeJSON(resp, &body); err != nil {
return nil, fmt.Errorf("decode repo variables page %d: %w", page, err)
}

totalCount = body.TotalCount
for _, v := range body.Variables {
result[v.Name] = v.Value
}
fetched += len(body.Variables)

if fetched >= totalCount || len(body.Variables) == 0 {
return result, nil
}
}

return nil, fmt.Errorf("list repo variables: pagination exceeded %d pages (fetched %d of %d variables)", maxPages, len(result), totalCount)
}

// DeleteRepoSecret deletes a repository Actions secret. It is idempotent:
// a 404 (secret already gone) is not treated as an error.
func (c *LiveClient) DeleteRepoSecret(ctx context.Context, owner, repo, name string) error {
resp, err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/repos/%s/%s/actions/secrets/%s", owner, repo, name), nil)
if err != nil {
return fmt.Errorf("delete repo secret %s: %w", name, err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
return nil
}
return &APIError{StatusCode: resp.StatusCode, Message: "unexpected status deleting repo secret"}
}

// GetWorkflow returns a workflow definition by filename (e.g. repo-maintenance.yml).
func (c *LiveClient) GetWorkflow(ctx context.Context, owner, repo, workflowFile string) (*forge.Workflow, error) {
resp, err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/actions/workflows/%s", owner, repo, workflowFile))
Expand Down
Loading
Loading