@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.
- package/dist/cli-registry.js +29 -0
- package/dist/cli.bundle.cjs +144 -91
- 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 +7 -2
- package/dist/commands/dashboard-data.js +14 -5
- package/dist/commands/dashboard-server.js +7 -2
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/list-move-tier.d.ts +11 -3
- package/dist/commands/list-move-tier.js +18 -7
- package/dist/commands/search.js +17 -3
- 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/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/issue-verification.d.ts +91 -0
- package/dist/core/issue-verification.js +270 -0
- package/dist/core/pr-attention.d.ts +52 -0
- package/dist/core/pr-attention.js +76 -0
- package/dist/core/types.d.ts +7 -0
- package/dist/formatters/json.d.ts +74 -1
- package/dist/formatters/json.js +43 -1
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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". */
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Provides structured output that can be consumed by scripts and plugins
|
|
4
4
|
*/
|
|
5
5
|
import { z, type ZodType } from 'zod';
|
|
6
|
+
import type { AttentionSummary } from '../core/pr-attention.js';
|
|
6
7
|
import type { FetchedPR, DailyDigest, AgentState, RepoGroup, CommentedIssue, ShelvedPRRef, SearchPriority, CapacityAssessment, ActionableIssue, ActionableIssueType, CompactActionableIssue, ActionMenuItem, ActionMenu } from '../core/types.js';
|
|
7
8
|
import type { ContributionStats } from '../core/stats.js';
|
|
8
9
|
import type { PRCheckFailure } from '../core/pr-monitor.js';
|
|
@@ -87,6 +88,8 @@ export interface DailyOutput {
|
|
|
87
88
|
summary: string;
|
|
88
89
|
briefSummary: string;
|
|
89
90
|
actionableIssues: CompactActionableIssue[];
|
|
91
|
+
/** Unified attention-bucket counts (#1352) — same classifier the dashboard stamps per-PR. */
|
|
92
|
+
attention: AttentionSummary;
|
|
90
93
|
actionMenu: ActionMenu;
|
|
91
94
|
commentedIssues: CommentedIssue[];
|
|
92
95
|
repoGroups: CompactRepoGroup[];
|
|
@@ -119,6 +122,8 @@ export interface CompactDailyOutput {
|
|
|
119
122
|
capacity: CapacityAssessment;
|
|
120
123
|
briefSummary: string;
|
|
121
124
|
actionableIssues: CompactActionableIssue[];
|
|
125
|
+
/** Unified attention-bucket counts (#1352), retained under --compact. */
|
|
126
|
+
attention: AttentionSummary;
|
|
122
127
|
actionMenu: ActionMenu;
|
|
123
128
|
commentedIssues: CommentedIssue[];
|
|
124
129
|
/** Number of PRs that failed to fetch. Non-zero indicates partial results. */
|
|
@@ -285,6 +290,12 @@ export declare const StrategyOutputSchema: z.ZodObject<{
|
|
|
285
290
|
message: z.ZodOptional<z.ZodString>;
|
|
286
291
|
}, z.core.$strip>;
|
|
287
292
|
export type StrategyCommandOutput = z.infer<typeof StrategyOutputSchema>;
|
|
293
|
+
export declare const AttentionSummarySchema: z.ZodObject<{
|
|
294
|
+
needsAttention: z.ZodNumber;
|
|
295
|
+
stuckCI: z.ZodNumber;
|
|
296
|
+
dormantFollowup: z.ZodNumber;
|
|
297
|
+
waiting: z.ZodNumber;
|
|
298
|
+
}, z.core.$strip>;
|
|
288
299
|
export declare const DailyOutputSchema: z.ZodObject<{
|
|
289
300
|
digest: z.ZodObject<{
|
|
290
301
|
generatedAt: z.ZodString;
|
|
@@ -348,6 +359,12 @@ export declare const DailyOutputSchema: z.ZodObject<{
|
|
|
348
359
|
label: z.ZodString;
|
|
349
360
|
isNewContribution: z.ZodBoolean;
|
|
350
361
|
}, z.core.$strip>>;
|
|
362
|
+
attention: z.ZodObject<{
|
|
363
|
+
needsAttention: z.ZodNumber;
|
|
364
|
+
stuckCI: z.ZodNumber;
|
|
365
|
+
dormantFollowup: z.ZodNumber;
|
|
366
|
+
waiting: z.ZodNumber;
|
|
367
|
+
}, z.core.$strip>;
|
|
351
368
|
actionMenu: z.ZodObject<{
|
|
352
369
|
items: z.ZodArray<z.ZodObject<{
|
|
353
370
|
key: z.ZodString;
|
|
@@ -493,6 +510,12 @@ export declare const CompactDailyOutputSchema: z.ZodObject<{
|
|
|
493
510
|
label: z.ZodString;
|
|
494
511
|
isNewContribution: z.ZodBoolean;
|
|
495
512
|
}, z.core.$strip>>;
|
|
513
|
+
attention: z.ZodObject<{
|
|
514
|
+
needsAttention: z.ZodNumber;
|
|
515
|
+
stuckCI: z.ZodNumber;
|
|
516
|
+
dormantFollowup: z.ZodNumber;
|
|
517
|
+
waiting: z.ZodNumber;
|
|
518
|
+
}, z.core.$strip>;
|
|
496
519
|
actionMenu: z.ZodObject<{
|
|
497
520
|
items: z.ZodArray<z.ZodObject<{
|
|
498
521
|
key: z.ZodString;
|
|
@@ -628,6 +651,7 @@ export declare const SearchOutputSchema: z.ZodObject<{
|
|
|
628
651
|
}, z.core.$strip>>;
|
|
629
652
|
excludedRepos: z.ZodArray<z.ZodString>;
|
|
630
653
|
aiPolicyBlocklist: z.ZodArray<z.ZodString>;
|
|
654
|
+
hiddenOwnPRCount: z.ZodNumber;
|
|
631
655
|
rateLimitWarning: z.ZodOptional<z.ZodString>;
|
|
632
656
|
}, z.core.$strip>;
|
|
633
657
|
export declare const FeaturesOutputSchema: z.ZodObject<{
|
|
@@ -784,7 +808,7 @@ export declare const ListMoveTierOutputSchema: z.ZodObject<{
|
|
|
784
808
|
}>;
|
|
785
809
|
fromTier: z.ZodOptional<z.ZodString>;
|
|
786
810
|
count: z.ZodNumber;
|
|
787
|
-
reason: z.ZodOptional<z.
|
|
811
|
+
reason: z.ZodOptional<z.ZodLiteral<"already in target tier">>;
|
|
788
812
|
}, z.core.$strip>;
|
|
789
813
|
export declare const ListMarkDoneOutputSchema: z.ZodObject<{
|
|
790
814
|
marked: z.ZodBoolean;
|
|
@@ -794,6 +818,52 @@ export declare const ListMarkDoneOutputSchema: z.ZodObject<{
|
|
|
794
818
|
remainingUnderRepo: z.ZodNumber;
|
|
795
819
|
reason: z.ZodOptional<z.ZodString>;
|
|
796
820
|
}, z.core.$strip>;
|
|
821
|
+
export declare const VerifyIssueOutputSchema: z.ZodObject<{
|
|
822
|
+
url: z.ZodString;
|
|
823
|
+
owner: z.ZodString;
|
|
824
|
+
repo: z.ZodString;
|
|
825
|
+
number: z.ZodNumber;
|
|
826
|
+
title: z.ZodString;
|
|
827
|
+
state: z.ZodEnum<{
|
|
828
|
+
closed: "closed";
|
|
829
|
+
open: "open";
|
|
830
|
+
}>;
|
|
831
|
+
stateReason: z.ZodNullable<z.ZodEnum<{
|
|
832
|
+
completed: "completed";
|
|
833
|
+
reopened: "reopened";
|
|
834
|
+
not_planned: "not_planned";
|
|
835
|
+
duplicate: "duplicate";
|
|
836
|
+
}>>;
|
|
837
|
+
closedAt: z.ZodNullable<z.ZodString>;
|
|
838
|
+
assignees: z.ZodArray<z.ZodString>;
|
|
839
|
+
linkedPRs: z.ZodArray<z.ZodObject<{
|
|
840
|
+
number: z.ZodNumber;
|
|
841
|
+
url: z.ZodString;
|
|
842
|
+
title: z.ZodString;
|
|
843
|
+
state: z.ZodEnum<{
|
|
844
|
+
closed: "closed";
|
|
845
|
+
open: "open";
|
|
846
|
+
merged: "merged";
|
|
847
|
+
}>;
|
|
848
|
+
isDraft: z.ZodBoolean;
|
|
849
|
+
author: z.ZodNullable<z.ZodString>;
|
|
850
|
+
isOwn: z.ZodBoolean;
|
|
851
|
+
linkType: z.ZodEnum<{
|
|
852
|
+
closing: "closing";
|
|
853
|
+
"cross-referenced": "cross-referenced";
|
|
854
|
+
}>;
|
|
855
|
+
updatedAt: z.ZodNullable<z.ZodString>;
|
|
856
|
+
}, z.core.$strip>>;
|
|
857
|
+
verdict: z.ZodEnum<{
|
|
858
|
+
closed: "closed";
|
|
859
|
+
"own-open-pr": "own-open-pr";
|
|
860
|
+
taken: "taken";
|
|
861
|
+
"at-risk": "at-risk";
|
|
862
|
+
available: "available";
|
|
863
|
+
}>;
|
|
864
|
+
verdictReason: z.ZodString;
|
|
865
|
+
userLogin: z.ZodString;
|
|
866
|
+
}, z.core.$strip>;
|
|
797
867
|
export declare const PostOutputSchema: z.ZodObject<{
|
|
798
868
|
commentUrl: z.ZodString;
|
|
799
869
|
url: z.ZodString;
|
|
@@ -1152,6 +1222,9 @@ export interface SearchOutput {
|
|
|
1152
1222
|
excludedRepos: string[];
|
|
1153
1223
|
/** Repos with known anti-AI contribution policies, filtered from search results (#108). */
|
|
1154
1224
|
aiPolicyBlocklist: string[];
|
|
1225
|
+
/** Candidates dropped because the authenticated user already has an open PR
|
|
1226
|
+
* linked to the issue (#1354). Surfaced as a count so the filter is visible. */
|
|
1227
|
+
hiddenOwnPRCount: number;
|
|
1155
1228
|
/** Present when rate limits affected the search — either low pre-flight quota or mid-search rate limit hits (#100). */
|
|
1156
1229
|
rateLimitWarning?: string;
|
|
1157
1230
|
}
|
package/dist/formatters/json.js
CHANGED
|
@@ -26,6 +26,7 @@ export function toCompactDailyOutput(output) {
|
|
|
26
26
|
capacity: output.capacity,
|
|
27
27
|
briefSummary: output.briefSummary,
|
|
28
28
|
actionableIssues: output.actionableIssues,
|
|
29
|
+
attention: output.attention,
|
|
29
30
|
actionMenu: output.actionMenu,
|
|
30
31
|
commentedIssues: output.commentedIssues,
|
|
31
32
|
failureCount: output.failures.length,
|
|
@@ -271,12 +272,19 @@ export const StrategyOutputSchema = z.object({
|
|
|
271
272
|
strategy: StrategyResultSchema.nullable(),
|
|
272
273
|
message: z.string().optional(),
|
|
273
274
|
});
|
|
275
|
+
export const AttentionSummarySchema = z.object({
|
|
276
|
+
needsAttention: z.number().int().nonnegative(),
|
|
277
|
+
stuckCI: z.number().int().nonnegative(),
|
|
278
|
+
dormantFollowup: z.number().int().nonnegative(),
|
|
279
|
+
waiting: z.number().int().nonnegative(),
|
|
280
|
+
});
|
|
274
281
|
export const DailyOutputSchema = z.object({
|
|
275
282
|
digest: DailyDigestCompactSchema,
|
|
276
283
|
capacity: CapacityAssessmentSchema,
|
|
277
284
|
summary: z.string(),
|
|
278
285
|
briefSummary: z.string(),
|
|
279
286
|
actionableIssues: z.array(CompactActionableIssueSchema),
|
|
287
|
+
attention: AttentionSummarySchema,
|
|
280
288
|
actionMenu: ActionMenuSchema,
|
|
281
289
|
commentedIssues: z.array(CommentedIssuePassthroughSchema),
|
|
282
290
|
repoGroups: z.array(CompactRepoGroupSchema),
|
|
@@ -289,6 +297,7 @@ export const CompactDailyOutputSchema = z.object({
|
|
|
289
297
|
capacity: CapacityAssessmentSchema,
|
|
290
298
|
briefSummary: z.string(),
|
|
291
299
|
actionableIssues: z.array(CompactActionableIssueSchema),
|
|
300
|
+
attention: AttentionSummarySchema,
|
|
292
301
|
actionMenu: ActionMenuSchema,
|
|
293
302
|
commentedIssues: z.array(CommentedIssuePassthroughSchema),
|
|
294
303
|
failureCount: z.number().int().nonnegative(),
|
|
@@ -349,6 +358,7 @@ export const SearchOutputSchema = z.object({
|
|
|
349
358
|
candidates: z.array(SearchCandidateSchema),
|
|
350
359
|
excludedRepos: z.array(z.string()),
|
|
351
360
|
aiPolicyBlocklist: z.array(z.string()),
|
|
361
|
+
hiddenOwnPRCount: z.number().int().nonnegative(),
|
|
352
362
|
rateLimitWarning: z.string().optional(),
|
|
353
363
|
});
|
|
354
364
|
// ── Features output schema (scout 0.9.0 #97/#98/#99) ─────────────────
|
|
@@ -396,7 +406,10 @@ export const ListMoveTierOutputSchema = z.object({
|
|
|
396
406
|
toTier: z.enum(['pursue', 'maybe', 'skip']),
|
|
397
407
|
fromTier: z.string().optional(),
|
|
398
408
|
count: z.number().int().nonnegative(),
|
|
399
|
-
|
|
409
|
+
// #1355: not-found now throws before reaching the success envelope, so the
|
|
410
|
+
// only reachable no-op reason is the idempotent one. Pinned so a new quiet
|
|
411
|
+
// no-op path trips the #1105 runtime validator instead of shipping silently.
|
|
412
|
+
reason: z.literal('already in target tier').optional(),
|
|
400
413
|
});
|
|
401
414
|
// list-mark-done (#1299): mirrors {@link MarkDoneOutput} from the command
|
|
402
415
|
// module. Strict shape — additional keys must be added here AND in the
|
|
@@ -410,6 +423,35 @@ export const ListMarkDoneOutputSchema = z.object({
|
|
|
410
423
|
remainingUnderRepo: z.number().int().nonnegative(),
|
|
411
424
|
reason: z.string().optional(),
|
|
412
425
|
});
|
|
426
|
+
// verify-issue (#1353, #1354): mirrors {@link IssueVerification} from
|
|
427
|
+
// core/issue-verification.ts. Strict shape — additional keys must be added
|
|
428
|
+
// here AND in the module output, otherwise the validator rejects the
|
|
429
|
+
// response before it reaches consumers.
|
|
430
|
+
export const VerifyIssueOutputSchema = z.object({
|
|
431
|
+
url: z.string(),
|
|
432
|
+
owner: z.string(),
|
|
433
|
+
repo: z.string(),
|
|
434
|
+
number: z.number().int().positive(),
|
|
435
|
+
title: z.string(),
|
|
436
|
+
state: z.enum(['open', 'closed']),
|
|
437
|
+
stateReason: z.enum(['completed', 'not_planned', 'reopened', 'duplicate']).nullable(),
|
|
438
|
+
closedAt: z.string().nullable(),
|
|
439
|
+
assignees: z.array(z.string()),
|
|
440
|
+
linkedPRs: z.array(z.object({
|
|
441
|
+
number: z.number().int().positive(),
|
|
442
|
+
url: z.string(),
|
|
443
|
+
title: z.string(),
|
|
444
|
+
state: z.enum(['open', 'closed', 'merged']),
|
|
445
|
+
isDraft: z.boolean(),
|
|
446
|
+
author: z.string().nullable(),
|
|
447
|
+
isOwn: z.boolean(),
|
|
448
|
+
linkType: z.enum(['closing', 'cross-referenced']),
|
|
449
|
+
updatedAt: z.string().nullable(),
|
|
450
|
+
})),
|
|
451
|
+
verdict: z.enum(['closed', 'own-open-pr', 'taken', 'at-risk', 'available']),
|
|
452
|
+
verdictReason: z.string(),
|
|
453
|
+
userLogin: z.string(),
|
|
454
|
+
});
|
|
413
455
|
// ── #1155: Zod coverage for remaining CLI commands ───────────────────
|
|
414
456
|
export const PostOutputSchema = z.object({
|
|
415
457
|
commentUrl: z.string(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oss-autopilot/core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
4
4
|
"description": "CLI and core library for managing open source contributions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"zod": "^4.4.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@types/node": "^25.9.
|
|
62
|
+
"@types/node": "^25.9.3",
|
|
63
63
|
"@vitest/coverage-v8": "^4.1.8",
|
|
64
64
|
"esbuild": "^0.28.0",
|
|
65
65
|
"tsx": "^4.22.4",
|