@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
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Command } from 'commander';
|
|
12
12
|
import { getGitHubTokenAsync, enableDebug, debug, getCLIVersion, stateFileExists } from './core/index.js';
|
|
13
|
-
import { commands } from './cli-registry.js';
|
|
13
|
+
import { commands, handleCommandError } from './cli-registry.js';
|
|
14
14
|
const VERSION = getCLIVersion();
|
|
15
15
|
const program = new Command();
|
|
16
16
|
program
|
|
@@ -100,5 +100,13 @@ Run oss-autopilot --help for all commands.
|
|
|
100
100
|
`);
|
|
101
101
|
process.exit(0);
|
|
102
102
|
}
|
|
103
|
-
// Parse and execute
|
|
104
|
-
|
|
103
|
+
// Parse and execute. parseAsync, not parse: the preAction hook is async, so
|
|
104
|
+
// with synchronous parse() a rejected hook promise (corrupt Gist, missing
|
|
105
|
+
// gist scope, rate limit during bootstrap — all of which ensureGistPersistence
|
|
106
|
+
// now propagates) became an UnhandledPromiseRejection and the user saw a raw
|
|
107
|
+
// stack instead of the actionable message (#1386). Command actions already
|
|
108
|
+
// route their errors through executeAction/handleCommandError, so this catch
|
|
109
|
+
// covers exactly the hook path (plus any handler that escapes the wrapper).
|
|
110
|
+
program.parseAsync().catch((err) => {
|
|
111
|
+
handleCommandError(err, process.argv.includes('--json'));
|
|
112
|
+
});
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Handles GitHub comment interactions
|
|
4
4
|
*/
|
|
5
5
|
import { getStateManager, getOctokit, parseGitHubUrl, requireGitHubToken, maybeCheckpoint } from '../core/index.js';
|
|
6
|
-
import {
|
|
6
|
+
import { wrapUntrustedContent } from '../core/untrusted-content.js';
|
|
7
|
+
import { ValidationError, isRateLimitOrAuthError } from '../core/errors.js';
|
|
7
8
|
import { warn } from '../core/logger.js';
|
|
8
9
|
const MODULE = 'comments';
|
|
9
10
|
import { paginateAll } from '../core/pagination.js';
|
|
@@ -77,6 +78,12 @@ export async function runComments(options) {
|
|
|
77
78
|
const relevantReviews = reviews
|
|
78
79
|
.filter((r) => filterComment(r) && r.body && r.body.trim())
|
|
79
80
|
.sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime());
|
|
81
|
+
// Fence every third-party body at this boundary: the output feeds agents
|
|
82
|
+
// directly (CLI --json, the MCP `comments` tool, and the `respond-to-pr`
|
|
83
|
+
// MCP prompt), so raw GitHub text must never reach a prompt unfenced
|
|
84
|
+
// (#1372). The CLI text display unwraps via safeExtractFromFence.
|
|
85
|
+
const fenceLabel = `${owner}/${repo}#${pull_number}`;
|
|
86
|
+
const fence = (body, source, author, association) => wrapUntrustedContent(body, fenceLabel, { author, association, source });
|
|
80
87
|
const staleness = stateManager.getStateStaleness();
|
|
81
88
|
return {
|
|
82
89
|
pr: {
|
|
@@ -90,18 +97,18 @@ export async function runComments(options) {
|
|
|
90
97
|
reviews: relevantReviews.map((r) => ({
|
|
91
98
|
user: r.user?.login,
|
|
92
99
|
state: r.state,
|
|
93
|
-
body: r.body
|
|
100
|
+
body: r.body == null ? null : fence(r.body, 'pr-review', r.user?.login, r.author_association),
|
|
94
101
|
submittedAt: r.submitted_at ?? null,
|
|
95
102
|
})),
|
|
96
103
|
reviewComments: relevantReviewComments.map((c) => ({
|
|
97
104
|
user: c.user?.login,
|
|
98
|
-
body: c.body,
|
|
105
|
+
body: fence(c.body, 'pr-review-comment', c.user?.login, c.author_association),
|
|
99
106
|
path: c.path,
|
|
100
107
|
createdAt: c.created_at,
|
|
101
108
|
})),
|
|
102
109
|
issueComments: relevantIssueComments.map((c) => ({
|
|
103
110
|
user: c.user?.login,
|
|
104
|
-
body: c.body,
|
|
111
|
+
body: c.body == null ? c.body : fence(c.body, 'pr-issue-comment', c.user?.login, c.author_association),
|
|
105
112
|
createdAt: c.created_at,
|
|
106
113
|
})),
|
|
107
114
|
summary: {
|
|
@@ -170,16 +177,15 @@ export async function runClaim(options) {
|
|
|
170
177
|
}
|
|
171
178
|
const { owner, repo, number } = parsed;
|
|
172
179
|
const octokit = getOctokit(token);
|
|
173
|
-
const { data: comment } = await octokit.issues.createComment({
|
|
174
|
-
owner,
|
|
175
|
-
repo,
|
|
176
|
-
issue_number: number,
|
|
177
|
-
body: message,
|
|
178
|
-
});
|
|
179
180
|
// Fetch the real issue title + labels so the tracked entry has useful metadata
|
|
180
181
|
// rather than a permanent "(claimed)" placeholder that never gets backfilled
|
|
181
182
|
// (#1056 M24). Best-effort: if the fetch fails, fall back to the placeholder
|
|
182
183
|
// so state still records the claim.
|
|
184
|
+
//
|
|
185
|
+
// Ordering matters: enrichment runs BEFORE the claim comment is posted
|
|
186
|
+
// (#1391). Rate-limit / auth failures rethrow here, and aborting after the
|
|
187
|
+
// comment landed would leave an orphaned claim on GitHub that local state
|
|
188
|
+
// never tracked — aborting before it means a clean no-op the user can retry.
|
|
183
189
|
let issueTitle = '(claimed)';
|
|
184
190
|
let issueLabels = [];
|
|
185
191
|
let issueCreatedAt = new Date().toISOString();
|
|
@@ -194,9 +200,18 @@ export async function runClaim(options) {
|
|
|
194
200
|
issueCreatedAt = issue.created_at;
|
|
195
201
|
}
|
|
196
202
|
catch (error) {
|
|
197
|
-
|
|
203
|
+
if (isRateLimitOrAuthError(error))
|
|
204
|
+
throw error;
|
|
205
|
+
warn(MODULE, `Failed to enrich issue metadata (title/labels) for ${options.issueUrl}; claiming with placeholder: ${error instanceof Error ? error.message : error}`);
|
|
198
206
|
}
|
|
207
|
+
const { data: comment } = await octokit.issues.createComment({
|
|
208
|
+
owner,
|
|
209
|
+
repo,
|
|
210
|
+
issue_number: number,
|
|
211
|
+
body: message,
|
|
212
|
+
});
|
|
199
213
|
// Add to tracked issues — non-fatal if state save fails (comment already posted)
|
|
214
|
+
let gistSyncWarning = null;
|
|
200
215
|
try {
|
|
201
216
|
const stateManager = getStateManager();
|
|
202
217
|
stateManager.addIssue({
|
|
@@ -211,10 +226,10 @@ export async function runClaim(options) {
|
|
|
211
226
|
updatedAt: new Date().toISOString(),
|
|
212
227
|
vetted: false,
|
|
213
228
|
});
|
|
214
|
-
// Push state to Gist if in Gist mode. Best-effort —
|
|
215
|
-
//
|
|
216
|
-
// signal (#1036 audit H1).
|
|
217
|
-
await maybeCheckpoint(stateManager, MODULE);
|
|
229
|
+
// Push state to Gist if in Gist mode. Best-effort — the warning is
|
|
230
|
+
// threaded into the structured output so MCP/dashboard consumers see
|
|
231
|
+
// the degraded-sync signal, not just the stderr log (#1036 audit H1, #1370).
|
|
232
|
+
gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
|
|
218
233
|
}
|
|
219
234
|
catch (error) {
|
|
220
235
|
// Structured warning instead of bare console.error so the breadcrumb shows
|
|
@@ -224,5 +239,6 @@ export async function runClaim(options) {
|
|
|
224
239
|
return {
|
|
225
240
|
commentUrl: comment.html_url,
|
|
226
241
|
issueUrl: options.issueUrl,
|
|
242
|
+
...(gistSyncWarning ? { gistSyncWarning } : {}),
|
|
227
243
|
};
|
|
228
244
|
}
|
|
@@ -11,15 +11,20 @@
|
|
|
11
11
|
* mutation, runs against a public PR URL.
|
|
12
12
|
*/
|
|
13
13
|
import { getOctokit, requireGitHubToken } from '../core/index.js';
|
|
14
|
-
import { ValidationError } from '../core/errors.js';
|
|
14
|
+
import { ValidationError, errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
|
|
15
|
+
import { warn } from '../core/logger.js';
|
|
15
16
|
import { validateUrl, PR_URL_PATTERN, validateGitHubUrl } from './validation.js';
|
|
16
17
|
import { parseGitHubUrl } from '../core/urls.js';
|
|
17
18
|
import { computeComplianceScore, } from '../core/compliance-score.js';
|
|
19
|
+
const MODULE = 'compliance-score';
|
|
18
20
|
/**
|
|
19
21
|
* Detect whether the target repo has visible test infrastructure. Looks
|
|
20
22
|
* for the well-known directories at the repo root in a single contents
|
|
21
|
-
* call.
|
|
22
|
-
* `undefined` so the score function falls back to its strict default
|
|
23
|
+
* call. Non-fatal failures (missing repo, 5xx, network) surface as
|
|
24
|
+
* `undefined` so the score function falls back to its strict default,
|
|
25
|
+
* with a warning so "couldn't look" is distinguishable from "no test
|
|
26
|
+
* dir" in logs. Rate-limit / auth errors propagate — swallowing them
|
|
27
|
+
* here would mask a systemic problem affecting the whole run (#1373).
|
|
23
28
|
*/
|
|
24
29
|
async function detectTestInfrastructure(octokit, owner, repo) {
|
|
25
30
|
try {
|
|
@@ -29,7 +34,10 @@ async function detectTestInfrastructure(octokit, owner, repo) {
|
|
|
29
34
|
const TEST_DIR = /^(?:tests?|__tests__|spec)$/i;
|
|
30
35
|
return data.some((entry) => entry.type === 'dir' && TEST_DIR.test(entry.name));
|
|
31
36
|
}
|
|
32
|
-
catch {
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (isRateLimitOrAuthError(err))
|
|
39
|
+
throw err;
|
|
40
|
+
warn(MODULE, `test-infrastructure probe for ${owner}/${repo} failed: ${errorMessage(err)}`);
|
|
33
41
|
return undefined;
|
|
34
42
|
}
|
|
35
43
|
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* (daily-logic.test.ts) — pure rendering, easy to assert on string output.
|
|
16
16
|
*/
|
|
17
17
|
import type { CapacityAssessment, CommentedIssue, CommentedIssueWithResponse, DailyDigest, MaintainerActionHint } from '../core/types.js';
|
|
18
|
+
import type { AttentionSummary } from '../core/pr-attention.js';
|
|
18
19
|
/**
|
|
19
20
|
* Format a maintainer action hint as a human-readable label.
|
|
20
21
|
*/
|
|
@@ -24,7 +25,7 @@ export declare function formatActionHint(hint: MaintainerActionHint): string;
|
|
|
24
25
|
*
|
|
25
26
|
* @returns One-line status string (e.g., "3 Active PRs | 1 needs attention | 2 issue replies")
|
|
26
27
|
*/
|
|
27
|
-
export declare function formatBriefSummary(digest: DailyDigest, issueCount: number, issueResponseCount?: number): string;
|
|
28
|
+
export declare function formatBriefSummary(digest: DailyDigest, issueCount: number, issueResponseCount?: number, attention?: Pick<AttentionSummary, 'stuckCI' | 'dormantFollowup'>): string;
|
|
28
29
|
/**
|
|
29
30
|
* Format the full dashboard summary as markdown.
|
|
30
31
|
* Used in JSON output for Claude to display verbatim — includes all PR sections,
|
|
@@ -42,10 +42,16 @@ export function formatActionHint(hint) {
|
|
|
42
42
|
*
|
|
43
43
|
* @returns One-line status string (e.g., "3 Active PRs | 1 needs attention | 2 issue replies")
|
|
44
44
|
*/
|
|
45
|
-
export function formatBriefSummary(digest, issueCount, issueResponseCount = 0) {
|
|
45
|
+
export function formatBriefSummary(digest, issueCount, issueResponseCount = 0, attention) {
|
|
46
46
|
const attentionText = issueCount > 0 ? `${issueCount} need${issueCount === 1 ? 's' : ''} attention` : 'all on track';
|
|
47
47
|
const issueReplyText = issueResponseCount > 0 ? ` | ${issueResponseCount} issue repl${issueResponseCount === 1 ? 'y' : 'ies'}` : '';
|
|
48
|
-
|
|
48
|
+
// #1352: the watch-style buckets are appended only when non-zero — they're
|
|
49
|
+
// ping/nudge workflows, not part of the headline "need attention" count.
|
|
50
|
+
const stuckText = attention && attention.stuckCI > 0 ? ` | ${attention.stuckCI} stuck CI` : '';
|
|
51
|
+
const dormantText = attention && attention.dormantFollowup > 0
|
|
52
|
+
? ` | ${attention.dormantFollowup} dormant follow-up${attention.dormantFollowup === 1 ? '' : 's'}`
|
|
53
|
+
: '';
|
|
54
|
+
return `\u{1F4CA} ${digest.summary.totalActivePRs} Active PRs | ${attentionText}${issueReplyText}${stuckText}${dormantText}`;
|
|
49
55
|
}
|
|
50
56
|
/**
|
|
51
57
|
* Format the full dashboard summary as markdown.
|
package/dist/commands/daily.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Domain logic lives in src/core/daily-logic.ts; this file is a thin
|
|
7
7
|
* orchestration layer that wires up the phases and handles I/O.
|
|
8
8
|
*/
|
|
9
|
-
import { type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup } from '../core/index.js';
|
|
9
|
+
import { type AttentionSummary, type DailyDigest, type CommentedIssue, type PRCheckFailure, type RepoGroup } from '../core/index.js';
|
|
10
10
|
import { type StrategyResult } from '../core/strategy.js';
|
|
11
11
|
import { type DailyOutput, type DailyWarning, type CapacityAssessment, type ActionableIssue, type ActionMenu } from '../formatters/json.js';
|
|
12
12
|
export { applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
|
|
@@ -23,6 +23,8 @@ export interface DailyCheckResult {
|
|
|
23
23
|
summary: string;
|
|
24
24
|
briefSummary: string;
|
|
25
25
|
actionableIssues: ActionableIssue[];
|
|
26
|
+
/** Unified attention-bucket counts over active PRs (#1352). */
|
|
27
|
+
attention: AttentionSummary;
|
|
26
28
|
actionMenu: ActionMenu;
|
|
27
29
|
commentedIssues: CommentedIssue[];
|
|
28
30
|
repoGroups: RepoGroup[];
|
package/dist/commands/daily.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* Domain logic lives in src/core/daily-logic.ts; this file is a thin
|
|
7
7
|
* orchestration layer that wires up the phases and handles I/O.
|
|
8
8
|
*/
|
|
9
|
-
import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
|
|
9
|
+
import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, summarizeAttentionBuckets, } from '../core/index.js';
|
|
10
10
|
import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
|
|
11
|
+
import { wrapUntrustedContent } from '../core/untrusted-content.js';
|
|
11
12
|
import { computeStrategy, shouldComputeStrategy } from '../core/strategy.js';
|
|
12
13
|
import { warn } from '../core/logger.js';
|
|
13
14
|
import { emptyPRCountsResult } from '../core/github-stats.js';
|
|
@@ -419,7 +420,10 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
|
|
|
419
420
|
// Auto-undismiss mutations are auto-saved by undismissIssue()
|
|
420
421
|
const actionableIssues = collectActionableIssues(activePRs, previousLastDigestAt);
|
|
421
422
|
digest.summary.totalNeedingAttention = actionableIssues.length;
|
|
422
|
-
|
|
423
|
+
// #1352: one classifier for all attention surfaces — the dashboard stamps
|
|
424
|
+
// the same buckets per-PR, so the headline counts cannot diverge.
|
|
425
|
+
const attention = summarizeAttentionBuckets(activePRs);
|
|
426
|
+
const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length, attention);
|
|
423
427
|
const actionMenu = computeActionMenu(actionableIssues, capacity, filteredCommentedIssues);
|
|
424
428
|
const repoGroups = groupPRsByRepo(activePRs);
|
|
425
429
|
// Periodic strategy snapshot (#1270 Step 2). Cadence-gated to fire every
|
|
@@ -446,6 +450,7 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
|
|
|
446
450
|
summary,
|
|
447
451
|
briefSummary,
|
|
448
452
|
actionableIssues,
|
|
453
|
+
attention,
|
|
449
454
|
actionMenu,
|
|
450
455
|
commentedIssues: filteredCommentedIssues,
|
|
451
456
|
repoGroups,
|
|
@@ -457,6 +462,35 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
|
|
|
457
462
|
// ---------------------------------------------------------------------------
|
|
458
463
|
// Public API
|
|
459
464
|
// ---------------------------------------------------------------------------
|
|
465
|
+
/**
|
|
466
|
+
* Fence the GitHub-sourced comment excerpts in a CommentedIssue for
|
|
467
|
+
* agent-facing JSON (#1372). This happens HERE — not in
|
|
468
|
+
* `issue-conversation.ts` — because the producer's objects also feed the
|
|
469
|
+
* dashboard SPA and the CLI text renderers (`daily-render.ts`), which must
|
|
470
|
+
* not display raw fence tags, and the producer's internal classification
|
|
471
|
+
* (acknowledgment / @mention matching) string-matches on raw bodies.
|
|
472
|
+
*
|
|
473
|
+
* `userLastCommentBody` is fenced too: it is the user's own comment, but
|
|
474
|
+
* repo maintainers can edit any comment in their repos, so it is still
|
|
475
|
+
* GitHub-controlled text by the time we re-fetch it.
|
|
476
|
+
*/
|
|
477
|
+
function fenceCommentedIssue(issue) {
|
|
478
|
+
const label = `${issue.repo}#${issue.number}`;
|
|
479
|
+
if (issue.status === 'new_response') {
|
|
480
|
+
return {
|
|
481
|
+
...issue,
|
|
482
|
+
userLastCommentBody: wrapUntrustedContent(issue.userLastCommentBody, label, { source: 'issue-comment' }),
|
|
483
|
+
lastResponseBody: wrapUntrustedContent(issue.lastResponseBody, label, {
|
|
484
|
+
author: issue.lastResponseAuthor,
|
|
485
|
+
source: 'issue-comment',
|
|
486
|
+
}),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
...issue,
|
|
491
|
+
userLastCommentBody: wrapUntrustedContent(issue.userLastCommentBody, label, { source: 'issue-comment' }),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
460
494
|
/**
|
|
461
495
|
* Convert a full DailyCheckResult to the compact DailyOutput for JSON serialization (#287).
|
|
462
496
|
* Deduplicates PR objects: category arrays become PR URL references,
|
|
@@ -472,8 +506,9 @@ export function toDailyOutput(result) {
|
|
|
472
506
|
summary: result.summary,
|
|
473
507
|
briefSummary: result.briefSummary,
|
|
474
508
|
actionableIssues: compactActionableIssues(result.actionableIssues),
|
|
509
|
+
attention: result.attention,
|
|
475
510
|
actionMenu: result.actionMenu,
|
|
476
|
-
commentedIssues: result.commentedIssues,
|
|
511
|
+
commentedIssues: result.commentedIssues.map(fenceCommentedIssue),
|
|
477
512
|
repoGroups: compactRepoGroups(result.repoGroups),
|
|
478
513
|
failures: result.failures,
|
|
479
514
|
warnings: result.warnings,
|
|
@@ -516,6 +551,13 @@ async function executeDailyCheckInternal(token) {
|
|
|
516
551
|
if (staleness) {
|
|
517
552
|
warnings.push(buildStalenessWarning(staleness));
|
|
518
553
|
}
|
|
554
|
+
// Surface a state-load recovery (corrupt state.json restored from backup or
|
|
555
|
+
// replaced with fresh state) so it's machine-visible, not just stderr (#1371).
|
|
556
|
+
const loadRecovery = getStateManager().getLoadRecovery();
|
|
557
|
+
if (loadRecovery) {
|
|
558
|
+
recordWarning(warnings, 'state-load', 'State file recovery', new Error(`${loadRecovery.reason}; recovered from ${loadRecovery.source}` +
|
|
559
|
+
(loadRecovery.rejectedPath ? `; rejected file preserved at ${loadRecovery.rejectedPath}` : '')));
|
|
560
|
+
}
|
|
519
561
|
// Phase 1: Fetch all PR data from GitHub
|
|
520
562
|
const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token, warnings);
|
|
521
563
|
// Phase 2: Update repo scores (signals, star counts, trust sync)
|
|
@@ -566,10 +608,18 @@ async function executeDailyCheckInternal(token) {
|
|
|
566
608
|
// Checkpoint: push state to Gist if in Gist mode.
|
|
567
609
|
// If getStateManagerAsync was not called before this command ran,
|
|
568
610
|
// isGistMode() will be false and checkpoint is correctly skipped.
|
|
611
|
+
// `warnings` is the same array referenced by `result`, so warnings
|
|
612
|
+
// recorded here still reach the structured output.
|
|
569
613
|
try {
|
|
570
614
|
const sm = getStateManager();
|
|
571
615
|
if (sm.isGistMode()) {
|
|
572
|
-
|
|
616
|
+
// checkpoint() resolves false (no throw) when the push failed after
|
|
617
|
+
// its retry — previously discarded, reporting clean success while
|
|
618
|
+
// cross-machine state silently drifted (#1370).
|
|
619
|
+
const pushed = await sm.checkpoint();
|
|
620
|
+
if (!pushed) {
|
|
621
|
+
recordWarning(warnings, 'gist-checkpoint', 'Gist checkpoint', new Error('push failed after retry; state not synced to Gist this run'));
|
|
622
|
+
}
|
|
573
623
|
}
|
|
574
624
|
}
|
|
575
625
|
catch (err) {
|
|
@@ -67,6 +67,23 @@ export interface ActionRequest {
|
|
|
67
67
|
target?: 'attention' | 'waiting' | 'shelved' | 'auto';
|
|
68
68
|
}
|
|
69
69
|
export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>, storedMergedCount?: number, storedClosedCount?: number): DashboardStats;
|
|
70
|
+
/**
|
|
71
|
+
* Re-derive `digest.shelvedPRs` and `summary.totalActivePRs` from the CURRENT
|
|
72
|
+
* `state.config.shelvedPRUrls` so a shelve/unshelve issued from the dashboard
|
|
73
|
+
* SPA is reflected immediately.
|
|
74
|
+
*
|
|
75
|
+
* Why this exists: POST /api/action → runMove only mutates
|
|
76
|
+
* `state.config.shelvedPRUrls`; it never touches the cached digest.
|
|
77
|
+
* buildDashboardJson derives both `shelvedPRUrls` and `stats.shelvedPRs` from
|
|
78
|
+
* `digest.shelvedPRs`, so without this reconciliation a dashboard shelve/unshelve
|
|
79
|
+
* appears to do nothing until the next full /api/refresh rebuilds the digest.
|
|
80
|
+
*
|
|
81
|
+
* Baked shelved entries that are NOT among the current open PRs (the daily-check
|
|
82
|
+
* dormant partition, #981) are preserved as-is — the SPA cannot act on those —
|
|
83
|
+
* while explicit shelve/unshelve of open PRs and the dormant-auto-shelve rule
|
|
84
|
+
* are honored.
|
|
85
|
+
*/
|
|
86
|
+
export declare function reconcileShelvePartition(digest: DailyDigest, state: Readonly<AgentState>): void;
|
|
70
87
|
/**
|
|
71
88
|
* Merge fresh API counts into existing stored counts.
|
|
72
89
|
* Months present in the fresh data are updated; months only in the existing data are preserved.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Handles GitHub API calls, PR grouping, stats computation, and monthly chart data.
|
|
4
4
|
* Consumed by the dashboard HTTP server (dashboard-server.ts) for the SPA API.
|
|
5
5
|
*/
|
|
6
|
-
import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit } from '../core/index.js';
|
|
6
|
+
import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit, CRITICAL_STATUSES } from '../core/index.js';
|
|
7
7
|
import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
|
|
8
8
|
import { warn } from '../core/logger.js';
|
|
9
9
|
import { emptyPRCountsResult, fetchMergedPRsSince, fetchClosedPRsSince } from '../core/github-stats.js';
|
|
@@ -35,6 +35,62 @@ export function buildDashboardStats(digest, state, storedMergedCount, storedClos
|
|
|
35
35
|
mergeRate: `${(summary.mergeRate ?? 0).toFixed(1)}%`,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* A PR is shelved for display when the user explicitly shelved it, or the
|
|
40
|
+
* dormant-auto-shelve rule applies (dormant + not needing attention). Mirrors
|
|
41
|
+
* the partition built in fetchDashboardData (see the `freshShelved` filter).
|
|
42
|
+
*
|
|
43
|
+
* #1352: an explicitly shelved PR that turns critical is NOT shelved for
|
|
44
|
+
* display — the daily check auto-unshelves it (`CRITICAL_STATUSES`), so the
|
|
45
|
+
* dashboard must agree immediately rather than diverging from the CLI's
|
|
46
|
+
* headline count until the next daily run.
|
|
47
|
+
*/
|
|
48
|
+
function isShelvedForDisplay(pr, explicitlyShelved) {
|
|
49
|
+
if (CRITICAL_STATUSES.has(pr.status))
|
|
50
|
+
return false;
|
|
51
|
+
return explicitlyShelved.has(pr.url) || pr.stalenessTier === 'dormant';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Re-derive `digest.shelvedPRs` and `summary.totalActivePRs` from the CURRENT
|
|
55
|
+
* `state.config.shelvedPRUrls` so a shelve/unshelve issued from the dashboard
|
|
56
|
+
* SPA is reflected immediately.
|
|
57
|
+
*
|
|
58
|
+
* Why this exists: POST /api/action → runMove only mutates
|
|
59
|
+
* `state.config.shelvedPRUrls`; it never touches the cached digest.
|
|
60
|
+
* buildDashboardJson derives both `shelvedPRUrls` and `stats.shelvedPRs` from
|
|
61
|
+
* `digest.shelvedPRs`, so without this reconciliation a dashboard shelve/unshelve
|
|
62
|
+
* appears to do nothing until the next full /api/refresh rebuilds the digest.
|
|
63
|
+
*
|
|
64
|
+
* Baked shelved entries that are NOT among the current open PRs (the daily-check
|
|
65
|
+
* dormant partition, #981) are preserved as-is — the SPA cannot act on those —
|
|
66
|
+
* while explicit shelve/unshelve of open PRs and the dormant-auto-shelve rule
|
|
67
|
+
* are honored.
|
|
68
|
+
*/
|
|
69
|
+
export function reconcileShelvePartition(digest, state) {
|
|
70
|
+
const openPRs = (digest.openPRs || []);
|
|
71
|
+
const openByUrl = new Map(openPRs.map((pr) => [pr.url, pr]));
|
|
72
|
+
const explicitlyShelved = new Set(state.config.shelvedPRUrls || []);
|
|
73
|
+
// Keep baked entries that are either off the open-PR list (daily dormant
|
|
74
|
+
// partition we can't recompute here) or still shelved under current state.
|
|
75
|
+
const reconciled = (digest.shelvedPRs || []).filter((ref) => {
|
|
76
|
+
const pr = openByUrl.get(ref.url);
|
|
77
|
+
return !pr || isShelvedForDisplay(pr, explicitlyShelved);
|
|
78
|
+
});
|
|
79
|
+
// Add open PRs that became shelved but aren't represented in the baked list yet.
|
|
80
|
+
const present = new Set(reconciled.map((ref) => ref.url));
|
|
81
|
+
for (const pr of openPRs) {
|
|
82
|
+
if (!present.has(pr.url) && isShelvedForDisplay(pr, explicitlyShelved)) {
|
|
83
|
+
reconciled.push(toShelvedPRRef(pr));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
digest.shelvedPRs = reconciled;
|
|
87
|
+
// Only re-derive the active count when we actually have the open-PR list;
|
|
88
|
+
// some digests carry an authoritative summary with an empty openPRs fixture.
|
|
89
|
+
if (openPRs.length > 0) {
|
|
90
|
+
const shelvedOpen = reconciled.filter((ref) => openByUrl.has(ref.url)).length;
|
|
91
|
+
digest.summary.totalActivePRs = openPRs.length - shelvedOpen;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
38
94
|
/**
|
|
39
95
|
* Merge fresh API counts into existing stored counts.
|
|
40
96
|
* Months present in the fresh data are updated; months only in the existing data are preserved.
|
|
@@ -205,10 +261,12 @@ export async function fetchDashboardData(token) {
|
|
|
205
261
|
const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
|
|
206
262
|
updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
|
|
207
263
|
const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
|
|
208
|
-
// Apply shelve partitioning for display (auto-unshelve only runs in daily check)
|
|
209
|
-
// Dormant PRs are treated as shelved unless they need addressing
|
|
264
|
+
// Apply shelve partitioning for display (auto-unshelve only runs in daily check).
|
|
265
|
+
// Dormant PRs are treated as shelved unless they need addressing, and a
|
|
266
|
+
// critical PR is never display-shelved (#1352) — mirrors the daily
|
|
267
|
+
// check's CRITICAL_STATUSES auto-unshelve so headline counts agree.
|
|
210
268
|
const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
|
|
211
|
-
const freshShelved = prs.filter((pr) => shelvedUrls.has(pr.url) ||
|
|
269
|
+
const freshShelved = prs.filter((pr) => !CRITICAL_STATUSES.has(pr.status) && (shelvedUrls.has(pr.url) || pr.stalenessTier === 'dormant'));
|
|
212
270
|
digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
|
|
213
271
|
digest.autoUnshelvedPRs = [];
|
|
214
272
|
digest.summary.totalActivePRs = prs.length - freshShelved.length;
|
|
@@ -9,11 +9,11 @@ import * as http from 'node:http';
|
|
|
9
9
|
import * as fs from 'node:fs';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import * as crypto from 'node:crypto';
|
|
12
|
-
import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } from '../core/index.js';
|
|
13
|
-
import { errorMessage, ValidationError } from '../core/errors.js';
|
|
12
|
+
import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, } from '../core/index.js';
|
|
13
|
+
import { errorMessage, ValidationError, ConcurrencyError, GistConcurrencyError } from '../core/errors.js';
|
|
14
14
|
import { warn } from '../core/logger.js';
|
|
15
15
|
import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
|
|
16
|
-
import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
|
|
16
|
+
import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, reconcileShelvePartition, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
|
|
17
17
|
import { openInBrowser, detectIssueList } from './startup.js';
|
|
18
18
|
import { parseIssueList } from './parse-list.js';
|
|
19
19
|
import { writeDashboardServerInfo, removeDashboardServerInfo } from './dashboard-process.js';
|
|
@@ -75,6 +75,11 @@ function getIssueListMtimeMs() {
|
|
|
75
75
|
* start-up, so tests that need a specific digest should call this directly).
|
|
76
76
|
*/
|
|
77
77
|
export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs, partialFailures) {
|
|
78
|
+
// Re-derive the shelve partition from the CURRENT state before reading it.
|
|
79
|
+
// The POST /api/action path rebuilds with a cached digest whose shelvedPRs
|
|
80
|
+
// predates the shelve/unshelve, so without this the SPA action appears to do
|
|
81
|
+
// nothing until the next full /api/refresh.
|
|
82
|
+
reconcileShelvePartition(digest, state);
|
|
78
83
|
const prsByRepo = computePRsByRepo(digest, state);
|
|
79
84
|
const topRepos = computeTopRepos(prsByRepo);
|
|
80
85
|
const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
|
|
@@ -108,7 +113,12 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
|
|
|
108
113
|
monthlyMerged,
|
|
109
114
|
monthlyOpened,
|
|
110
115
|
monthlyClosed,
|
|
111
|
-
|
|
116
|
+
// #1352: stamp the unified attention bucket so the SPA renders the same
|
|
117
|
+
// taxonomy the CLI brief counts (single classifier, no second opinion).
|
|
118
|
+
activePRs: applyStatusOverrides(digest.openPRs || [], state).map((pr) => ({
|
|
119
|
+
...pr,
|
|
120
|
+
attentionBucket: classifyAttentionBucket(pr),
|
|
121
|
+
})),
|
|
112
122
|
// Source of truth is digest.shelvedPRs (union of explicitly-shelved URLs
|
|
113
123
|
// and dormant-non-addressing PRs auto-shelved for display). Returning
|
|
114
124
|
// only state.config.shelvedPRUrls would under-count and desync from
|
|
@@ -240,6 +250,27 @@ function sendJson(res, statusCode, data) {
|
|
|
240
250
|
function sendError(res, statusCode, message) {
|
|
241
251
|
sendJson(res, statusCode, { error: message });
|
|
242
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* True when an error is an optimistic-concurrency conflict on state.json
|
|
255
|
+
* (local mtime CAS) or the state Gist (ETag CAS). Both carry the
|
|
256
|
+
* CONCURRENCY_ERROR code and the same recovery contract: reload, re-apply,
|
|
257
|
+
* save. See state-concurrency.test.ts (#1378) and errors.ts.
|
|
258
|
+
*/
|
|
259
|
+
function isConcurrencyConflict(error) {
|
|
260
|
+
return error instanceof ConcurrencyError || error instanceof GistConcurrencyError;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Send the machine-readable 409 for a concurrency conflict (#1397).
|
|
264
|
+
* `retryable: true` tells the SPA it can re-prime via GET /api/data and
|
|
265
|
+
* retry the POST; `code` matches the structured code on ConcurrencyError.
|
|
266
|
+
*/
|
|
267
|
+
function sendConflict(res) {
|
|
268
|
+
sendJson(res, 409, {
|
|
269
|
+
error: 'Another process wrote state concurrently — retry the request',
|
|
270
|
+
code: 'CONCURRENCY_ERROR',
|
|
271
|
+
retryable: true,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
243
274
|
// ── Server ─────────────────────────────────────────────────────────────────────
|
|
244
275
|
export async function startDashboardServer(options) {
|
|
245
276
|
const { port: requestedPort, assetsDir, token, open } = options;
|
|
@@ -398,14 +429,16 @@ export async function startDashboardServer(options) {
|
|
|
398
429
|
});
|
|
399
430
|
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
400
431
|
// ── POST /api/action handler ─────────────────────────────────────────────
|
|
401
|
-
|
|
402
|
-
|
|
432
|
+
/** Re-read state written by external processes (CLI) before mutating. */
|
|
433
|
+
async function reloadState() {
|
|
403
434
|
if (stateManager.isGistMode()) {
|
|
404
435
|
await stateManager.refreshFromGist();
|
|
405
436
|
}
|
|
406
437
|
else {
|
|
407
438
|
stateManager.reloadIfChanged();
|
|
408
439
|
}
|
|
440
|
+
}
|
|
441
|
+
async function handleAction(req, res) {
|
|
409
442
|
let body;
|
|
410
443
|
try {
|
|
411
444
|
const raw = await readBody(req);
|
|
@@ -440,24 +473,62 @@ export async function startDashboardServer(options) {
|
|
|
440
473
|
}
|
|
441
474
|
return;
|
|
442
475
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
476
|
+
// Resolve the mutation up-front so target validation happens before the
|
|
477
|
+
// state reload — keeps the reload-to-save freshness window minimal.
|
|
478
|
+
let applyMutation;
|
|
479
|
+
if (body.action === 'move') {
|
|
480
|
+
const { VALID_TARGETS, runMove } = await import('./move.js');
|
|
481
|
+
if (!body.target || !VALID_TARGETS.includes(body.target)) {
|
|
482
|
+
sendError(res, 400, `move requires a valid "target" field (${VALID_TARGETS.join(', ')})`);
|
|
483
|
+
return;
|
|
451
484
|
}
|
|
452
|
-
|
|
453
|
-
|
|
485
|
+
const target = body.target;
|
|
486
|
+
applyMutation = async () => {
|
|
487
|
+
await runMove({ prUrl: body.url, target });
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
// dismiss_issue_response
|
|
492
|
+
applyMutation = () => {
|
|
454
493
|
stateManager.dismissIssue(body.url, new Date().toISOString());
|
|
455
|
-
}
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// Reload state before mutating to avoid overwriting external CLI changes.
|
|
497
|
+
// Runs AFTER body parsing/validation (which only inspects the request,
|
|
498
|
+
// never the loaded state) so the read-modify-write window excludes the
|
|
499
|
+
// body-streaming time (#1397).
|
|
500
|
+
await reloadState();
|
|
501
|
+
try {
|
|
502
|
+
await applyMutation();
|
|
456
503
|
}
|
|
457
504
|
catch (error) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
505
|
+
if (!isConcurrencyConflict(error)) {
|
|
506
|
+
warn(MODULE, `Action failed: ${body.action} ${body.url} ${errorMessage(error)}`);
|
|
507
|
+
sendError(res, 500, 'Action failed');
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// Concurrency conflict: an external write landed between our reload and
|
|
511
|
+
// save. Decision (#1397): retry ONCE server-side instead of bouncing the
|
|
512
|
+
// first conflict to the client. Both mutations (move targets, dismiss)
|
|
513
|
+
// are absolute set operations, so re-applying them on a freshly reloaded
|
|
514
|
+
// baseline is safe — exactly the reload-reapply recovery contract pinned
|
|
515
|
+
// in state-concurrency.test.ts. A second consecutive conflict means
|
|
516
|
+
// sustained contention; surface it as a retryable 409 and let the SPA
|
|
517
|
+
// re-prime and retry.
|
|
518
|
+
try {
|
|
519
|
+
await reloadState();
|
|
520
|
+
await applyMutation();
|
|
521
|
+
}
|
|
522
|
+
catch (retryError) {
|
|
523
|
+
if (isConcurrencyConflict(retryError)) {
|
|
524
|
+
warn(MODULE, `Action conflicted twice: ${body.action} ${body.url} ${errorMessage(retryError)}`);
|
|
525
|
+
sendConflict(res);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
warn(MODULE, `Action failed on conflict retry: ${body.action} ${body.url} ${errorMessage(retryError)}`);
|
|
529
|
+
sendError(res, 500, 'Action failed');
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
461
532
|
}
|
|
462
533
|
// Rebuild dashboard data from cached digest + updated state. Persist
|
|
463
534
|
// the last-known partialFailures across action rebuilds (#1035) so the
|
|
@@ -475,12 +546,7 @@ export async function startDashboardServer(options) {
|
|
|
475
546
|
return;
|
|
476
547
|
}
|
|
477
548
|
try {
|
|
478
|
-
|
|
479
|
-
await stateManager.refreshFromGist();
|
|
480
|
-
}
|
|
481
|
-
else {
|
|
482
|
-
stateManager.reloadIfChanged();
|
|
483
|
-
}
|
|
549
|
+
await reloadState();
|
|
484
550
|
warn(MODULE, 'Refreshing dashboard data from GitHub...');
|
|
485
551
|
const result = await fetchDashboardData(currentToken);
|
|
486
552
|
cachedDigest = result.digest;
|
|
@@ -493,6 +559,14 @@ export async function startDashboardServer(options) {
|
|
|
493
559
|
sendJson(res, 200, cachedJsonData);
|
|
494
560
|
}
|
|
495
561
|
catch (error) {
|
|
562
|
+
// No server-side retry here (unlike handleAction): a refresh re-run is a
|
|
563
|
+
// full GitHub fetch — expensive and rate-limited. The 409 is retryable
|
|
564
|
+
// by the client, which re-POSTs /api/refresh on its own schedule (#1397).
|
|
565
|
+
if (isConcurrencyConflict(error)) {
|
|
566
|
+
warn(MODULE, `Dashboard refresh hit a concurrent state write: ${errorMessage(error)}`);
|
|
567
|
+
sendConflict(res);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
496
570
|
warn(MODULE, `Dashboard refresh failed: ${errorMessage(error)}`);
|
|
497
571
|
sendError(res, 500, 'Refresh failed');
|
|
498
572
|
}
|