From 03d29aecd5c206b55605f997f80072586a7ce13e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 23 Aug 2026 18:03:02 -0400 Subject: [PATCH] feat(agents): skip archived repos in drift tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archived repos are frozen — they can never publish another tag or release, so their pins can't advance. The drift tooling had no archived awareness and would still report them as "N versions behind" (agent list/versions, codefly ci) and file chore issues against them (status release --create-issues) that nobody can act on. - Add pkg/gh: one shared GitHub client + token path + Archived() helper (fails safe: any lookup error is treated as not-archived). - cmd/agents/versions.go: collectInventory short-circuits archived repos to 0-behind, reporting only pinned + local-cache versions. Routed via a repoArchived seam matching the existing fetch seams. - cmd/status/release.go: gate chore-issue creation on the clone's origin remote being non-archived (org-agnostic; zero extra API calls on display). - Tests for the archived drift guard and GitHub remote URL parsing; existing drift tests made hermetic. Co-Authored-By: Claude Opus 4.8 --- cmd/agents/versions.go | 17 +++++++++ cmd/agents/versions_test.go | 56 +++++++++++++++++++++++++---- cmd/status/release.go | 42 ++++++++++++++++++++++ cmd/status/release_test.go | 27 ++++++++++++++ pkg/gh/gh.go | 72 +++++++++++++++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 cmd/status/release_test.go create mode 100644 pkg/gh/gh.go diff --git a/cmd/agents/versions.go b/cmd/agents/versions.go index a116b6f4..85b231c0 100644 --- a/cmd/agents/versions.go +++ b/cmd/agents/versions.go @@ -16,6 +16,7 @@ import ( "github.com/blang/semver" "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/gh" "github.com/codefly-dev/core/resources" "github.com/google/go-github/v89/github" "github.com/spf13/cobra" @@ -33,8 +34,17 @@ var ( fetchReleases = fetchReleasesFromGitHub fetchTags = fetchTagsFromGitHub fetchOCITags = fetchOCITagsFromRegistry + repoArchived = agentRepoArchived ) +// agentRepoArchived reports whether the agent's GitHub repo is archived. An +// archived repo is frozen — it can never publish another version — so drift +// tooling treats it as up to date instead of forever "behind". +func agentRepoArchived(ctx context.Context, agent *resources.Agent) bool { + owner, repo := githubSource(agent) + return gh.Archived(ctx, owner, repo) +} + // releaseInfo is one published GitHub release: the version it tags and the // os_arch suffixes it ships a downloadable asset for. type releaseInfo struct { @@ -200,6 +210,13 @@ func init() { // resolvability inventory. GitHub lookups that fail (missing repo, rate limit) // degrade to a warning so the local-cache and pinned columns still render. func collectInventory(ctx context.Context, agent *resources.Agent, pinned []string) inventory { + // An archived repo is frozen: it will never publish another tag or release, + // so its pin can't advance. Reporting it as "N versions behind" is pure + // noise (agent list / versions / `codefly ci`). Skip the remote sources and + // report only what's pinned and locally cached, so drift computes 0 behind. + if repoArchived(ctx, agent) { + return buildInventory(agent, nil, nil, localCacheVersions(ctx, agent), pinned, nil, false) + } releases, err := fetchReleases(ctx, agent) if err != nil { cli.Warning("cannot list GitHub releases for %s/%s: %v", agent.Publisher, agent.Name, err) diff --git a/cmd/agents/versions_test.go b/cmd/agents/versions_test.go index 6b0e47ba..b4136d0a 100644 --- a/cmd/agents/versions_test.go +++ b/cmd/agents/versions_test.go @@ -176,8 +176,12 @@ func TestVersionsBehindIgnoresUnresolvableNewer(t *testing.T) { } func TestSummarizeWorkspaceAgentsReportsDrift(t *testing.T) { - restoreReleases, restoreTags, restoreOCI := fetchReleases, fetchTags, fetchOCITags - defer func() { fetchReleases, fetchTags, fetchOCITags = restoreReleases, restoreTags, restoreOCI }() + restoreReleases, restoreTags, restoreOCI, restoreArchived := fetchReleases, fetchTags, fetchOCITags, repoArchived + defer func() { + fetchReleases, fetchTags, fetchOCITags, repoArchived = restoreReleases, restoreTags, restoreOCI, restoreArchived + }() + // Keep the archived check off the network: none of these fixtures are archived. + repoArchived = func(context.Context, *resources.Agent) bool { return false } fetchReleases = func(_ context.Context, _ *resources.Agent) ([]releaseInfo, error) { return []releaseInfo{ @@ -209,8 +213,12 @@ func TestSummarizeWorkspaceAgentsReportsDrift(t *testing.T) { } func TestLatestResolvableDriftMatchesInventory(t *testing.T) { - restoreReleases, restoreTags, restoreOCI := fetchReleases, fetchTags, fetchOCITags - defer func() { fetchReleases, fetchTags, fetchOCITags = restoreReleases, restoreTags, restoreOCI }() + restoreReleases, restoreTags, restoreOCI, restoreArchived := fetchReleases, fetchTags, fetchOCITags, repoArchived + defer func() { + fetchReleases, fetchTags, fetchOCITags, repoArchived = restoreReleases, restoreTags, restoreOCI, restoreArchived + }() + // Keep the archived check off the network: none of these fixtures are archived. + repoArchived = func(context.Context, *resources.Agent) bool { return false } fetchReleases = func(_ context.Context, _ *resources.Agent) ([]releaseInfo, error) { return []releaseInfo{{version: "0.0.22", platforms: []string{ciPlatform}}}, nil @@ -232,6 +240,38 @@ func TestLatestResolvableDriftMatchesInventory(t *testing.T) { } } +func TestArchivedRepoReportsNoDrift(t *testing.T) { + restoreReleases, restoreTags, restoreOCI, restoreArchived := fetchReleases, fetchTags, fetchOCITags, repoArchived + defer func() { + fetchReleases, fetchTags, fetchOCITags, repoArchived = restoreReleases, restoreTags, restoreOCI, restoreArchived + }() + // The remote clearly has newer resolvable releases... + fetchReleases = func(_ context.Context, _ *resources.Agent) ([]releaseInfo, error) { + return []releaseInfo{{version: "0.0.99", platforms: []string{ciPlatform}}}, nil + } + fetchTags = func(_ context.Context, _ *resources.Agent) ([]string, error) { + return []string{"0.0.15", "0.0.99"}, nil + } + fetchOCITags = func(_ context.Context, _ *resources.Agent) (bool, []string, error) { + return false, nil, nil + } + // ...but the repo is archived, so drift tooling must ignore it entirely and + // must never even reach for the remote source list. + repoArchived = func(context.Context, *resources.Agent) bool { return true } + fetchReleases = func(_ context.Context, _ *resources.Agent) ([]releaseInfo, error) { + t.Fatal("fetchReleases called for an archived repo") + return nil, nil + } + + latest, behind := LatestResolvableDrift(context.Background(), redisAgent(), "0.0.15") + if behind != 0 { + t.Fatalf("behind = %d, want 0 for an archived repo", behind) + } + if latest != "" { + t.Fatalf("latest resolvable = %q, want empty for an archived repo", latest) + } +} + func TestBehindCell(t *testing.T) { if got := behindCell(0); got != "-" { t.Fatalf("behindCell(0) = %q, want -", got) @@ -340,8 +380,12 @@ func TestNewGitHubClientUnauthenticated(t *testing.T) { } func TestSummarizeWorkspaceAgentsCachesAndFlagsResolvability(t *testing.T) { - restoreReleases, restoreTags, restoreOCI := fetchReleases, fetchTags, fetchOCITags - defer func() { fetchReleases, fetchTags, fetchOCITags = restoreReleases, restoreTags, restoreOCI }() + restoreReleases, restoreTags, restoreOCI, restoreArchived := fetchReleases, fetchTags, fetchOCITags, repoArchived + defer func() { + fetchReleases, fetchTags, fetchOCITags, repoArchived = restoreReleases, restoreTags, restoreOCI, restoreArchived + }() + // Keep the archived check off the network: none of these fixtures are archived. + repoArchived = func(context.Context, *resources.Agent) bool { return false } var releaseCalls int fetchReleases = func(_ context.Context, agent *resources.Agent) ([]releaseInfo, error) { diff --git a/cmd/status/release.go b/cmd/status/release.go index 620418ac..0175f797 100644 --- a/cmd/status/release.go +++ b/cmd/status/release.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "github.com/codefly-dev/cli/pkg/gh" "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -103,8 +104,20 @@ func runRelease(cmd *cobra.Command, args []string) error { if createIssues { fmt.Printf("\n==> Creating GitHub issues for agents with issues...\n") issuesCreated := 0 + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } for _, s := range statuses { if s.Delta > 50 || len(s.Issues) > 0 { + // Never file a chore issue against an archived repo: it's frozen + // and can't be bumped or released, so the issue would be noise + // nobody can act on. Resolve the repo from the clone's origin so + // this works regardless of org. + if owner, repo := remoteOwnerRepo(ctx, filepath.Join(baseDir, s.Name)); gh.Archived(ctx, owner, repo) { + fmt.Printf("⏭ Skipping archived repo %s\n", s.Name) + continue + } if err := createAgentIssue(baseDir, s); err != nil { fmt.Printf("⚠ Failed to create issue for %s: %v\n", s.Name, err) } else { @@ -249,6 +262,35 @@ func checkAgentHealth(agentPath string) []string { return issues } +// remoteOwnerRepo returns the GitHub owner/repo a local clone points at, read +// from its origin remote. It returns empty strings when the path isn't a git +// repo, has no origin, or points somewhere other than github.com — callers then +// treat the repo as non-archived and proceed as before. +func remoteOwnerRepo(ctx context.Context, path string) (owner, repo string) { + out, err := exec.CommandContext(ctx, "git", "-C", path, "config", "--get", "remote.origin.url").Output() + if err != nil { + return "", "" + } + return parseGitHubRemote(strings.TrimSpace(string(out))) +} + +// parseGitHubRemote extracts owner/repo from a github.com remote URL, handling +// both git@github.com:owner/repo.git and https://github.com/owner/repo(.git). +// It returns empty strings for any non-GitHub or unparseable remote. +func parseGitHubRemote(url string) (owner, repo string) { + url = strings.TrimSuffix(url, ".git") + i := strings.Index(url, "github.com") + if i < 0 { + return "", "" + } + rest := strings.TrimLeft(url[i+len("github.com"):], ":/") + parts := strings.SplitN(rest, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "" + } + return parts[0], parts[1] +} + func createAgentIssue(baseDir string, status AgentStatus) error { agentPath := filepath.Join(baseDir, status.Name) diff --git a/cmd/status/release_test.go b/cmd/status/release_test.go new file mode 100644 index 00000000..669bcf37 --- /dev/null +++ b/cmd/status/release_test.go @@ -0,0 +1,27 @@ +package status + +import "testing" + +func TestParseGitHubRemote(t *testing.T) { + cases := []struct { + name string + url string + owner, repo string + }{ + {"https", "https://github.com/codefly-dev/service-minio", "codefly-dev", "service-minio"}, + {"https .git", "https://github.com/codefly-dev/service-minio.git", "codefly-dev", "service-minio"}, + {"ssh", "git@github.com:codefly-dev/service-s3.git", "codefly-dev", "service-s3"}, + {"non-github", "https://gitlab.com/codefly-dev/service-x.git", "", ""}, + {"garbage", "not-a-url", "", ""}, + {"owner only", "https://github.com/codefly-dev", "", ""}, + {"empty", "", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + owner, repo := parseGitHubRemote(tc.url) + if owner != tc.owner || repo != tc.repo { + t.Fatalf("parseGitHubRemote(%q) = (%q, %q), want (%q, %q)", tc.url, owner, repo, tc.owner, tc.repo) + } + }) + } +} diff --git a/pkg/gh/gh.go b/pkg/gh/gh.go new file mode 100644 index 00000000..d73d50a1 --- /dev/null +++ b/pkg/gh/gh.go @@ -0,0 +1,72 @@ +// Package gh centralizes GitHub API access for the CLI: one authenticated +// client, one token-resolution path, and small platform helpers shared by the +// drift tooling. Keeping this in one place means agent-drift (cmd/agents) and +// the local release scan (cmd/status) resolve credentials and archived state +// identically instead of each re-deriving them. +package gh + +import ( + "context" + "os" + "os/exec" + "strings" + + "github.com/google/go-github/v89/github" +) + +// Token resolves a GitHub token from GITHUB_TOKEN/GH_TOKEN, falling back to the +// `gh` CLI's stored credential. Without the `gh` fallback, authenticated-only +// diagnostics silently drop to the unauthenticated 60 req/hour limit on a +// machine that is in fact logged in via `gh`. +func Token() string { + if t := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); t != "" { + return t + } + if t := strings.TrimSpace(os.Getenv("GH_TOKEN")); t != "" { + return t + } + out, err := exec.Command("gh", "auth", "token").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// Client returns a GitHub client authenticated with Token() when one is +// available, and an unauthenticated client otherwise. It returns nil only if +// the underlying client cannot be constructed, which callers should treat as +// "GitHub unavailable" rather than fatal. +func Client() *github.Client { + var ( + c *github.Client + err error + ) + if token := Token(); token != "" { + c, err = github.NewClient(github.WithAuthToken(token)) + } else { + c, err = github.NewClient() + } + if err != nil { + return nil + } + return c +} + +// Archived reports whether owner/repo is an archived GitHub repository. +// A lookup failure (missing repo, rate limit, no auth, no client) returns false +// so callers only skip a repo on a *confirmed* archived flag and otherwise +// degrade to their normal behavior rather than silently hiding live repos. +func Archived(ctx context.Context, owner, repo string) bool { + if owner == "" || repo == "" { + return false + } + client := Client() + if client == nil { + return false + } + r, _, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + return false + } + return r.GetArchived() +}