@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.
- package/dist/cli-registry.d.ts +7 -0
- package/dist/cli-registry.js +58 -5
- package/dist/cli.bundle.cjs +165 -112
- package/dist/cli.js +11 -3
- package/dist/commands/comments.js +31 -15
- package/dist/commands/compliance-score.js +12 -4
- package/dist/commands/daily-render.d.ts +2 -1
- package/dist/commands/daily-render.js +8 -2
- package/dist/commands/daily.d.ts +3 -1
- package/dist/commands/daily.js +54 -4
- package/dist/commands/dashboard-data.d.ts +17 -0
- package/dist/commands/dashboard-data.js +62 -4
- package/dist/commands/dashboard-server.js +100 -26
- package/dist/commands/dismiss.d.ts +4 -0
- package/dist/commands/dismiss.js +4 -4
- package/dist/commands/guidelines.d.ts +19 -0
- package/dist/commands/guidelines.js +23 -4
- package/dist/commands/index.d.ts +5 -1
- package/dist/commands/index.js +4 -0
- package/dist/commands/list-move-tier.d.ts +11 -3
- package/dist/commands/list-move-tier.js +18 -7
- package/dist/commands/move.d.ts +2 -0
- package/dist/commands/move.js +12 -8
- package/dist/commands/repo-vet.js +30 -8
- package/dist/commands/search.js +17 -3
- package/dist/commands/shelve.d.ts +4 -0
- package/dist/commands/shelve.js +4 -4
- package/dist/commands/verify-issue.d.ts +20 -0
- package/dist/commands/verify-issue.js +32 -0
- package/dist/core/daily-logic.js +65 -52
- package/dist/core/gist-state-store.js +42 -7
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +3 -1
- package/dist/core/issue-conversation.js +15 -2
- package/dist/core/issue-verification.d.ts +91 -0
- package/dist/core/issue-verification.js +270 -0
- package/dist/core/paths.d.ts +12 -0
- package/dist/core/paths.js +16 -0
- package/dist/core/pr-attention.d.ts +52 -0
- package/dist/core/pr-attention.js +76 -0
- package/dist/core/pr-comments-fetcher.d.ts +10 -2
- package/dist/core/pr-comments-fetcher.js +22 -4
- package/dist/core/state-persistence.d.ts +31 -9
- package/dist/core/state-persistence.js +51 -16
- package/dist/core/state.d.ts +18 -1
- package/dist/core/state.js +35 -3
- package/dist/core/types.d.ts +7 -0
- package/dist/core/untrusted-content.d.ts +24 -3
- package/dist/core/untrusted-content.js +31 -3
- package/dist/formatters/json.d.ts +83 -2
- package/dist/formatters/json.js +55 -1
- package/package.json +7 -7
|
@@ -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
|
+
}
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -25,6 +25,18 @@ export declare function getDataDir(): string;
|
|
|
25
25
|
* Implicitly creates the data directory via {@link getDataDir} if it does not exist.
|
|
26
26
|
*/
|
|
27
27
|
export declare function getStatePath(): string;
|
|
28
|
+
/**
|
|
29
|
+
* Returns the legacy (pre-`~/.oss-autopilot`) state file path: `./data/state.json`
|
|
30
|
+
* relative to the working directory.
|
|
31
|
+
*
|
|
32
|
+
* Functions rather than module constants so tests can mock them per-file:
|
|
33
|
+
* as constants in state-persistence.ts they resolved to the one shared
|
|
34
|
+
* `packages/core/data/` during vitest, and the migration tests racing
|
|
35
|
+
* parallel workers on that real directory caused CI flakes (#1382).
|
|
36
|
+
*/
|
|
37
|
+
export declare function getLegacyStatePath(): string;
|
|
38
|
+
/** Legacy backup directory (`./data/backups`); see {@link getLegacyStatePath}. */
|
|
39
|
+
export declare function getLegacyBackupDir(): string;
|
|
28
40
|
/**
|
|
29
41
|
* Returns the backup directory path, creating it if it does not exist.
|
|
30
42
|
*
|
package/dist/core/paths.js
CHANGED
|
@@ -36,6 +36,22 @@ export function getDataDir() {
|
|
|
36
36
|
export function getStatePath() {
|
|
37
37
|
return path.join(getDataDir(), 'state.json');
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Returns the legacy (pre-`~/.oss-autopilot`) state file path: `./data/state.json`
|
|
41
|
+
* relative to the working directory.
|
|
42
|
+
*
|
|
43
|
+
* Functions rather than module constants so tests can mock them per-file:
|
|
44
|
+
* as constants in state-persistence.ts they resolved to the one shared
|
|
45
|
+
* `packages/core/data/` during vitest, and the migration tests racing
|
|
46
|
+
* parallel workers on that real directory caused CI flakes (#1382).
|
|
47
|
+
*/
|
|
48
|
+
export function getLegacyStatePath() {
|
|
49
|
+
return path.join(process.cwd(), 'data', 'state.json');
|
|
50
|
+
}
|
|
51
|
+
/** Legacy backup directory (`./data/backups`); see {@link getLegacyStatePath}. */
|
|
52
|
+
export function getLegacyBackupDir() {
|
|
53
|
+
return path.join(process.cwd(), 'data', 'backups');
|
|
54
|
+
}
|
|
39
55
|
/**
|
|
40
56
|
* Returns the backup directory path, creating it if it does not exist.
|
|
41
57
|
*
|
|
@@ -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
|
+
}
|
|
@@ -16,6 +16,7 @@ import type { Octokit } from '@octokit/rest';
|
|
|
16
16
|
export interface PRReviewEntry {
|
|
17
17
|
author: string;
|
|
18
18
|
authorAssociation: string;
|
|
19
|
+
/** `<github-content>`-fenced review body (#1372). */
|
|
19
20
|
body: string;
|
|
20
21
|
submittedAt: string;
|
|
21
22
|
}
|
|
@@ -23,6 +24,7 @@ export interface PRReviewEntry {
|
|
|
23
24
|
export interface PRReviewCommentEntry {
|
|
24
25
|
author: string;
|
|
25
26
|
authorAssociation: string;
|
|
27
|
+
/** `<github-content>`-fenced comment body (#1372). */
|
|
26
28
|
body: string;
|
|
27
29
|
path: string;
|
|
28
30
|
createdAt: string;
|
|
@@ -31,6 +33,7 @@ export interface PRReviewCommentEntry {
|
|
|
31
33
|
export interface PRIssueCommentEntry {
|
|
32
34
|
author: string;
|
|
33
35
|
authorAssociation: string;
|
|
36
|
+
/** `<github-content>`-fenced comment body (#1372). */
|
|
34
37
|
body: string;
|
|
35
38
|
createdAt: string;
|
|
36
39
|
}
|
|
@@ -62,8 +65,13 @@ export declare function fetchPRCommentBundle(octokit: Octokit, prUrl: string, gi
|
|
|
62
65
|
* `{ bundles, failures }` so the caller can decide whether to retry, surface
|
|
63
66
|
* a partial-data banner, or proceed. Rationale: extraction quality is already
|
|
64
67
|
* a partial-information problem (users contribute to many repos and many PRs),
|
|
65
|
-
* so a single 404 /
|
|
66
|
-
* from the other 4 — but the failure should still be visible (#1209 L8).
|
|
68
|
+
* so a single 404 / transient failure on one PR should not deny the host the
|
|
69
|
+
* corpus from the other 4 — but the failure should still be visible (#1209 L8).
|
|
70
|
+
*
|
|
71
|
+
* Rate-limit / auth errors are the exception: they reject the whole batch
|
|
72
|
+
* (#1391). Under throttling every remaining PR would fail the same way, so
|
|
73
|
+
* degrading to "skipped N PRs" silently masks a systemic failure the caller
|
|
74
|
+
* needs to surface (the CLI/MCP error envelope on `guidelines fetch-corpus`).
|
|
67
75
|
*/
|
|
68
76
|
export interface PRCommentBundlesBatchResult {
|
|
69
77
|
bundles: PRCommentBundle[];
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { paginateAll } from './pagination.js';
|
|
2
|
+
import { wrapUntrustedContent } from './untrusted-content.js';
|
|
2
3
|
import { isBotAuthor } from './comment-utils.js';
|
|
3
4
|
import { parseGitHubUrl } from './urls.js';
|
|
4
|
-
import { ValidationError, errorMessage } from './errors.js';
|
|
5
|
+
import { ValidationError, errorMessage, isRateLimitOrAuthError } from './errors.js';
|
|
5
6
|
import { debug, warn } from './logger.js';
|
|
6
7
|
const MODULE = 'pr-comments-fetcher';
|
|
7
8
|
/** Default concurrency for {@link fetchPRCommentBundlesBatch}. */
|
|
@@ -60,6 +61,12 @@ export async function fetchPRCommentBundle(octokit, prUrl, githubUsername) {
|
|
|
60
61
|
return true;
|
|
61
62
|
};
|
|
62
63
|
const mergedAt = pr.merged_at ?? pr.closed_at ?? '';
|
|
64
|
+
// Fence every body at fetch time (#1372): the bundle's only consumer is
|
|
65
|
+
// `guidelines fetch-corpus`, whose output goes straight into the host's
|
|
66
|
+
// extract-learnings prompt — there is no human-display or string-matching
|
|
67
|
+
// consumer downstream, so fetch-time wrapping is safe here.
|
|
68
|
+
const fenceLabel = `${repoFull}#${pull_number}`;
|
|
69
|
+
const fence = (body, source, author, association) => wrapUntrustedContent(body, fenceLabel, { author, association, source });
|
|
63
70
|
return {
|
|
64
71
|
prUrl,
|
|
65
72
|
prTitle: pr.title,
|
|
@@ -70,7 +77,7 @@ export async function fetchPRCommentBundle(octokit, prUrl, githubUsername) {
|
|
|
70
77
|
.map((r) => ({
|
|
71
78
|
author: r.user?.login ?? '',
|
|
72
79
|
authorAssociation: r.author_association ?? 'NONE',
|
|
73
|
-
body: r.body ?? '',
|
|
80
|
+
body: fence(r.body ?? '', 'pr-review', r.user?.login ?? '', r.author_association ?? 'NONE'),
|
|
74
81
|
submittedAt: r.submitted_at ?? '',
|
|
75
82
|
})),
|
|
76
83
|
reviewComments: reviewComments
|
|
@@ -78,7 +85,7 @@ export async function fetchPRCommentBundle(octokit, prUrl, githubUsername) {
|
|
|
78
85
|
.map((c) => ({
|
|
79
86
|
author: c.user?.login ?? '',
|
|
80
87
|
authorAssociation: c.author_association ?? 'NONE',
|
|
81
|
-
body: c.body ?? '',
|
|
88
|
+
body: fence(c.body ?? '', 'pr-review-comment', c.user?.login ?? '', c.author_association ?? 'NONE'),
|
|
82
89
|
path: c.path ?? '',
|
|
83
90
|
createdAt: c.created_at ?? '',
|
|
84
91
|
})),
|
|
@@ -87,7 +94,7 @@ export async function fetchPRCommentBundle(octokit, prUrl, githubUsername) {
|
|
|
87
94
|
.map((c) => ({
|
|
88
95
|
author: c.user?.login ?? '',
|
|
89
96
|
authorAssociation: c.author_association ?? 'NONE',
|
|
90
|
-
body: c.body ?? '',
|
|
97
|
+
body: fence(c.body ?? '', 'pr-issue-comment', c.user?.login ?? '', c.author_association ?? 'NONE'),
|
|
91
98
|
createdAt: c.created_at ?? '',
|
|
92
99
|
})),
|
|
93
100
|
};
|
|
@@ -96,8 +103,11 @@ export async function fetchPRCommentBundlesBatch(octokit, prUrls, githubUsername
|
|
|
96
103
|
const bundles = [];
|
|
97
104
|
const failures = [];
|
|
98
105
|
const queue = [...prUrls];
|
|
106
|
+
let aborted = false;
|
|
99
107
|
async function worker() {
|
|
100
108
|
while (queue.length > 0) {
|
|
109
|
+
if (aborted)
|
|
110
|
+
return;
|
|
101
111
|
const url = queue.shift();
|
|
102
112
|
if (!url)
|
|
103
113
|
return;
|
|
@@ -106,6 +116,14 @@ export async function fetchPRCommentBundlesBatch(octokit, prUrls, githubUsername
|
|
|
106
116
|
bundles.push(bundle);
|
|
107
117
|
}
|
|
108
118
|
catch (err) {
|
|
119
|
+
// Rate-limit / auth failures abort the batch instead of degrading to
|
|
120
|
+
// a per-PR skip — see the doc comment above (#1391). The abort flag
|
|
121
|
+
// stops sibling workers from burning more rate-limited requests on
|
|
122
|
+
// results that will be discarded when Promise.all rejects.
|
|
123
|
+
if (isRateLimitOrAuthError(err)) {
|
|
124
|
+
aborted = true;
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
109
127
|
const errorMsg = errorMessage(err);
|
|
110
128
|
failures.push({ prUrl: url, error: errorMsg });
|
|
111
129
|
warn(MODULE, `Skipping ${url}: ${errorMsg}`);
|
|
@@ -45,16 +45,41 @@ export declare function migrateV3ToV4(rawState: Record<string, unknown>): Record
|
|
|
45
45
|
* Leverages Zod schema defaults to produce a complete state.
|
|
46
46
|
*/
|
|
47
47
|
export declare function createFreshState(): AgentState;
|
|
48
|
+
/**
|
|
49
|
+
* Details about a recovery that happened during {@link loadState} — set when
|
|
50
|
+
* the main state file was unreadable (JSON parse error) or structurally
|
|
51
|
+
* invalid (Zod rejection) and the loader fell back to a backup or fresh state.
|
|
52
|
+
* Surfaced through StateManager.getLoadRecovery() so commands can include the
|
|
53
|
+
* event in structured output instead of only stderr warn() lines (#1371).
|
|
54
|
+
*/
|
|
55
|
+
export interface LoadRecoveryInfo {
|
|
56
|
+
/** Where the loaded state came from after the original file was rejected. */
|
|
57
|
+
source: 'backup' | 'fresh';
|
|
58
|
+
/** Path of the preserved `.rejected-<ts>` copy, or null if preservation failed. */
|
|
59
|
+
rejectedPath: string | null;
|
|
60
|
+
/** Short summary of why the original state file was rejected. */
|
|
61
|
+
reason: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Result of {@link loadState}: the loaded state, the file's mtime for change
|
|
65
|
+
* detection, and (only when the main file was rejected) recovery details.
|
|
66
|
+
*/
|
|
67
|
+
export interface LoadStateResult {
|
|
68
|
+
state: AgentState;
|
|
69
|
+
mtimeMs: number;
|
|
70
|
+
recovery?: LoadRecoveryInfo;
|
|
71
|
+
}
|
|
48
72
|
/**
|
|
49
73
|
* Load state from file, or create initial state if none exists.
|
|
50
74
|
* If the main state file is corrupted, attempts to restore from the most recent backup.
|
|
51
75
|
* Performs migration from legacy ./data/ location if needed.
|
|
52
|
-
* @returns Object with the loaded state
|
|
76
|
+
* @returns Object with the loaded state, the file's mtime (for change detection),
|
|
77
|
+
* and recovery details when the main file was rejected.
|
|
78
|
+
* @throws Error when the state file exists but cannot be read due to a
|
|
79
|
+
* permission error (EACCES/EPERM) — that is an environment problem, not
|
|
80
|
+
* corruption, so restore-or-fresh would mask it and risk clobbering data.
|
|
53
81
|
*/
|
|
54
|
-
export declare function loadState():
|
|
55
|
-
state: AgentState;
|
|
56
|
-
mtimeMs: number;
|
|
57
|
-
};
|
|
82
|
+
export declare function loadState(): LoadStateResult;
|
|
58
83
|
/**
|
|
59
84
|
* Persist state to disk, creating a timestamped backup of the previous
|
|
60
85
|
* state file first. Retains at most 10 backup files.
|
|
@@ -79,7 +104,4 @@ export declare function saveState(state: Readonly<AgentState>, expectedMtimeMs?:
|
|
|
79
104
|
* Uses mtime comparison (single statSync call) to avoid unnecessary JSON parsing.
|
|
80
105
|
* @returns The new state and mtime if reloaded, or null if no change detected.
|
|
81
106
|
*/
|
|
82
|
-
export declare function reloadStateIfChanged(lastLoadedMtimeMs: number):
|
|
83
|
-
state: AgentState;
|
|
84
|
-
mtimeMs: number;
|
|
85
|
-
} | null;
|
|
107
|
+
export declare function reloadStateIfChanged(lastLoadedMtimeMs: number): LoadStateResult | null;
|