diff --git a/cmd/agents/versions.go b/cmd/agents/versions.go index 6e99b9cf..dca3ab3a 100644 --- a/cmd/agents/versions.go +++ b/cmd/agents/versions.go @@ -33,8 +33,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 +209,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 0251cf69..58cef730 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) @@ -304,8 +344,12 @@ func TestLocalCacheVersionsScansAgentDir(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 0e76064b..5b580250 100644 --- a/cmd/status/release.go +++ b/cmd/status/release.go @@ -105,8 +105,21 @@ 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; an unresolvable remote falls + // through to the normal (non-archived) path. + if repoIsArchived(ctx, filepath.Join(baseDir, s.Name)) { + 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 { @@ -251,6 +264,17 @@ func checkAgentHealth(agentPath string) []string { return issues } +// repoIsArchived reports whether the clone at path points at an archived GitHub +// repo. A remote we can't resolve (not a git repo, no origin, non-GitHub) is +// treated as non-archived so the caller proceeds exactly as before. +func repoIsArchived(ctx context.Context, path string) bool { + owner, repo, err := agentRepository(path) + if err != nil { + return false + } + return gh.Archived(ctx, owner, repo) +} + func createAgentIssue(baseDir string, status AgentStatus) error { agentPath := filepath.Join(baseDir, status.Name) diff --git a/pkg/gh/gh.go b/pkg/gh/gh.go new file mode 100644 index 00000000..dddd6c6b --- /dev/null +++ b/pkg/gh/gh.go @@ -0,0 +1,23 @@ +package gh + +import "context" + +// Archived reports whether owner/repo is an archived GitHub repository. +// A lookup failure (missing repo, rate limit, no auth, client construction) +// 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, err := NewClient() + if err != nil || client == nil { + return false + } + r, _, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + return false + } + return r.GetArchived() +}