@oss-autopilot/core 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/cli-registry.d.ts +7 -0
  2. package/dist/cli-registry.js +58 -5
  3. package/dist/cli.bundle.cjs +165 -112
  4. package/dist/cli.js +11 -3
  5. package/dist/commands/comments.js +31 -15
  6. package/dist/commands/compliance-score.js +12 -4
  7. package/dist/commands/daily-render.d.ts +2 -1
  8. package/dist/commands/daily-render.js +8 -2
  9. package/dist/commands/daily.d.ts +3 -1
  10. package/dist/commands/daily.js +54 -4
  11. package/dist/commands/dashboard-data.d.ts +17 -0
  12. package/dist/commands/dashboard-data.js +62 -4
  13. package/dist/commands/dashboard-server.js +100 -26
  14. package/dist/commands/dismiss.d.ts +4 -0
  15. package/dist/commands/dismiss.js +4 -4
  16. package/dist/commands/guidelines.d.ts +19 -0
  17. package/dist/commands/guidelines.js +23 -4
  18. package/dist/commands/index.d.ts +5 -1
  19. package/dist/commands/index.js +4 -0
  20. package/dist/commands/list-move-tier.d.ts +11 -3
  21. package/dist/commands/list-move-tier.js +18 -7
  22. package/dist/commands/move.d.ts +2 -0
  23. package/dist/commands/move.js +12 -8
  24. package/dist/commands/repo-vet.js +30 -8
  25. package/dist/commands/search.js +17 -3
  26. package/dist/commands/shelve.d.ts +4 -0
  27. package/dist/commands/shelve.js +4 -4
  28. package/dist/commands/verify-issue.d.ts +20 -0
  29. package/dist/commands/verify-issue.js +32 -0
  30. package/dist/core/daily-logic.js +65 -52
  31. package/dist/core/gist-state-store.js +42 -7
  32. package/dist/core/index.d.ts +3 -1
  33. package/dist/core/index.js +3 -1
  34. package/dist/core/issue-conversation.js +15 -2
  35. package/dist/core/issue-verification.d.ts +91 -0
  36. package/dist/core/issue-verification.js +270 -0
  37. package/dist/core/paths.d.ts +12 -0
  38. package/dist/core/paths.js +16 -0
  39. package/dist/core/pr-attention.d.ts +52 -0
  40. package/dist/core/pr-attention.js +76 -0
  41. package/dist/core/pr-comments-fetcher.d.ts +10 -2
  42. package/dist/core/pr-comments-fetcher.js +22 -4
  43. package/dist/core/state-persistence.d.ts +31 -9
  44. package/dist/core/state-persistence.js +51 -16
  45. package/dist/core/state.d.ts +18 -1
  46. package/dist/core/state.js +35 -3
  47. package/dist/core/types.d.ts +7 -0
  48. package/dist/core/untrusted-content.d.ts +24 -3
  49. package/dist/core/untrusted-content.js +31 -3
  50. package/dist/formatters/json.d.ts +83 -2
  51. package/dist/formatters/json.js +55 -1
  52. package/package.json +7 -7
@@ -6,10 +6,14 @@
6
6
  export interface DismissOutput {
7
7
  dismissed: boolean;
8
8
  url: string;
9
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
10
+ gistSyncWarning?: string;
9
11
  }
10
12
  export interface UndismissOutput {
11
13
  undismissed: boolean;
12
14
  url: string;
15
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
16
+ gistSyncWarning?: string;
13
17
  }
14
18
  /**
15
19
  * Dismiss an issue's reply notifications without posting a comment.
@@ -20,8 +20,8 @@ export async function runDismiss(options) {
20
20
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
21
21
  const stateManager = getStateManager();
22
22
  const added = stateManager.dismissIssue(options.url, new Date().toISOString());
23
- await maybeCheckpoint(stateManager, MODULE);
24
- return { dismissed: added, url: options.url };
23
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
24
+ return { dismissed: added, url: options.url, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
25
25
  }
26
26
  /**
27
27
  * Restore a dismissed issue to notifications.
@@ -36,6 +36,6 @@ export async function runUndismiss(options) {
36
36
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
37
37
  const stateManager = getStateManager();
38
38
  const removed = stateManager.undismissIssue(options.url);
39
- await maybeCheckpoint(stateManager, MODULE);
40
- return { undismissed: removed, url: options.url };
39
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
40
+ return { undismissed: removed, url: options.url, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
41
41
  }
@@ -10,15 +10,26 @@ export interface GuidelinesViewOutput {
10
10
  /** Where the guidelines would be persisted if a write happened now. */
11
11
  storageMode: 'gist' | 'local-unavailable';
12
12
  }
13
+ export interface GuidelinesListOutput {
14
+ /** Repos with non-empty guidelines stored, sorted alphabetically. Always empty in local mode. */
15
+ repos: string[];
16
+ count: number;
17
+ /** Where guidelines are persisted. `local-unavailable` always yields an empty list. */
18
+ storageMode: 'gist' | 'local-unavailable';
19
+ }
13
20
  export interface GuidelinesStoreOutput {
14
21
  repo: string;
15
22
  byteSize: number;
16
23
  stored: boolean;
24
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
25
+ gistSyncWarning?: string;
17
26
  }
18
27
  export interface GuidelinesResetOutput {
19
28
  repo: string;
20
29
  /** True when an existing file was tombstoned, false when no file existed. */
21
30
  deleted: boolean;
31
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
32
+ gistSyncWarning?: string;
22
33
  }
23
34
  export interface FetchCorpusOutput {
24
35
  repo: string;
@@ -37,6 +48,8 @@ export interface FetchCorpusOutput {
37
48
  prUrl: string;
38
49
  error: string;
39
50
  }>;
51
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
52
+ gistSyncWarning?: string;
40
53
  }
41
54
  interface RepoOption {
42
55
  repo: string;
@@ -52,6 +65,12 @@ interface StoreOptions extends RepoOption {
52
65
  }
53
66
  /** Read the per-repo guidelines for `repo`. Returns a `local-unavailable` envelope in non-Gist mode. */
54
67
  export declare function runGuidelinesView(options: RepoOption): Promise<GuidelinesViewOutput>;
68
+ /**
69
+ * List every repo with non-empty stored guidelines. Never throws in local
70
+ * mode — returns an empty list with `storageMode: 'local-unavailable'` so
71
+ * hosts can distinguish "nothing stored" from "storage not configured".
72
+ */
73
+ export declare function runGuidelinesList(): Promise<GuidelinesListOutput>;
55
74
  /**
56
75
  * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
57
76
  * when content exceeds the byte budget — the CLI surface relies on the
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Guidelines CLI commands (#867 PR 4).
3
3
  *
4
+ * `guidelines list` — list repos with stored guidelines.
4
5
  * `guidelines view` — read the per-repo guidelines file from the Gist.
5
6
  * `guidelines store` — overwrite the per-repo guidelines file.
6
7
  * `guidelines reset` — tombstone the file so subsequent reads return null.
@@ -41,6 +42,20 @@ export async function runGuidelinesView(options) {
41
42
  storageMode: sm.isGuidelinesAvailable() ? 'gist' : 'local-unavailable',
42
43
  };
43
44
  }
45
+ /**
46
+ * List every repo with non-empty stored guidelines. Never throws in local
47
+ * mode — returns an empty list with `storageMode: 'local-unavailable'` so
48
+ * hosts can distinguish "nothing stored" from "storage not configured".
49
+ */
50
+ export async function runGuidelinesList() {
51
+ const sm = getStateManager();
52
+ const repos = sm.listGuidelinesRepos();
53
+ return {
54
+ repos,
55
+ count: repos.length,
56
+ storageMode: sm.isGuidelinesAvailable() ? 'gist' : 'local-unavailable',
57
+ };
58
+ }
44
59
  /**
45
60
  * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
46
61
  * when content exceeds the byte budget — the CLI surface relies on the
@@ -57,11 +72,12 @@ export async function runGuidelinesStore(options) {
57
72
  // Push to Gist — autoSave only writes the local state-cache mirror in Gist
58
73
  // mode, so without this checkpoint the change never propagates across
59
74
  // machines (#1200).
60
- await maybeCheckpoint(sm, MODULE);
75
+ const gistSyncWarning = await maybeCheckpoint(sm, MODULE);
61
76
  return {
62
77
  repo: options.repo,
63
78
  byteSize: Buffer.byteLength(options.content, 'utf8'),
64
79
  stored: true,
80
+ ...(gistSyncWarning ? { gistSyncWarning } : {}),
65
81
  };
66
82
  }
67
83
  /** Tombstone the guidelines file for `repo`. */
@@ -72,12 +88,13 @@ export async function runGuidelinesReset(options) {
72
88
  throw new GuidelinesNotAvailableError();
73
89
  }
74
90
  const existed = sm.getGuidelines(options.repo) !== null;
91
+ let gistSyncWarning = null;
75
92
  if (existed) {
76
93
  sm.deleteGuidelines(options.repo);
77
94
  // Push to Gist — see runGuidelinesStore note (#1200).
78
- await maybeCheckpoint(sm, MODULE);
95
+ gistSyncWarning = await maybeCheckpoint(sm, MODULE);
79
96
  }
80
- return { repo: options.repo, deleted: existed };
97
+ return { repo: options.repo, deleted: existed, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
81
98
  }
82
99
  /**
83
100
  * Fetch raw PR comment bundles for the most recent merged/closed PRs in `repo`
@@ -146,8 +163,9 @@ export async function runFetchCorpus(options) {
146
163
  // Push commentsFetchedAt stamps to Gist so other machines don't re-fetch
147
164
  // the same PRs forever. autoSave only writes the local mirror in Gist
148
165
  // mode (#1200).
166
+ let gistSyncWarning = null;
149
167
  if (bundles.length > 0) {
150
- await maybeCheckpoint(sm, MODULE);
168
+ gistSyncWarning = await maybeCheckpoint(sm, MODULE);
151
169
  }
152
170
  return {
153
171
  repo: options.repo,
@@ -155,6 +173,7 @@ export async function runFetchCorpus(options) {
155
173
  prCount: bundles.length,
156
174
  skipped,
157
175
  failures,
176
+ ...(gistSyncWarning ? { gistSyncWarning } : {}),
158
177
  };
159
178
  }
160
179
  function clampLimit(limit) {
@@ -29,6 +29,8 @@ export { runSearch, MAX_SEARCH_RESULTS } from './search.js';
29
29
  export { runFeatures, MAX_FEATURES_RESULTS } from './features.js';
30
30
  /** Vet a single GitHub issue for claimability (open, unassigned, no linked PRs, repo health). */
31
31
  export { runVet } from './vet.js';
32
+ /** Deterministic availability check: state/stateReason + linked-PR classification (#1353, #1354). */
33
+ export { runVerifyIssue, type VerifyIssueOptions } from './verify-issue.js';
32
34
  /** Re-vet all available issues in a curated issue list for freshness. */
33
35
  export { runVetList } from './vet-list.js';
34
36
  /** Fetch PR metadata from GitHub (informational; nothing is persisted). */
@@ -61,6 +63,8 @@ export { runInit } from './init.js';
61
63
  export { runSetup } from './setup.js';
62
64
  /** Check whether setup has been completed. */
63
65
  export { runCheckSetup } from './setup.js';
66
+ /** List repos with stored guidelines (empty in local mode). */
67
+ export { runGuidelinesList } from './guidelines.js';
64
68
  /** Read the guidelines file for a repo. */
65
69
  export { runGuidelinesView } from './guidelines.js';
66
70
  /** Persist a guidelines file for a repo (overwrites on subsequent calls). */
@@ -96,7 +100,7 @@ export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput, VetListOutput,
96
100
  export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, ParsedIssueItem, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
97
101
  export type { ShelveOutput, UnshelveOutput } from './shelve.js';
98
102
  export type { MoveOutput, MoveTarget } from './move.js';
99
- export type { GuidelinesViewOutput, GuidelinesStoreOutput, GuidelinesResetOutput, FetchCorpusOutput, } from './guidelines.js';
103
+ export type { GuidelinesListOutput, GuidelinesViewOutput, GuidelinesStoreOutput, GuidelinesResetOutput, FetchCorpusOutput, } from './guidelines.js';
100
104
  export type { DismissOutput, UndismissOutput } from './dismiss.js';
101
105
  export type { InitOutput } from './init.js';
102
106
  export type { ConfigSetOutput, ConfigCommandOutput } from './config.js';
@@ -30,6 +30,8 @@ export { runSearch, MAX_SEARCH_RESULTS } from './search.js';
30
30
  export { runFeatures, MAX_FEATURES_RESULTS } from './features.js';
31
31
  /** Vet a single GitHub issue for claimability (open, unassigned, no linked PRs, repo health). */
32
32
  export { runVet } from './vet.js';
33
+ /** Deterministic availability check: state/stateReason + linked-PR classification (#1353, #1354). */
34
+ export { runVerifyIssue } from './verify-issue.js';
33
35
  /** Re-vet all available issues in a curated issue list for freshness. */
34
36
  export { runVetList } from './vet-list.js';
35
37
  // ── PR Management ───────────────────────────────────────────────────────────
@@ -66,6 +68,8 @@ export { runSetup } from './setup.js';
66
68
  /** Check whether setup has been completed. */
67
69
  export { runCheckSetup } from './setup.js';
68
70
  // ── Per-Repo Guidelines (#867) ──────────────────────────────────────────────
71
+ /** List repos with stored guidelines (empty in local mode). */
72
+ export { runGuidelinesList } from './guidelines.js';
69
73
  /** Read the guidelines file for a repo. */
70
74
  export { runGuidelinesView } from './guidelines.js';
71
75
  /** Persist a guidelines file for a repo (overwrites on subsequent calls). */
@@ -6,7 +6,9 @@
6
6
  * issue-list markdown file. Replaces the model-driven prose rewrite that
7
7
  * lived in /oss-search with a deterministic file manipulation.
8
8
  *
9
- * Idempotent: re-running with the same target tier is a no-op.
9
+ * Idempotent: re-running with the same target tier is a no-op. A URL that is
10
+ * not in the list at all is an error (#1355) — the command does not create
11
+ * entries, and callers must add the entry before moving it.
10
12
  *
11
13
  * No GitHub calls — pure read/transform/write of a local file.
12
14
  */
@@ -17,7 +19,8 @@ export interface ListMoveTierOptions {
17
19
  listPath: string;
18
20
  }
19
21
  export interface ListMoveTierOutput {
20
- /** Whether anything moved (false when the URL isn't in the list, or it was already in the target tier). */
22
+ /** Whether anything moved (false only when the entry was already in the
23
+ * target tier — a URL missing from the list entirely throws instead, #1355). */
21
24
  moved: boolean;
22
25
  /** Fully-resolved file path that was inspected. */
23
26
  filePath: string;
@@ -25,7 +28,8 @@ export interface ListMoveTierOutput {
25
28
  url: string;
26
29
  /** The target tier (always normalized to one of pursue/maybe/skip). */
27
30
  toTier: Tier;
28
- /** The tier the issue was moved out of, if it had one. Absent when not found or already in target. */
31
+ /** The tier the issue was moved out of, if it had one. Also populated on the
32
+ * already-in-target no-op; absent when the source block sat under no tier header. */
29
33
  fromTier?: string;
30
34
  /** Number of matching entries moved. Should normally be 1; >1 means the list contained duplicate entries (all moved). */
31
35
  count: number;
@@ -36,11 +40,15 @@ export interface ListMoveTierOutput {
36
40
  * Pure transform — accepts the file content and returns the rewritten content
37
41
  * plus a summary of what changed. Exported for unit testing.
38
42
  */
43
+ /** Discriminates the two `moved: false` outcomes of {@link moveIssueToTier}. */
44
+ export type MoveNoOpReason = 'not-found' | 'already-in-target';
39
45
  export declare function moveIssueToTier(content: string, issueUrl: string, targetTier: Tier): {
40
46
  content: string;
41
47
  moved: boolean;
42
48
  fromTier?: string;
43
49
  count: number;
44
50
  reason?: string;
51
+ /** Set only when `moved` is false — why nothing changed. */
52
+ reasonCode?: MoveNoOpReason;
45
53
  };
46
54
  export declare function runListMoveTier(options: ListMoveTierOptions): Promise<ListMoveTierOutput>;
@@ -6,13 +6,15 @@
6
6
  * issue-list markdown file. Replaces the model-driven prose rewrite that
7
7
  * lived in /oss-search with a deterministic file manipulation.
8
8
  *
9
- * Idempotent: re-running with the same target tier is a no-op.
9
+ * Idempotent: re-running with the same target tier is a no-op. A URL that is
10
+ * not in the list at all is an error (#1355) — the command does not create
11
+ * entries, and callers must add the entry before moving it.
10
12
  *
11
13
  * No GitHub calls — pure read/transform/write of a local file.
12
14
  */
13
15
  import * as fs from 'node:fs';
14
16
  import * as path from 'node:path';
15
- import { errorMessage } from '../core/errors.js';
17
+ import { errorMessage, ValidationError } from '../core/errors.js';
16
18
  const TIER_HEADERS = {
17
19
  pursue: '## Pursue',
18
20
  maybe: '## Maybe',
@@ -82,17 +84,19 @@ function findTierInsertionIndex(lines, headerIndex) {
82
84
  }
83
85
  return lines.length;
84
86
  }
85
- /**
86
- * Pure transform — accepts the file content and returns the rewritten content
87
- * plus a summary of what changed. Exported for unit testing.
88
- */
89
87
  export function moveIssueToTier(content, issueUrl, targetTier) {
90
88
  // Preserve the trailing newline if present so we don't accidentally strip it.
91
89
  const hadTrailingNewline = content.endsWith('\n');
92
90
  const lines = (hadTrailingNewline ? content.slice(0, -1) : content).split('\n');
93
91
  const blocks = findIssueBlocks(lines, issueUrl);
94
92
  if (blocks.length === 0) {
95
- return { content, moved: false, count: 0, reason: 'issue URL not found in the list' };
93
+ return {
94
+ content,
95
+ moved: false,
96
+ count: 0,
97
+ reason: 'issue URL not found in the list',
98
+ reasonCode: 'not-found',
99
+ };
96
100
  }
97
101
  const targetHeader = TIER_HEADERS[targetTier];
98
102
  // If every match is already in the target tier, no-op (idempotent).
@@ -103,6 +107,7 @@ export function moveIssueToTier(content, issueUrl, targetTier) {
103
107
  fromTier: blocks[0].tier,
104
108
  count: blocks.length,
105
109
  reason: 'already in target tier',
110
+ reasonCode: 'already-in-target',
106
111
  };
107
112
  }
108
113
  // Extract the blocks (highest start index first so earlier indices stay
@@ -172,6 +177,12 @@ export async function runListMoveTier(options) {
172
177
  throw new Error(`Failed to read file: ${errorMessage(error)}`, { cause: error });
173
178
  }
174
179
  const result = moveIssueToTier(content, options.issueUrl, options.tier);
180
+ // #1355: a missing entry is a caller error, not a quiet success. Idempotent
181
+ // re-runs (already-in-target) still resolve normally.
182
+ if (result.reasonCode === 'not-found') {
183
+ throw new ValidationError(`Issue URL not found in the list: ${options.issueUrl} (${filePath}). ` +
184
+ 'Add the entry to the list first, then re-run list-move-tier.');
185
+ }
175
186
  if (result.moved) {
176
187
  try {
177
188
  fs.writeFileSync(filePath, result.content, 'utf8');
@@ -9,6 +9,8 @@ export interface MoveOutput {
9
9
  target: MoveTarget;
10
10
  /** Human-readable description of what happened. */
11
11
  description: string;
12
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
13
+ gistSyncWarning?: string;
12
14
  }
13
15
  /**
14
16
  * Move a PR between states: attention, waiting, shelved, or auto (computed).
@@ -7,6 +7,10 @@ import { ValidationError } from '../core/errors.js';
7
7
  import { PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
8
8
  const MODULE = 'move';
9
9
  export const VALID_TARGETS = ['attention', 'waiting', 'shelved', 'auto'];
10
+ /** Attach the checkpoint warning only when present, keeping the key off the wire on clean runs (#1370). */
11
+ function withGistSyncWarning(output, gistSyncWarning) {
12
+ return gistSyncWarning ? { ...output, gistSyncWarning } : output;
13
+ }
10
14
  /**
11
15
  * Move a PR between states: attention, waiting, shelved, or auto (computed).
12
16
  *
@@ -36,32 +40,32 @@ export async function runMove(options) {
36
40
  stateManager.setStatusOverride(options.prUrl, status, lastActivityAt);
37
41
  stateManager.unshelvePR(options.prUrl);
38
42
  });
39
- await maybeCheckpoint(stateManager, MODULE);
40
- return { url: options.prUrl, target, description: `Moved to ${label}` };
43
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
44
+ return withGistSyncWarning({ url: options.prUrl, target, description: `Moved to ${label}` }, gistSyncWarning);
41
45
  }
42
46
  case 'shelved': {
43
47
  stateManager.batch(() => {
44
48
  stateManager.shelvePR(options.prUrl);
45
49
  stateManager.clearStatusOverride(options.prUrl);
46
50
  });
47
- await maybeCheckpoint(stateManager, MODULE);
48
- return {
51
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
52
+ return withGistSyncWarning({
49
53
  url: options.prUrl,
50
54
  target,
51
55
  description: 'Shelved — excluded from capacity and actionable items',
52
- };
56
+ }, gistSyncWarning);
53
57
  }
54
58
  case 'auto': {
55
59
  stateManager.batch(() => {
56
60
  stateManager.clearStatusOverride(options.prUrl);
57
61
  stateManager.unshelvePR(options.prUrl);
58
62
  });
59
- await maybeCheckpoint(stateManager, MODULE);
60
- return {
63
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
64
+ return withGistSyncWarning({
61
65
  url: options.prUrl,
62
66
  target,
63
67
  description: 'Reset to computed status',
64
- };
68
+ }, gistSyncWarning);
65
69
  }
66
70
  default: {
67
71
  const _exhaustive = target;
@@ -11,7 +11,7 @@
11
11
  * no state mutation, runs against a public `owner/repo` slug.
12
12
  */
13
13
  import { getOctokit, requireGitHubToken } from '../core/index.js';
14
- import { errorMessage } from '../core/errors.js';
14
+ import { errorMessage, getHttpStatusCode, isRateLimitOrAuthError } from '../core/errors.js';
15
15
  import { warn } from '../core/logger.js';
16
16
  import { validateRepoIdentifier } from './validation.js';
17
17
  import { computeRepoVet } from '../core/repo-vet.js';
@@ -160,13 +160,30 @@ export async function runRepoVet(options) {
160
160
  const token = requireGitHubToken();
161
161
  const octokit = getOctokit(token);
162
162
  const now = new Date();
163
+ // Tracks the release-list analogue of communityHealth's `incomplete`:
164
+ // set when the listReleases call failed for a reason that does NOT
165
+ // prove absence (5xx, network), so the caller can distinguish "repo
166
+ // has no releases" from "couldn't check releases" (#1373).
167
+ let releasesIncomplete = false;
163
168
  const [repoMetaResp, closedPRsResp, commitsResp, releasesResp, communityHealthSummary] = await Promise.all([
164
169
  octokit.repos.get({ owner, repo }),
165
170
  octokit.pulls.list({ owner, repo, state: 'closed', sort: 'updated', direction: 'desc', per_page: 100 }),
166
171
  octokit.repos.listCommits({ owner, repo, per_page: 100 }),
167
- octokit.repos
168
- .listReleases({ owner, repo, per_page: 1 })
169
- .catch(() => ({ data: [] })),
172
+ octokit.repos.listReleases({ owner, repo, per_page: 1 }).catch((err) => {
173
+ // Rate-limit / auth failures must abort the run, same as every
174
+ // sibling fetch under throttling, a blanket catch here would
175
+ // silently report "no releases" for the whole vet batch (#1373).
176
+ if (isRateLimitOrAuthError(err))
177
+ throw err;
178
+ const empty = { data: [] };
179
+ // 404 is a definitive "nothing there" — genuine absence, not a gap.
180
+ if (getHttpStatusCode(err) === 404)
181
+ return empty;
182
+ // 5xx / network: absence is unproven. Degrade gracefully but flag it.
183
+ releasesIncomplete = true;
184
+ warn(MODULE, `release listing for ${owner}/${repo} failed: ${errorMessage(err)} — release-recency signal is incomplete`);
185
+ return empty;
186
+ }),
170
187
  checkCommunityHealth(octokit, owner, repo),
171
188
  ]);
172
189
  const prs = closedPRsResp.data.map((p) => ({
@@ -201,15 +218,20 @@ export async function runRepoVet(options) {
201
218
  const result = computeRepoVet(input);
202
219
  // The core function names its metadata object `repo`. Rename to `repoMeta`
203
220
  // at the CLI boundary so the top-level slug doesn't collide with it.
204
- // Also overlay the community-health `incomplete` flag the wrapper
205
- // tracks (the core type doesn't carry it because computeRepoVet is
206
- // pure — only the wrapper makes the API calls that can fail mid-probe).
207
- const { repo: repoMeta, communityHealth, ...rest } = result;
221
+ // Also overlay the community-health `incomplete` and the
222
+ // `releasesIncomplete` flags the wrapper tracks (the core type doesn't
223
+ // carry them because computeRepoVet is pure — only the wrapper makes
224
+ // the API calls that can fail mid-probe). Like communityHealth's
225
+ // incomplete flag, releasesIncomplete deliberately does NOT alter the
226
+ // rubric score — the score reflects the fetched signals as-is and the
227
+ // flag tells consumers which signal was unverified.
228
+ const { repo: repoMeta, communityHealth, maintainerActivity, ...rest } = result;
208
229
  return {
209
230
  repoSlug: options.repo,
210
231
  fetchedAt: now.toISOString(),
211
232
  repoMeta,
212
233
  communityHealth: { ...communityHealth, incomplete: communityHealthSummary.incomplete },
234
+ maintainerActivity: { ...maintainerActivity, releasesIncomplete },
213
235
  ...rest,
214
236
  };
215
237
  }
@@ -2,8 +2,8 @@
2
2
  * Search command
3
3
  * Searches for new issues to work on via @oss-scout/core
4
4
  */
5
- import { buildCandidateLinkedPR, createAutopilotScout } from './scout-bridge.js';
6
- import { getStateManager } from '../core/index.js';
5
+ import { adaptScoutLinkedPR, buildCandidateLinkedPR, createAutopilotScout } from './scout-bridge.js';
6
+ import { classifyLinkedPR, getStateManager } from '../core/index.js';
7
7
  import { gradeFromCandidate } from '../core/issue-grading.js';
8
8
  import { computeStrategy } from '../core/strategy.js';
9
9
  import { debug, warn } from '../core/logger.js';
@@ -64,8 +64,21 @@ export async function runSearch(options) {
64
64
  preferLanguages,
65
65
  preferRepos,
66
66
  });
67
+ // #1354: never surface issues the user already has an open PR for. Uses
68
+ // scout's structured linked-PR metadata when present; candidates without it
69
+ // pass through (the issue-scout agent re-checks via verify-issue anyway).
70
+ // Empty login means "can't prove any PR is the user's own" — nothing hidden.
71
+ const userLogin = stateManager.getState().config?.githubUsername ?? '';
72
+ if (userLogin === '') {
73
+ warn(MODULE, 'githubUsername not configured — the own-PR filter (#1354) cannot run; hiddenOwnPRCount will be 0');
74
+ }
75
+ const visibleCandidates = result.candidates.filter((c) => classifyLinkedPR({ linkedPR: adaptScoutLinkedPR(c.vettingResult?.linkedPR), userLogin }) !== 'user_open');
76
+ const hiddenOwnPRCount = result.candidates.length - visibleCandidates.length;
77
+ if (hiddenOwnPRCount > 0) {
78
+ debug(MODULE, `Hid ${hiddenOwnPRCount} candidate(s) with the user's own open PR (#1354)`);
79
+ }
67
80
  const searchOutput = {
68
- candidates: result.candidates.map((c) => {
81
+ candidates: visibleCandidates.map((c) => {
69
82
  const repoScoreRecord = stateManager.getRepoScore(c.issue.repo);
70
83
  // Scout's `search` does not emit per-candidate projectHealth (only
71
84
  // `vetIssue` does). Pass a sentinel `checkFailed: true` so the grader
@@ -119,6 +132,7 @@ export async function runSearch(options) {
119
132
  }),
120
133
  excludedRepos: result.excludedRepos,
121
134
  aiPolicyBlocklist: result.aiPolicyBlocklist,
135
+ hiddenOwnPRCount,
122
136
  };
123
137
  if (result.rateLimitWarning) {
124
138
  searchOutput.rateLimitWarning = result.rateLimitWarning;
@@ -11,10 +11,14 @@ import { PR_URL_PATTERN } from './validation.js';
11
11
  export interface ShelveOutput {
12
12
  shelved: boolean;
13
13
  url: string;
14
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
15
+ gistSyncWarning?: string;
14
16
  }
15
17
  export interface UnshelveOutput {
16
18
  unshelved: boolean;
17
19
  url: string;
20
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
21
+ gistSyncWarning?: string;
18
22
  }
19
23
  export { PR_URL_PATTERN };
20
24
  /**
@@ -29,8 +29,8 @@ export async function runShelve(options) {
29
29
  added = stateManager.shelvePR(options.prUrl);
30
30
  stateManager.clearStatusOverride(options.prUrl);
31
31
  });
32
- await maybeCheckpoint(stateManager, MODULE);
33
- return { shelved: added, url: options.prUrl };
32
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
33
+ return { shelved: added, url: options.prUrl, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
34
34
  }
35
35
  /**
36
36
  * Unshelve a PR, restoring it to the daily digest.
@@ -49,6 +49,6 @@ export async function runUnshelve(options) {
49
49
  removed = stateManager.unshelvePR(options.prUrl);
50
50
  stateManager.clearStatusOverride(options.prUrl);
51
51
  });
52
- await maybeCheckpoint(stateManager, MODULE);
53
- return { unshelved: removed, url: options.prUrl };
52
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
53
+ return { unshelved: removed, url: options.prUrl, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
54
54
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * verify-issue command (#1353, #1354).
3
+ *
4
+ * Deterministic availability check for one GitHub issue: state/stateReason,
5
+ * assignees, and linked PRs classified as `closing` vs `cross-referenced`,
6
+ * with the authenticated user's own PRs flagged. One GraphQL round-trip,
7
+ * no scoring, no scout heuristics — this is the ground truth the
8
+ * issue-scout agent consumes BEFORE any judgment-based vetting.
9
+ */
10
+ import { type IssueVerification } from '../core/index.js';
11
+ export interface VerifyIssueOptions {
12
+ issueUrl: string;
13
+ }
14
+ /**
15
+ * Verify a GitHub issue's real availability.
16
+ *
17
+ * @throws {ValidationError} If the URL is not a valid GitHub issue URL or
18
+ * the issue does not exist.
19
+ */
20
+ export declare function runVerifyIssue(options: VerifyIssueOptions): Promise<IssueVerification>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * verify-issue command (#1353, #1354).
3
+ *
4
+ * Deterministic availability check for one GitHub issue: state/stateReason,
5
+ * assignees, and linked PRs classified as `closing` vs `cross-referenced`,
6
+ * with the authenticated user's own PRs flagged. One GraphQL round-trip,
7
+ * no scoring, no scout heuristics — this is the ground truth the
8
+ * issue-scout agent consumes BEFORE any judgment-based vetting.
9
+ */
10
+ import { fetchIssueVerification, getOctokit, parseGitHubUrl, requireGitHubToken, } from '../core/index.js';
11
+ import { ValidationError } from '../core/errors.js';
12
+ import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
13
+ /**
14
+ * Verify a GitHub issue's real availability.
15
+ *
16
+ * @throws {ValidationError} If the URL is not a valid GitHub issue URL or
17
+ * the issue does not exist.
18
+ */
19
+ export async function runVerifyIssue(options) {
20
+ validateUrl(options.issueUrl);
21
+ validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, 'issue');
22
+ const parsed = parseGitHubUrl(options.issueUrl);
23
+ if (!parsed || parsed.type !== 'issues') {
24
+ throw new ValidationError(`Not a parseable GitHub issue URL: ${options.issueUrl}`);
25
+ }
26
+ const octokit = getOctokit(requireGitHubToken());
27
+ return fetchIssueVerification(octokit, {
28
+ owner: parsed.owner,
29
+ repo: parsed.repo,
30
+ number: parsed.number,
31
+ });
32
+ }