@oss-autopilot/core 3.11.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.
@@ -15,6 +15,7 @@
15
15
  * (daily-logic.test.ts) — pure rendering, easy to assert on string output.
16
16
  */
17
17
  import type { CapacityAssessment, CommentedIssue, CommentedIssueWithResponse, DailyDigest, MaintainerActionHint } from '../core/types.js';
18
+ import type { AttentionSummary } from '../core/pr-attention.js';
18
19
  /**
19
20
  * Format a maintainer action hint as a human-readable label.
20
21
  */
@@ -24,7 +25,7 @@ export declare function formatActionHint(hint: MaintainerActionHint): string;
24
25
  *
25
26
  * @returns One-line status string (e.g., "3 Active PRs | 1 needs attention | 2 issue replies")
26
27
  */
27
- export declare function formatBriefSummary(digest: DailyDigest, issueCount: number, issueResponseCount?: number): string;
28
+ export declare function formatBriefSummary(digest: DailyDigest, issueCount: number, issueResponseCount?: number, attention?: Pick<AttentionSummary, 'stuckCI' | 'dormantFollowup'>): string;
28
29
  /**
29
30
  * Format the full dashboard summary as markdown.
30
31
  * Used in JSON output for Claude to display verbatim — includes all PR sections,
@@ -42,10 +42,16 @@ export function formatActionHint(hint) {
42
42
  *
43
43
  * @returns One-line status string (e.g., "3 Active PRs | 1 needs attention | 2 issue replies")
44
44
  */
45
- export function formatBriefSummary(digest, issueCount, issueResponseCount = 0) {
45
+ export function formatBriefSummary(digest, issueCount, issueResponseCount = 0, attention) {
46
46
  const attentionText = issueCount > 0 ? `${issueCount} need${issueCount === 1 ? 's' : ''} attention` : 'all on track';
47
47
  const issueReplyText = issueResponseCount > 0 ? ` | ${issueResponseCount} issue repl${issueResponseCount === 1 ? 'y' : 'ies'}` : '';
48
- return `\u{1F4CA} ${digest.summary.totalActivePRs} Active PRs | ${attentionText}${issueReplyText}`;
48
+ // #1352: the watch-style buckets are appended only when non-zero — they're
49
+ // ping/nudge workflows, not part of the headline "need attention" count.
50
+ const stuckText = attention && attention.stuckCI > 0 ? ` | ${attention.stuckCI} stuck CI` : '';
51
+ const dormantText = attention && attention.dormantFollowup > 0
52
+ ? ` | ${attention.dormantFollowup} dormant follow-up${attention.dormantFollowup === 1 ? '' : 's'}`
53
+ : '';
54
+ return `\u{1F4CA} ${digest.summary.totalActivePRs} Active PRs | ${attentionText}${issueReplyText}${stuckText}${dormantText}`;
49
55
  }
50
56
  /**
51
57
  * Format the full dashboard summary as markdown.
@@ -6,7 +6,7 @@
6
6
  * Domain logic lives in src/core/daily-logic.ts; this file is a thin
7
7
  * orchestration layer that wires up the phases and handles I/O.
8
8
  */
9
- import { type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup } from '../core/index.js';
9
+ import { type AttentionSummary, type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup } from '../core/index.js';
10
10
  import { type StrategyResult } from '../core/strategy.js';
11
11
  import { type DailyOutput, type DailyWarning, type CapacityAssessment, type ActionableIssue, type ActionMenu } from '../formatters/json.js';
12
12
  export { applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
@@ -23,6 +23,8 @@ export interface DailyCheckResult {
23
23
  summary: string;
24
24
  briefSummary: string;
25
25
  actionableIssues: ActionableIssue[];
26
+ /** Unified attention-bucket counts over active PRs (#1352). */
27
+ attention: AttentionSummary;
26
28
  actionMenu: ActionMenu;
27
29
  commentedIssues: CommentedIssue[];
28
30
  repoGroups: RepoGroup[];
@@ -6,7 +6,7 @@
6
6
  * Domain logic lives in src/core/daily-logic.ts; this file is a thin
7
7
  * orchestration layer that wires up the phases and handles I/O.
8
8
  */
9
- import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
9
+ import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, summarizeAttentionBuckets, } from '../core/index.js';
10
10
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
11
11
  import { wrapUntrustedContent } from '../core/untrusted-content.js';
12
12
  import { computeStrategy, shouldComputeStrategy } from '../core/strategy.js';
@@ -420,7 +420,10 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
420
420
  // Auto-undismiss mutations are auto-saved by undismissIssue()
421
421
  const actionableIssues = collectActionableIssues(activePRs, previousLastDigestAt);
422
422
  digest.summary.totalNeedingAttention = actionableIssues.length;
423
- const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
423
+ // #1352: one classifier for all attention surfaces — the dashboard stamps
424
+ // the same buckets per-PR, so the headline counts cannot diverge.
425
+ const attention = summarizeAttentionBuckets(activePRs);
426
+ const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length, attention);
424
427
  const actionMenu = computeActionMenu(actionableIssues, capacity, filteredCommentedIssues);
425
428
  const repoGroups = groupPRsByRepo(activePRs);
426
429
  // Periodic strategy snapshot (#1270 Step 2). Cadence-gated to fire every
@@ -447,6 +450,7 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
447
450
  summary,
448
451
  briefSummary,
449
452
  actionableIssues,
453
+ attention,
450
454
  actionMenu,
451
455
  commentedIssues: filteredCommentedIssues,
452
456
  repoGroups,
@@ -502,6 +506,7 @@ export function toDailyOutput(result) {
502
506
  summary: result.summary,
503
507
  briefSummary: result.briefSummary,
504
508
  actionableIssues: compactActionableIssues(result.actionableIssues),
509
+ attention: result.attention,
505
510
  actionMenu: result.actionMenu,
506
511
  commentedIssues: result.commentedIssues.map(fenceCommentedIssue),
507
512
  repoGroups: compactRepoGroups(result.repoGroups),
@@ -3,7 +3,7 @@
3
3
  * Handles GitHub API calls, PR grouping, stats computation, and monthly chart data.
4
4
  * Consumed by the dashboard HTTP server (dashboard-server.ts) for the SPA API.
5
5
  */
6
- import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit } from '../core/index.js';
6
+ import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit, CRITICAL_STATUSES } from '../core/index.js';
7
7
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
8
8
  import { warn } from '../core/logger.js';
9
9
  import { emptyPRCountsResult, fetchMergedPRsSince, fetchClosedPRsSince } from '../core/github-stats.js';
@@ -39,9 +39,16 @@ export function buildDashboardStats(digest, state, storedMergedCount, storedClos
39
39
  * A PR is shelved for display when the user explicitly shelved it, or the
40
40
  * dormant-auto-shelve rule applies (dormant + not needing attention). Mirrors
41
41
  * the partition built in fetchDashboardData (see the `freshShelved` filter).
42
+ *
43
+ * #1352: an explicitly shelved PR that turns critical is NOT shelved for
44
+ * display — the daily check auto-unshelves it (`CRITICAL_STATUSES`), so the
45
+ * dashboard must agree immediately rather than diverging from the CLI's
46
+ * headline count until the next daily run.
42
47
  */
43
48
  function isShelvedForDisplay(pr, explicitlyShelved) {
44
- return explicitlyShelved.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing');
49
+ if (CRITICAL_STATUSES.has(pr.status))
50
+ return false;
51
+ return explicitlyShelved.has(pr.url) || pr.stalenessTier === 'dormant';
45
52
  }
46
53
  /**
47
54
  * Re-derive `digest.shelvedPRs` and `summary.totalActivePRs` from the CURRENT
@@ -254,10 +261,12 @@ export async function fetchDashboardData(token) {
254
261
  const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
255
262
  updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
256
263
  const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
257
- // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
258
- // Dormant PRs are treated as shelved unless they need addressing
264
+ // Apply shelve partitioning for display (auto-unshelve only runs in daily check).
265
+ // Dormant PRs are treated as shelved unless they need addressing, and a
266
+ // critical PR is never display-shelved (#1352) — mirrors the daily
267
+ // check's CRITICAL_STATUSES auto-unshelve so headline counts agree.
259
268
  const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
260
- const freshShelved = prs.filter((pr) => shelvedUrls.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing'));
269
+ const freshShelved = prs.filter((pr) => !CRITICAL_STATUSES.has(pr.status) && (shelvedUrls.has(pr.url) || pr.stalenessTier === 'dormant'));
261
270
  digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
262
271
  digest.autoUnshelvedPRs = [];
263
272
  digest.summary.totalActivePRs = prs.length - freshShelved.length;
@@ -9,7 +9,7 @@ import * as http from 'node:http';
9
9
  import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import * as crypto from 'node:crypto';
12
- import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } from '../core/index.js';
12
+ import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, } from '../core/index.js';
13
13
  import { errorMessage, ValidationError, ConcurrencyError, GistConcurrencyError } from '../core/errors.js';
14
14
  import { warn } from '../core/logger.js';
15
15
  import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
@@ -113,7 +113,12 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
113
113
  monthlyMerged,
114
114
  monthlyOpened,
115
115
  monthlyClosed,
116
- activePRs: applyStatusOverrides(digest.openPRs || [], state),
116
+ // #1352: stamp the unified attention bucket so the SPA renders the same
117
+ // taxonomy the CLI brief counts (single classifier, no second opinion).
118
+ activePRs: applyStatusOverrides(digest.openPRs || [], state).map((pr) => ({
119
+ ...pr,
120
+ attentionBucket: classifyAttentionBucket(pr),
121
+ })),
117
122
  // Source of truth is digest.shelvedPRs (union of explicitly-shelved URLs
118
123
  // and dormant-non-addressing PRs auto-shelved for display). Returning
119
124
  // only state.config.shelvedPRUrls would under-count and desync from
@@ -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). */
@@ -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 ───────────────────────────────────────────────────────────
@@ -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');
@@ -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;
@@ -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
+ }
@@ -248,62 +248,75 @@ export function collectActionableIssues(prs, lastDigestAt) {
248
248
  'merge_conflict',
249
249
  'incomplete_checklist',
250
250
  ];
251
- for (const reason of reasonOrder) {
252
- for (const pr of actionPRs) {
253
- if (pr.actionReason !== reason)
254
- continue;
255
- let label;
256
- let type;
257
- switch (reason) {
258
- case 'needs_response': {
259
- label = '[Needs Response]';
260
- type = 'needs_response';
261
- break;
262
- }
263
- case 'needs_changes': {
264
- label = '[Needs Changes]';
265
- type = 'needs_changes';
266
- break;
267
- }
268
- case 'failing_ci': {
269
- const checkInfo = pr.failingCheckNames.length > 0 ? ` (${pr.failingCheckNames.join(', ')})` : '';
270
- label = `[CI Failing${checkInfo}]`;
271
- type = 'ci_failing';
272
- break;
273
- }
274
- case 'merge_conflict': {
275
- label = '[Merge Conflict]';
276
- type = 'merge_conflict';
277
- break;
278
- }
279
- case 'incomplete_checklist': {
280
- const stats = pr.checklistStats ? ` (${pr.checklistStats.checked}/${pr.checklistStats.total})` : '';
281
- label = `[Incomplete Checklist${stats}]`;
282
- type = 'incomplete_checklist';
283
- break;
284
- }
285
- default: {
286
- // Defensive fallback for ActionReason values not explicitly handled
287
- // above (e.g. ci_not_running, needs_rebase, missing_required_files).
288
- // These aren't in reasonOrder today but this guards future additions.
289
- warn('daily-logic', `Unhandled ActionReason "${reason}" for PR ${pr.url} — falling back to needs_response`);
290
- label = `[${reason}]`;
291
- type = 'needs_response';
292
- }
251
+ // #1352: every needs_addressing PR produces exactly one entry, including
252
+ // PRs whose actionReason is missing or outside reasonOrder (the defensive
253
+ // default below). This keeps the CLI brief's count equal to the dashboard's
254
+ // needs_attention bucket by construction — previously an unmapped reason
255
+ // silently dropped the PR from the brief while the dashboard still counted
256
+ // it. Unmapped reasons sort last.
257
+ const sortedPRs = [...actionPRs].sort((a, b) => {
258
+ const rank = (pr) => {
259
+ const i = reasonOrder.indexOf(pr.actionReason);
260
+ return i === -1 ? reasonOrder.length : i;
261
+ };
262
+ return rank(a) - rank(b);
263
+ });
264
+ for (const pr of sortedPRs) {
265
+ if (pr.actionReason === undefined) {
266
+ warn('daily-logic', `needs_addressing PR ${pr.url} has no actionReason — defaulting to needs_response`);
267
+ }
268
+ const reason = pr.actionReason ?? 'needs_response';
269
+ let label;
270
+ let type;
271
+ switch (reason) {
272
+ case 'needs_response': {
273
+ label = '[Needs Response]';
274
+ type = 'needs_response';
275
+ break;
276
+ }
277
+ case 'needs_changes': {
278
+ label = '[Needs Changes]';
279
+ type = 'needs_changes';
280
+ break;
281
+ }
282
+ case 'failing_ci': {
283
+ const checkInfo = pr.failingCheckNames.length > 0 ? ` (${pr.failingCheckNames.join(', ')})` : '';
284
+ label = `[CI Failing${checkInfo}]`;
285
+ type = 'ci_failing';
286
+ break;
287
+ }
288
+ case 'merge_conflict': {
289
+ label = '[Merge Conflict]';
290
+ type = 'merge_conflict';
291
+ break;
293
292
  }
294
- // A PR is "new" if it was created after the last daily digest (first time seen).
295
- // If there's no previous digest (first run) or createdAt is invalid, assume new.
296
- const createdTime = new Date(pr.createdAt).getTime();
297
- let isNewContribution;
298
- if (isNaN(createdTime)) {
299
- warn('daily-logic', `Invalid createdAt "${pr.createdAt}" for PR ${pr.url}, assuming new contribution`);
300
- isNewContribution = true;
293
+ case 'incomplete_checklist': {
294
+ const stats = pr.checklistStats ? ` (${pr.checklistStats.checked}/${pr.checklistStats.total})` : '';
295
+ label = `[Incomplete Checklist${stats}]`;
296
+ type = 'incomplete_checklist';
297
+ break;
301
298
  }
302
- else {
303
- isNewContribution = isNaN(lastDigestTime) || createdTime > lastDigestTime;
299
+ default: {
300
+ // Defensive fallback for ActionReason values not explicitly handled
301
+ // above (e.g. ci_not_running, needs_rebase, missing_required_files).
302
+ // These aren't in reasonOrder today but this guards future additions.
303
+ warn('daily-logic', `Unhandled ActionReason "${reason}" for PR ${pr.url} — falling back to needs_response`);
304
+ label = `[${reason}]`;
305
+ type = 'needs_response';
304
306
  }
305
- issues.push({ type, pr, label, isNewContribution });
306
307
  }
308
+ // A PR is "new" if it was created after the last daily digest (first time seen).
309
+ // If there's no previous digest (first run) or createdAt is invalid, assume new.
310
+ const createdTime = new Date(pr.createdAt).getTime();
311
+ let isNewContribution;
312
+ if (isNaN(createdTime)) {
313
+ warn('daily-logic', `Invalid createdAt "${pr.createdAt}" for PR ${pr.url}, assuming new contribution`);
314
+ isNewContribution = true;
315
+ }
316
+ else {
317
+ isNewContribution = isNaN(lastDigestTime) || createdTime > lastDigestTime;
318
+ }
319
+ issues.push({ type, pr, label, isNewContribution });
307
320
  }
308
321
  return issues;
309
322
  }
@@ -22,6 +22,8 @@ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsBy
22
22
  export { computeContributionStats, type ContributionStats, type ComputeStatsInput } from './stats.js';
23
23
  export { fetchPRTemplate, type PRTemplateResult } from './pr-template.js';
24
24
  export { classifyLinkedPR, isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, type LinkedPR, type LinkedPRClassification, type LinkedPRState, } from './linked-pr-classification.js';
25
+ export { classifyIssueAvailability, fetchIssueVerification, type IssueAvailabilityVerdict, type IssueVerification, type LinkedPRLinkType, type VerifiedLinkedPR, type VerifyIssueParams, } from './issue-verification.js';
26
+ export { classifyAttentionBucket, summarizeAttentionBuckets, STUCK_CI_THRESHOLD_DAYS, DORMANT_FOLLOWUP_THRESHOLD_DAYS, type AttentionBucket, type AttentionInput, type AttentionSummary, } from './pr-attention.js';
25
27
  export { scanForAntiLLMPolicy, type AntiLLMCategory, type AntiLLMMatch, type AntiLLMScanResult, } from './anti-llm-policy.js';
26
28
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, type DetectedFormatter, type FormatterDetectionResult, type CIFormatterDiagnosis, type FormatterName, } from './formatter-detection.js';
27
29
  export { CONFIG_KEY_REGISTRY, type ConfigKeyDef, type SettableVia, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';
@@ -23,6 +23,8 @@ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsBy
23
23
  export { computeContributionStats } from './stats.js';
24
24
  export { fetchPRTemplate } from './pr-template.js';
25
25
  export { classifyLinkedPR, isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, } from './linked-pr-classification.js';
26
+ export { classifyIssueAvailability, fetchIssueVerification, } from './issue-verification.js';
27
+ export { classifyAttentionBucket, summarizeAttentionBuckets, STUCK_CI_THRESHOLD_DAYS, DORMANT_FOLLOWUP_THRESHOLD_DAYS, } from './pr-attention.js';
26
28
  export { scanForAntiLLMPolicy, } from './anti-llm-policy.js';
27
29
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, } from './formatter-detection.js';
28
30
  export { CONFIG_KEY_REGISTRY, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Deterministic issue verification (#1353, #1354).
3
+ *
4
+ * One GraphQL round-trip answers the two questions the issue-scout agent
5
+ * historically hallucinated: "is this issue actually open?" and "is it
6
+ * actually taken?". The query fetches the issue's `state`/`stateReason`,
7
+ * its assignees, every cross-referenced PR with `closingIssuesReferences`,
8
+ * and `viewer.login` — so "is this my PR?" is derived from the same token
9
+ * that fetched the data instead of a cached username.
10
+ *
11
+ * Classification is a pure function (`classifyIssueAvailability`) so the
12
+ * verdict rules are unit-testable without network access. The distinction
13
+ * that matters (#1353): a PR whose `closingIssuesReferences` includes this
14
+ * issue is a real claim (`closing`); a PR that merely mentions the issue in
15
+ * its timeline is just a mention (`cross-referenced`) and must not drive a
16
+ * "Taken" verdict.
17
+ */
18
+ import type { Octokit } from '@octokit/rest';
19
+ /** How a PR found in the issue's timeline relates to the issue. */
20
+ export type LinkedPRLinkType = 'closing' | 'cross-referenced';
21
+ export interface VerifiedLinkedPR {
22
+ number: number;
23
+ url: string;
24
+ title: string;
25
+ /** Lowercase normalization of GraphQL's OPEN/CLOSED/MERGED union. */
26
+ state: 'open' | 'closed' | 'merged';
27
+ isDraft: boolean;
28
+ /** PR author login; null for ghost (deleted) accounts. */
29
+ author: string | null;
30
+ /** True when the PR author is the authenticated user (case-insensitive). */
31
+ isOwn: boolean;
32
+ /** `closing` = the PR's closingIssuesReferences names this issue (a real
33
+ * claim). `cross-referenced` = timeline mention only (NOT a claim). */
34
+ linkType: LinkedPRLinkType;
35
+ updatedAt: string | null;
36
+ }
37
+ /**
38
+ * The short-circuit signal consumers route on:
39
+ * - `closed` — issue is not open; stop all further analysis (#1353).
40
+ * - `own-open-pr` — the authenticated user already has an open closing PR;
41
+ * not a new opportunity (#1354).
42
+ * - `taken` — someone else has an open closing PR, or the issue is assigned
43
+ * to someone else.
44
+ * - `at-risk` — no hard claim, but signals competition or imminent closure
45
+ * (open cross-referencing PRs, or a merged closing PR awaiting issue close).
46
+ * - `available` — open, unassigned, no claiming PRs.
47
+ */
48
+ export type IssueAvailabilityVerdict = 'closed' | 'own-open-pr' | 'taken' | 'at-risk' | 'available';
49
+ export interface IssueVerification {
50
+ url: string;
51
+ owner: string;
52
+ repo: string;
53
+ number: number;
54
+ title: string;
55
+ /** Lowercase normalization of GraphQL's OPEN/CLOSED union. */
56
+ state: 'open' | 'closed';
57
+ /** Lowercase GraphQL IssueStateReason. `completed` = fixed upstream (mark
58
+ * Done); `not_planned` = wontfix (drop permanently). Note: an OPEN issue
59
+ * can carry `reopened` — never infer closed-ness from a non-null reason;
60
+ * branch on `state` only. */
61
+ stateReason: 'completed' | 'not_planned' | 'reopened' | 'duplicate' | null;
62
+ closedAt: string | null;
63
+ assignees: string[];
64
+ linkedPRs: VerifiedLinkedPR[];
65
+ verdict: IssueAvailabilityVerdict;
66
+ verdictReason: string;
67
+ /** Login of the authenticated user the verdict was computed for. */
68
+ userLogin: string;
69
+ }
70
+ /** Inputs for the pure verdict classifier — the fetched facts, no I/O. */
71
+ export interface ClassifyIssueAvailabilityInput {
72
+ state: 'open' | 'closed';
73
+ stateReason: IssueVerification['stateReason'];
74
+ assignees: string[];
75
+ linkedPRs: VerifiedLinkedPR[];
76
+ userLogin: string;
77
+ }
78
+ /**
79
+ * Compute the availability verdict from fetched facts. Rules in priority
80
+ * order; the first match wins.
81
+ */
82
+ export declare function classifyIssueAvailability(input: ClassifyIssueAvailabilityInput): {
83
+ verdict: IssueAvailabilityVerdict;
84
+ verdictReason: string;
85
+ };
86
+ export interface VerifyIssueParams {
87
+ owner: string;
88
+ repo: string;
89
+ number: number;
90
+ }
91
+ export declare function fetchIssueVerification(octokit: Octokit, params: VerifyIssueParams): Promise<IssueVerification>;