@oss-autopilot/core 3.11.0 → 3.13.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.
@@ -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>;
@@ -0,0 +1,270 @@
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 { ValidationError } from './errors.js';
19
+ function isSameLogin(a, b) {
20
+ return a !== null && a !== '' && b !== '' && a.toLowerCase() === b.toLowerCase();
21
+ }
22
+ /**
23
+ * Compute the availability verdict from fetched facts. Rules in priority
24
+ * order; the first match wins.
25
+ */
26
+ export function classifyIssueAvailability(input) {
27
+ const { state, stateReason, assignees, linkedPRs, userLogin } = input;
28
+ if (state === 'closed') {
29
+ const reason = stateReason ?? 'unknown';
30
+ return {
31
+ verdict: 'closed',
32
+ verdictReason: reason === 'not_planned'
33
+ ? 'issue closed as not planned — drop it permanently'
34
+ : `issue closed (${reason}) — mark it done upstream`,
35
+ };
36
+ }
37
+ const closingPRs = linkedPRs.filter((pr) => pr.linkType === 'closing');
38
+ const ownOpenClaim = closingPRs.find((pr) => pr.state === 'open' && pr.isOwn);
39
+ if (ownOpenClaim) {
40
+ return {
41
+ verdict: 'own-open-pr',
42
+ verdictReason: `you already have an open PR for this issue: ${ownOpenClaim.url}`,
43
+ };
44
+ }
45
+ const otherOpenClaim = closingPRs.find((pr) => pr.state === 'open' && !pr.isOwn);
46
+ if (otherOpenClaim) {
47
+ return {
48
+ verdict: 'taken',
49
+ verdictReason: `open PR by ${otherOpenClaim.author ?? 'unknown author'} closes this issue: ${otherOpenClaim.url}`,
50
+ };
51
+ }
52
+ const otherAssignees = assignees.filter((login) => !isSameLogin(login, userLogin));
53
+ if (otherAssignees.length > 0) {
54
+ return {
55
+ verdict: 'taken',
56
+ verdictReason: `assigned to ${otherAssignees.join(', ')}`,
57
+ };
58
+ }
59
+ const mergedClaim = closingPRs.find((pr) => pr.state === 'merged');
60
+ if (mergedClaim) {
61
+ return {
62
+ verdict: 'at-risk',
63
+ verdictReason: `a merged PR closes this issue (${mergedClaim.url}) — likely resolved, awaiting issue close`,
64
+ };
65
+ }
66
+ const openMention = linkedPRs.find((pr) => pr.linkType === 'cross-referenced' && pr.state === 'open');
67
+ if (openMention) {
68
+ return {
69
+ verdict: 'at-risk',
70
+ verdictReason: `open PR ${openMention.url} cross-references this issue (mention only, not a claim) — ` +
71
+ 'risk of being superseded by broader work',
72
+ };
73
+ }
74
+ // Self-assignment was filtered out above — don't claim "unassigned" then.
75
+ const selfAssigned = assignees.length > 0;
76
+ return {
77
+ verdict: 'available',
78
+ verdictReason: selfAssigned ? 'open, assigned to you, no claiming PRs' : 'open, unassigned, no claiming PRs',
79
+ };
80
+ }
81
+ // ── GraphQL fetch ──────────────────────────────────────────────────────
82
+ const VERIFY_ISSUE_QUERY = /* GraphQL */ `
83
+ query VerifyIssue($owner: String!, $repo: String!, $number: Int!, $after: String) {
84
+ viewer {
85
+ login
86
+ }
87
+ repository(owner: $owner, name: $repo) {
88
+ issue(number: $number) {
89
+ number
90
+ title
91
+ url
92
+ state
93
+ stateReason
94
+ closedAt
95
+ assignees(first: 10) {
96
+ nodes {
97
+ login
98
+ }
99
+ }
100
+ timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], first: 100, after: $after) {
101
+ pageInfo {
102
+ hasNextPage
103
+ endCursor
104
+ }
105
+ nodes {
106
+ ... on CrossReferencedEvent {
107
+ source {
108
+ ... on PullRequest {
109
+ number
110
+ url
111
+ title
112
+ state
113
+ isDraft
114
+ updatedAt
115
+ author {
116
+ login
117
+ }
118
+ closingIssuesReferences(first: 50) {
119
+ nodes {
120
+ number
121
+ repository {
122
+ nameWithOwner
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ `;
135
+ /**
136
+ * Fetch + classify in one GraphQL round-trip.
137
+ *
138
+ * Throws `ValidationError` when the issue does not exist (or the URL points
139
+ * at a PR — GitHub's `issue()` resolver returns null for PR numbers).
140
+ * GraphQL/network/rate-limit errors propagate to the caller; nothing is
141
+ * swallowed into a degraded verdict.
142
+ */
143
+ /** True when a GraphQL error response is purely "the repo/issue we asked for
144
+ * doesn't exist" — a caller-input problem, not an API failure. Requires every
145
+ * error to be NOT_FOUND *and* rooted at the repository/issue path: a nested
146
+ * NOT_FOUND (e.g. a cross-referenced source in a deleted repo) must not be
147
+ * misdiagnosed as "issue not found". Duck-typed so we don't import octokit's
148
+ * GraphqlResponseError class just for instanceof. */
149
+ function isGraphqlNotFound(error) {
150
+ const errors = error?.errors;
151
+ if (!Array.isArray(errors) || errors.length === 0)
152
+ return false;
153
+ return errors.every((e) => {
154
+ if (e?.type !== 'NOT_FOUND')
155
+ return false;
156
+ const path = Array.isArray(e.path) ? e.path : [];
157
+ return path.length <= 2 && (path.length === 0 || (path[0] === 'repository' && (path[1] ?? 'issue') === 'issue'));
158
+ });
159
+ }
160
+ /**
161
+ * Hard ceiling on timeline pages (100 events each). Timeline events arrive
162
+ * oldest-first, so silently stopping early would drop the NEWEST events —
163
+ * exactly the ones most likely to be the active claiming PR. Past the cap we
164
+ * fail loudly instead of returning a possibly-false `available`.
165
+ */
166
+ const MAX_TIMELINE_PAGES = 20;
167
+ export async function fetchIssueVerification(octokit, params) {
168
+ const { owner, repo, number } = params;
169
+ let issue;
170
+ let userLogin;
171
+ const timelineNodes = [];
172
+ let after = null;
173
+ for (let page = 0;; page++) {
174
+ if (page >= MAX_TIMELINE_PAGES) {
175
+ throw new Error(`Issue ${owner}/${repo}#${number} has more than ${MAX_TIMELINE_PAGES * 100} cross-reference events; ` +
176
+ 'refusing to verify on a truncated timeline. Check the issue manually.');
177
+ }
178
+ let response;
179
+ try {
180
+ response = await octokit.graphql(VERIFY_ISSUE_QUERY, {
181
+ owner,
182
+ repo,
183
+ number,
184
+ after,
185
+ });
186
+ }
187
+ catch (error) {
188
+ // GitHub reports a missing repo/issue as a NOT_FOUND GraphQL error (the
189
+ // client throws before our null check below can run). Surface it as the
190
+ // same ValidationError; everything else (rate limits, network, auth)
191
+ // propagates untouched.
192
+ if (isGraphqlNotFound(error)) {
193
+ throw new ValidationError(`Issue not found: ${owner}/${repo}#${number}. ` +
194
+ 'Check the URL — it may point at a pull request or a deleted/private issue.');
195
+ }
196
+ throw error;
197
+ }
198
+ const pageIssue = response.repository?.issue;
199
+ if (!pageIssue) {
200
+ throw new ValidationError(`Issue not found: ${owner}/${repo}#${number}. ` +
201
+ 'Check the URL — it may point at a pull request or a deleted/private issue.');
202
+ }
203
+ issue ??= pageIssue;
204
+ userLogin = response.viewer.login;
205
+ timelineNodes.push(...pageIssue.timelineItems.nodes);
206
+ const pageInfo = pageIssue.timelineItems.pageInfo;
207
+ if (!pageInfo?.hasNextPage)
208
+ break;
209
+ after = pageInfo.endCursor;
210
+ }
211
+ // The loop above either assigns both then breaks, or throws.
212
+ if (!issue || userLogin === undefined) {
213
+ throw new Error('unreachable: timeline pagination exited without an issue');
214
+ }
215
+ const issueRepo = `${owner}/${repo}`.toLowerCase();
216
+ const linkedPRs = [];
217
+ const seenUrls = new Set();
218
+ for (const node of timelineNodes) {
219
+ const source = node?.source;
220
+ // CrossReferencedEvent sources can be issues too; PRs are the ones that
221
+ // carry a `state` from the inline fragment. Skip everything else.
222
+ if (!source || typeof source.number !== 'number' || !source.url || !source.state)
223
+ continue;
224
+ if (seenUrls.has(source.url))
225
+ continue;
226
+ seenUrls.add(source.url);
227
+ // A "closing" link must name THIS issue in THIS repo — closing references
228
+ // to same-numbered issues in other repos don't count. Deliberate cap:
229
+ // `closingIssuesReferences(first: 50)` — a PR closing 50+ issues is
230
+ // pathological; its claim on this one would downgrade to a mention.
231
+ const closesThisIssue = (source.closingIssuesReferences?.nodes ?? []).some((ref) => ref !== null && ref.number === number && ref.repository.nameWithOwner.toLowerCase() === issueRepo);
232
+ const author = source.author?.login ?? null;
233
+ linkedPRs.push({
234
+ number: source.number,
235
+ url: source.url,
236
+ title: source.title ?? '',
237
+ state: source.state.toLowerCase(),
238
+ isDraft: source.isDraft ?? false,
239
+ author,
240
+ isOwn: isSameLogin(author, userLogin),
241
+ linkType: closesThisIssue ? 'closing' : 'cross-referenced',
242
+ updatedAt: source.updatedAt ?? null,
243
+ });
244
+ }
245
+ const state = issue.state.toLowerCase();
246
+ const stateReason = (issue.stateReason?.toLowerCase() ?? null);
247
+ const assignees = issue.assignees.nodes.flatMap((n) => (n ? [n.login] : []));
248
+ const { verdict, verdictReason } = classifyIssueAvailability({
249
+ state,
250
+ stateReason,
251
+ assignees,
252
+ linkedPRs,
253
+ userLogin,
254
+ });
255
+ return {
256
+ url: issue.url,
257
+ owner,
258
+ repo,
259
+ number: issue.number,
260
+ title: issue.title,
261
+ state,
262
+ stateReason,
263
+ closedAt: issue.closedAt,
264
+ assignees,
265
+ linkedPRs,
266
+ verdict,
267
+ verdictReason,
268
+ userLogin,
269
+ };
270
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Unified PR attention taxonomy (#1352).
3
+ *
4
+ * The headline "needs attention" number used to be computed twice — the CLI
5
+ * brief counted `collectActionableIssues()` output while the dashboard
6
+ * counted raw `status === 'needs_addressing'` — and the cases the CLI
7
+ * deliberately excludes (stuck CI, dormant maintainer waits) sat
8
+ * undifferentiated inside `waiting_on_maintainer`. This module is the single
9
+ * classifier both surfaces consume.
10
+ *
11
+ * Buckets (mutually exclusive, first match wins):
12
+ * - `needs_attention` — the contributor has a concrete code-related next
13
+ * step today (`status === 'needs_addressing'`: response, changes, CI fix,
14
+ * conflict, checklist). This is the headline count on BOTH surfaces.
15
+ * - `stuck_ci` — waiting PRs whose CI has been `pending` long past any
16
+ * normal run time. A "ping / investigate" workflow, not a coding one.
17
+ * - `dormant_followup` — waiting PRs with `review_required` and no activity
18
+ * past the follow-up threshold. A "draft a nudge" workflow.
19
+ * - `waiting` — everything else that's healthy to leave alone.
20
+ */
21
+ import type { AttentionBucket, FetchedPR } from './types.js';
22
+ export type { AttentionBucket } from './types.js';
23
+ /**
24
+ * Days of inactivity after which a `pending` CI status reads as stuck rather
25
+ * than in-flight. Real CI runs finish in minutes-to-hours; multiple days of
26
+ * `pending` means a webhook was lost, a runner died, or a required check
27
+ * never started.
28
+ */
29
+ export declare const STUCK_CI_THRESHOLD_DAYS = 3;
30
+ /**
31
+ * Days of inactivity after which a `review_required` PR becomes a follow-up
32
+ * candidate. Matches the middle step of the 7/14/30 dormant-PR follow-up
33
+ * cadence — early enough to nudge before the PR goes fully dormant
34
+ * (default `dormantThresholdDays` is 30).
35
+ */
36
+ export declare const DORMANT_FOLLOWUP_THRESHOLD_DAYS = 14;
37
+ /** The minimal slice of {@link FetchedPR} the classifier reads. */
38
+ export type AttentionInput = Pick<FetchedPR, 'status' | 'ciStatus' | 'reviewDecision' | 'daysSinceActivity'>;
39
+ /**
40
+ * Classify one PR into its attention bucket. Pure — both the CLI daily path
41
+ * and the dashboard `/api/data` path call this, so the two surfaces cannot
42
+ * diverge on what a count means.
43
+ */
44
+ export declare function classifyAttentionBucket(pr: AttentionInput): AttentionBucket;
45
+ export interface AttentionSummary {
46
+ needsAttention: number;
47
+ stuckCI: number;
48
+ dormantFollowup: number;
49
+ waiting: number;
50
+ }
51
+ /** Count PRs per bucket — the shape both headline surfaces render from. */
52
+ export declare function summarizeAttentionBuckets(prs: readonly AttentionInput[]): AttentionSummary;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Unified PR attention taxonomy (#1352).
3
+ *
4
+ * The headline "needs attention" number used to be computed twice — the CLI
5
+ * brief counted `collectActionableIssues()` output while the dashboard
6
+ * counted raw `status === 'needs_addressing'` — and the cases the CLI
7
+ * deliberately excludes (stuck CI, dormant maintainer waits) sat
8
+ * undifferentiated inside `waiting_on_maintainer`. This module is the single
9
+ * classifier both surfaces consume.
10
+ *
11
+ * Buckets (mutually exclusive, first match wins):
12
+ * - `needs_attention` — the contributor has a concrete code-related next
13
+ * step today (`status === 'needs_addressing'`: response, changes, CI fix,
14
+ * conflict, checklist). This is the headline count on BOTH surfaces.
15
+ * - `stuck_ci` — waiting PRs whose CI has been `pending` long past any
16
+ * normal run time. A "ping / investigate" workflow, not a coding one.
17
+ * - `dormant_followup` — waiting PRs with `review_required` and no activity
18
+ * past the follow-up threshold. A "draft a nudge" workflow.
19
+ * - `waiting` — everything else that's healthy to leave alone.
20
+ */
21
+ /**
22
+ * Days of inactivity after which a `pending` CI status reads as stuck rather
23
+ * than in-flight. Real CI runs finish in minutes-to-hours; multiple days of
24
+ * `pending` means a webhook was lost, a runner died, or a required check
25
+ * never started.
26
+ */
27
+ export const STUCK_CI_THRESHOLD_DAYS = 3;
28
+ /**
29
+ * Days of inactivity after which a `review_required` PR becomes a follow-up
30
+ * candidate. Matches the middle step of the 7/14/30 dormant-PR follow-up
31
+ * cadence — early enough to nudge before the PR goes fully dormant
32
+ * (default `dormantThresholdDays` is 30).
33
+ */
34
+ export const DORMANT_FOLLOWUP_THRESHOLD_DAYS = 14;
35
+ /**
36
+ * Classify one PR into its attention bucket. Pure — both the CLI daily path
37
+ * and the dashboard `/api/data` path call this, so the two surfaces cannot
38
+ * diverge on what a count means.
39
+ */
40
+ export function classifyAttentionBucket(pr) {
41
+ if (pr.status === 'needs_addressing')
42
+ return 'needs_attention';
43
+ // From here on the PR is waiting_on_maintainer — subdivide the wait.
44
+ if (pr.ciStatus === 'pending' && pr.daysSinceActivity >= STUCK_CI_THRESHOLD_DAYS) {
45
+ return 'stuck_ci';
46
+ }
47
+ if (pr.reviewDecision === 'review_required' && pr.daysSinceActivity >= DORMANT_FOLLOWUP_THRESHOLD_DAYS) {
48
+ return 'dormant_followup';
49
+ }
50
+ return 'waiting';
51
+ }
52
+ /** Count PRs per bucket — the shape both headline surfaces render from. */
53
+ export function summarizeAttentionBuckets(prs) {
54
+ const summary = { needsAttention: 0, stuckCI: 0, dormantFollowup: 0, waiting: 0 };
55
+ for (const pr of prs) {
56
+ switch (classifyAttentionBucket(pr)) {
57
+ case 'needs_attention': {
58
+ summary.needsAttention++;
59
+ break;
60
+ }
61
+ case 'stuck_ci': {
62
+ summary.stuckCI++;
63
+ break;
64
+ }
65
+ case 'dormant_followup': {
66
+ summary.dormantFollowup++;
67
+ break;
68
+ }
69
+ case 'waiting': {
70
+ summary.waiting++;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ return summary;
76
+ }
@@ -126,6 +126,8 @@ export type MaintainerActionHint = 'demo_requested' | 'tests_requested' | 'chang
126
126
  * This is never persisted in local state — it represents a point-in-time snapshot
127
127
  * of a PR's current condition.
128
128
  */
129
+ /** Unified attention bucket (#1352) — see core/pr-attention.ts for the classifier. */
130
+ export type AttentionBucket = 'needs_attention' | 'stuck_ci' | 'dormant_followup' | 'waiting';
129
131
  export interface FetchedPR {
130
132
  id: number;
131
133
  url: string;
@@ -142,6 +144,11 @@ export interface FetchedPR {
142
144
  actionReasons?: ActionReason[];
143
145
  /** How stale the PR is based on activity age. Independent of status — a PR can be both needs_addressing and dormant. */
144
146
  stalenessTier: StalenessTier;
147
+ /** Unified attention bucket (#1352), computed by `classifyAttentionBucket()`
148
+ * from status/ciStatus/reviewDecision/daysSinceActivity. Stamped by the
149
+ * dashboard data path so the SPA renders the same taxonomy the CLI brief
150
+ * counts. Optional: absent on payloads from older producers. */
151
+ attentionBucket?: AttentionBucket;
145
152
  /** Human-readable status label for consistent display (#79). E.g., "[CI Failing]", "[Needs Response]". */
146
153
  displayLabel: string;
147
154
  /** Brief description of what's happening (#79). E.g., "3 checks failed", "@maintainer commented". */