@oss-autopilot/core 3.9.0 → 3.11.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 +29 -5
- package/dist/cli.bundle.cjs +114 -114
- 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.js +47 -2
- package/dist/commands/dashboard-data.d.ts +17 -0
- package/dist/commands/dashboard-data.js +49 -0
- package/dist/commands/dashboard-server.js +93 -24
- 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 +3 -1
- package/dist/commands/index.js +2 -0
- 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 +20 -2
- package/dist/commands/shelve.d.ts +4 -0
- package/dist/commands/shelve.js +4 -4
- package/dist/core/gist-state-store.js +42 -7
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/issue-conversation.js +15 -2
- package/dist/core/paths.d.ts +12 -0
- package/dist/core/paths.js +16 -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/untrusted-content.d.ts +24 -3
- package/dist/core/untrusted-content.js +31 -3
- package/dist/formatters/json.d.ts +15 -1
- package/dist/formatters/json.js +20 -0
- 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
|
}
|
package/dist/commands/daily.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } 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';
|
|
@@ -457,6 +458,35 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
|
|
|
457
458
|
// ---------------------------------------------------------------------------
|
|
458
459
|
// Public API
|
|
459
460
|
// ---------------------------------------------------------------------------
|
|
461
|
+
/**
|
|
462
|
+
* Fence the GitHub-sourced comment excerpts in a CommentedIssue for
|
|
463
|
+
* agent-facing JSON (#1372). This happens HERE — not in
|
|
464
|
+
* `issue-conversation.ts` — because the producer's objects also feed the
|
|
465
|
+
* dashboard SPA and the CLI text renderers (`daily-render.ts`), which must
|
|
466
|
+
* not display raw fence tags, and the producer's internal classification
|
|
467
|
+
* (acknowledgment / @mention matching) string-matches on raw bodies.
|
|
468
|
+
*
|
|
469
|
+
* `userLastCommentBody` is fenced too: it is the user's own comment, but
|
|
470
|
+
* repo maintainers can edit any comment in their repos, so it is still
|
|
471
|
+
* GitHub-controlled text by the time we re-fetch it.
|
|
472
|
+
*/
|
|
473
|
+
function fenceCommentedIssue(issue) {
|
|
474
|
+
const label = `${issue.repo}#${issue.number}`;
|
|
475
|
+
if (issue.status === 'new_response') {
|
|
476
|
+
return {
|
|
477
|
+
...issue,
|
|
478
|
+
userLastCommentBody: wrapUntrustedContent(issue.userLastCommentBody, label, { source: 'issue-comment' }),
|
|
479
|
+
lastResponseBody: wrapUntrustedContent(issue.lastResponseBody, label, {
|
|
480
|
+
author: issue.lastResponseAuthor,
|
|
481
|
+
source: 'issue-comment',
|
|
482
|
+
}),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
...issue,
|
|
487
|
+
userLastCommentBody: wrapUntrustedContent(issue.userLastCommentBody, label, { source: 'issue-comment' }),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
460
490
|
/**
|
|
461
491
|
* Convert a full DailyCheckResult to the compact DailyOutput for JSON serialization (#287).
|
|
462
492
|
* Deduplicates PR objects: category arrays become PR URL references,
|
|
@@ -473,7 +503,7 @@ export function toDailyOutput(result) {
|
|
|
473
503
|
briefSummary: result.briefSummary,
|
|
474
504
|
actionableIssues: compactActionableIssues(result.actionableIssues),
|
|
475
505
|
actionMenu: result.actionMenu,
|
|
476
|
-
commentedIssues: result.commentedIssues,
|
|
506
|
+
commentedIssues: result.commentedIssues.map(fenceCommentedIssue),
|
|
477
507
|
repoGroups: compactRepoGroups(result.repoGroups),
|
|
478
508
|
failures: result.failures,
|
|
479
509
|
warnings: result.warnings,
|
|
@@ -516,6 +546,13 @@ async function executeDailyCheckInternal(token) {
|
|
|
516
546
|
if (staleness) {
|
|
517
547
|
warnings.push(buildStalenessWarning(staleness));
|
|
518
548
|
}
|
|
549
|
+
// Surface a state-load recovery (corrupt state.json restored from backup or
|
|
550
|
+
// replaced with fresh state) so it's machine-visible, not just stderr (#1371).
|
|
551
|
+
const loadRecovery = getStateManager().getLoadRecovery();
|
|
552
|
+
if (loadRecovery) {
|
|
553
|
+
recordWarning(warnings, 'state-load', 'State file recovery', new Error(`${loadRecovery.reason}; recovered from ${loadRecovery.source}` +
|
|
554
|
+
(loadRecovery.rejectedPath ? `; rejected file preserved at ${loadRecovery.rejectedPath}` : '')));
|
|
555
|
+
}
|
|
519
556
|
// Phase 1: Fetch all PR data from GitHub
|
|
520
557
|
const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token, warnings);
|
|
521
558
|
// Phase 2: Update repo scores (signals, star counts, trust sync)
|
|
@@ -566,10 +603,18 @@ async function executeDailyCheckInternal(token) {
|
|
|
566
603
|
// Checkpoint: push state to Gist if in Gist mode.
|
|
567
604
|
// If getStateManagerAsync was not called before this command ran,
|
|
568
605
|
// isGistMode() will be false and checkpoint is correctly skipped.
|
|
606
|
+
// `warnings` is the same array referenced by `result`, so warnings
|
|
607
|
+
// recorded here still reach the structured output.
|
|
569
608
|
try {
|
|
570
609
|
const sm = getStateManager();
|
|
571
610
|
if (sm.isGistMode()) {
|
|
572
|
-
|
|
611
|
+
// checkpoint() resolves false (no throw) when the push failed after
|
|
612
|
+
// its retry — previously discarded, reporting clean success while
|
|
613
|
+
// cross-machine state silently drifted (#1370).
|
|
614
|
+
const pushed = await sm.checkpoint();
|
|
615
|
+
if (!pushed) {
|
|
616
|
+
recordWarning(warnings, 'gist-checkpoint', 'Gist checkpoint', new Error('push failed after retry; state not synced to Gist this run'));
|
|
617
|
+
}
|
|
573
618
|
}
|
|
574
619
|
}
|
|
575
620
|
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.
|
|
@@ -35,6 +35,55 @@ 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
|
+
function isShelvedForDisplay(pr, explicitlyShelved) {
|
|
44
|
+
return explicitlyShelved.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Re-derive `digest.shelvedPRs` and `summary.totalActivePRs` from the CURRENT
|
|
48
|
+
* `state.config.shelvedPRUrls` so a shelve/unshelve issued from the dashboard
|
|
49
|
+
* SPA is reflected immediately.
|
|
50
|
+
*
|
|
51
|
+
* Why this exists: POST /api/action → runMove only mutates
|
|
52
|
+
* `state.config.shelvedPRUrls`; it never touches the cached digest.
|
|
53
|
+
* buildDashboardJson derives both `shelvedPRUrls` and `stats.shelvedPRs` from
|
|
54
|
+
* `digest.shelvedPRs`, so without this reconciliation a dashboard shelve/unshelve
|
|
55
|
+
* appears to do nothing until the next full /api/refresh rebuilds the digest.
|
|
56
|
+
*
|
|
57
|
+
* Baked shelved entries that are NOT among the current open PRs (the daily-check
|
|
58
|
+
* dormant partition, #981) are preserved as-is — the SPA cannot act on those —
|
|
59
|
+
* while explicit shelve/unshelve of open PRs and the dormant-auto-shelve rule
|
|
60
|
+
* are honored.
|
|
61
|
+
*/
|
|
62
|
+
export function reconcileShelvePartition(digest, state) {
|
|
63
|
+
const openPRs = (digest.openPRs || []);
|
|
64
|
+
const openByUrl = new Map(openPRs.map((pr) => [pr.url, pr]));
|
|
65
|
+
const explicitlyShelved = new Set(state.config.shelvedPRUrls || []);
|
|
66
|
+
// Keep baked entries that are either off the open-PR list (daily dormant
|
|
67
|
+
// partition we can't recompute here) or still shelved under current state.
|
|
68
|
+
const reconciled = (digest.shelvedPRs || []).filter((ref) => {
|
|
69
|
+
const pr = openByUrl.get(ref.url);
|
|
70
|
+
return !pr || isShelvedForDisplay(pr, explicitlyShelved);
|
|
71
|
+
});
|
|
72
|
+
// Add open PRs that became shelved but aren't represented in the baked list yet.
|
|
73
|
+
const present = new Set(reconciled.map((ref) => ref.url));
|
|
74
|
+
for (const pr of openPRs) {
|
|
75
|
+
if (!present.has(pr.url) && isShelvedForDisplay(pr, explicitlyShelved)) {
|
|
76
|
+
reconciled.push(toShelvedPRRef(pr));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
digest.shelvedPRs = reconciled;
|
|
80
|
+
// Only re-derive the active count when we actually have the open-PR list;
|
|
81
|
+
// some digests carry an authoritative summary with an empty openPRs fixture.
|
|
82
|
+
if (openPRs.length > 0) {
|
|
83
|
+
const shelvedOpen = reconciled.filter((ref) => openByUrl.has(ref.url)).length;
|
|
84
|
+
digest.summary.totalActivePRs = openPRs.length - shelvedOpen;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
38
87
|
/**
|
|
39
88
|
* Merge fresh API counts into existing stored counts.
|
|
40
89
|
* Months present in the fresh data are updated; months only in the existing data are preserved.
|
|
@@ -10,10 +10,10 @@ import * as fs from 'node:fs';
|
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import * as crypto from 'node:crypto';
|
|
12
12
|
import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } from '../core/index.js';
|
|
13
|
-
import { errorMessage, ValidationError } from '../core/errors.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);
|
|
@@ -240,6 +245,27 @@ function sendJson(res, statusCode, data) {
|
|
|
240
245
|
function sendError(res, statusCode, message) {
|
|
241
246
|
sendJson(res, statusCode, { error: message });
|
|
242
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* True when an error is an optimistic-concurrency conflict on state.json
|
|
250
|
+
* (local mtime CAS) or the state Gist (ETag CAS). Both carry the
|
|
251
|
+
* CONCURRENCY_ERROR code and the same recovery contract: reload, re-apply,
|
|
252
|
+
* save. See state-concurrency.test.ts (#1378) and errors.ts.
|
|
253
|
+
*/
|
|
254
|
+
function isConcurrencyConflict(error) {
|
|
255
|
+
return error instanceof ConcurrencyError || error instanceof GistConcurrencyError;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Send the machine-readable 409 for a concurrency conflict (#1397).
|
|
259
|
+
* `retryable: true` tells the SPA it can re-prime via GET /api/data and
|
|
260
|
+
* retry the POST; `code` matches the structured code on ConcurrencyError.
|
|
261
|
+
*/
|
|
262
|
+
function sendConflict(res) {
|
|
263
|
+
sendJson(res, 409, {
|
|
264
|
+
error: 'Another process wrote state concurrently — retry the request',
|
|
265
|
+
code: 'CONCURRENCY_ERROR',
|
|
266
|
+
retryable: true,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
243
269
|
// ── Server ─────────────────────────────────────────────────────────────────────
|
|
244
270
|
export async function startDashboardServer(options) {
|
|
245
271
|
const { port: requestedPort, assetsDir, token, open } = options;
|
|
@@ -398,14 +424,16 @@ export async function startDashboardServer(options) {
|
|
|
398
424
|
});
|
|
399
425
|
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
400
426
|
// ── POST /api/action handler ─────────────────────────────────────────────
|
|
401
|
-
|
|
402
|
-
|
|
427
|
+
/** Re-read state written by external processes (CLI) before mutating. */
|
|
428
|
+
async function reloadState() {
|
|
403
429
|
if (stateManager.isGistMode()) {
|
|
404
430
|
await stateManager.refreshFromGist();
|
|
405
431
|
}
|
|
406
432
|
else {
|
|
407
433
|
stateManager.reloadIfChanged();
|
|
408
434
|
}
|
|
435
|
+
}
|
|
436
|
+
async function handleAction(req, res) {
|
|
409
437
|
let body;
|
|
410
438
|
try {
|
|
411
439
|
const raw = await readBody(req);
|
|
@@ -440,24 +468,62 @@ export async function startDashboardServer(options) {
|
|
|
440
468
|
}
|
|
441
469
|
return;
|
|
442
470
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
471
|
+
// Resolve the mutation up-front so target validation happens before the
|
|
472
|
+
// state reload — keeps the reload-to-save freshness window minimal.
|
|
473
|
+
let applyMutation;
|
|
474
|
+
if (body.action === 'move') {
|
|
475
|
+
const { VALID_TARGETS, runMove } = await import('./move.js');
|
|
476
|
+
if (!body.target || !VALID_TARGETS.includes(body.target)) {
|
|
477
|
+
sendError(res, 400, `move requires a valid "target" field (${VALID_TARGETS.join(', ')})`);
|
|
478
|
+
return;
|
|
451
479
|
}
|
|
452
|
-
|
|
453
|
-
|
|
480
|
+
const target = body.target;
|
|
481
|
+
applyMutation = async () => {
|
|
482
|
+
await runMove({ prUrl: body.url, target });
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
// dismiss_issue_response
|
|
487
|
+
applyMutation = () => {
|
|
454
488
|
stateManager.dismissIssue(body.url, new Date().toISOString());
|
|
455
|
-
}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// Reload state before mutating to avoid overwriting external CLI changes.
|
|
492
|
+
// Runs AFTER body parsing/validation (which only inspects the request,
|
|
493
|
+
// never the loaded state) so the read-modify-write window excludes the
|
|
494
|
+
// body-streaming time (#1397).
|
|
495
|
+
await reloadState();
|
|
496
|
+
try {
|
|
497
|
+
await applyMutation();
|
|
456
498
|
}
|
|
457
499
|
catch (error) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
500
|
+
if (!isConcurrencyConflict(error)) {
|
|
501
|
+
warn(MODULE, `Action failed: ${body.action} ${body.url} ${errorMessage(error)}`);
|
|
502
|
+
sendError(res, 500, 'Action failed');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// Concurrency conflict: an external write landed between our reload and
|
|
506
|
+
// save. Decision (#1397): retry ONCE server-side instead of bouncing the
|
|
507
|
+
// first conflict to the client. Both mutations (move targets, dismiss)
|
|
508
|
+
// are absolute set operations, so re-applying them on a freshly reloaded
|
|
509
|
+
// baseline is safe — exactly the reload-reapply recovery contract pinned
|
|
510
|
+
// in state-concurrency.test.ts. A second consecutive conflict means
|
|
511
|
+
// sustained contention; surface it as a retryable 409 and let the SPA
|
|
512
|
+
// re-prime and retry.
|
|
513
|
+
try {
|
|
514
|
+
await reloadState();
|
|
515
|
+
await applyMutation();
|
|
516
|
+
}
|
|
517
|
+
catch (retryError) {
|
|
518
|
+
if (isConcurrencyConflict(retryError)) {
|
|
519
|
+
warn(MODULE, `Action conflicted twice: ${body.action} ${body.url} ${errorMessage(retryError)}`);
|
|
520
|
+
sendConflict(res);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
warn(MODULE, `Action failed on conflict retry: ${body.action} ${body.url} ${errorMessage(retryError)}`);
|
|
524
|
+
sendError(res, 500, 'Action failed');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
461
527
|
}
|
|
462
528
|
// Rebuild dashboard data from cached digest + updated state. Persist
|
|
463
529
|
// the last-known partialFailures across action rebuilds (#1035) so the
|
|
@@ -475,12 +541,7 @@ export async function startDashboardServer(options) {
|
|
|
475
541
|
return;
|
|
476
542
|
}
|
|
477
543
|
try {
|
|
478
|
-
|
|
479
|
-
await stateManager.refreshFromGist();
|
|
480
|
-
}
|
|
481
|
-
else {
|
|
482
|
-
stateManager.reloadIfChanged();
|
|
483
|
-
}
|
|
544
|
+
await reloadState();
|
|
484
545
|
warn(MODULE, 'Refreshing dashboard data from GitHub...');
|
|
485
546
|
const result = await fetchDashboardData(currentToken);
|
|
486
547
|
cachedDigest = result.digest;
|
|
@@ -493,6 +554,14 @@ export async function startDashboardServer(options) {
|
|
|
493
554
|
sendJson(res, 200, cachedJsonData);
|
|
494
555
|
}
|
|
495
556
|
catch (error) {
|
|
557
|
+
// No server-side retry here (unlike handleAction): a refresh re-run is a
|
|
558
|
+
// full GitHub fetch — expensive and rate-limited. The 409 is retryable
|
|
559
|
+
// by the client, which re-POSTs /api/refresh on its own schedule (#1397).
|
|
560
|
+
if (isConcurrencyConflict(error)) {
|
|
561
|
+
warn(MODULE, `Dashboard refresh hit a concurrent state write: ${errorMessage(error)}`);
|
|
562
|
+
sendConflict(res);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
496
565
|
warn(MODULE, `Dashboard refresh failed: ${errorMessage(error)}`);
|
|
497
566
|
sendError(res, 500, 'Refresh failed');
|
|
498
567
|
}
|
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
export interface DismissOutput {
|
|
7
7
|
dismissed: boolean;
|
|
8
8
|
url: string;
|
|
9
|
+
/** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
|
|
10
|
+
gistSyncWarning?: string;
|
|
9
11
|
}
|
|
10
12
|
export interface UndismissOutput {
|
|
11
13
|
undismissed: boolean;
|
|
12
14
|
url: string;
|
|
15
|
+
/** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
|
|
16
|
+
gistSyncWarning?: string;
|
|
13
17
|
}
|
|
14
18
|
/**
|
|
15
19
|
* Dismiss an issue's reply notifications without posting a comment.
|
package/dist/commands/dismiss.js
CHANGED
|
@@ -20,8 +20,8 @@ export async function runDismiss(options) {
|
|
|
20
20
|
validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
|
|
21
21
|
const stateManager = getStateManager();
|
|
22
22
|
const added = stateManager.dismissIssue(options.url, new Date().toISOString());
|
|
23
|
-
await maybeCheckpoint(stateManager, MODULE);
|
|
24
|
-
return { dismissed: added, url: options.url };
|
|
23
|
+
const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
|
|
24
|
+
return { dismissed: added, url: options.url, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Restore a dismissed issue to notifications.
|
|
@@ -36,6 +36,6 @@ export async function runUndismiss(options) {
|
|
|
36
36
|
validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
|
|
37
37
|
const stateManager = getStateManager();
|
|
38
38
|
const removed = stateManager.undismissIssue(options.url);
|
|
39
|
-
await maybeCheckpoint(stateManager, MODULE);
|
|
40
|
-
return { undismissed: removed, url: options.url };
|
|
39
|
+
const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
|
|
40
|
+
return { undismissed: removed, url: options.url, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
|
|
41
41
|
}
|
|
@@ -10,15 +10,26 @@ export interface GuidelinesViewOutput {
|
|
|
10
10
|
/** Where the guidelines would be persisted if a write happened now. */
|
|
11
11
|
storageMode: 'gist' | 'local-unavailable';
|
|
12
12
|
}
|
|
13
|
+
export interface GuidelinesListOutput {
|
|
14
|
+
/** Repos with non-empty guidelines stored, sorted alphabetically. Always empty in local mode. */
|
|
15
|
+
repos: string[];
|
|
16
|
+
count: number;
|
|
17
|
+
/** Where guidelines are persisted. `local-unavailable` always yields an empty list. */
|
|
18
|
+
storageMode: 'gist' | 'local-unavailable';
|
|
19
|
+
}
|
|
13
20
|
export interface GuidelinesStoreOutput {
|
|
14
21
|
repo: string;
|
|
15
22
|
byteSize: number;
|
|
16
23
|
stored: boolean;
|
|
24
|
+
/** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
|
|
25
|
+
gistSyncWarning?: string;
|
|
17
26
|
}
|
|
18
27
|
export interface GuidelinesResetOutput {
|
|
19
28
|
repo: string;
|
|
20
29
|
/** True when an existing file was tombstoned, false when no file existed. */
|
|
21
30
|
deleted: boolean;
|
|
31
|
+
/** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
|
|
32
|
+
gistSyncWarning?: string;
|
|
22
33
|
}
|
|
23
34
|
export interface FetchCorpusOutput {
|
|
24
35
|
repo: string;
|
|
@@ -37,6 +48,8 @@ export interface FetchCorpusOutput {
|
|
|
37
48
|
prUrl: string;
|
|
38
49
|
error: string;
|
|
39
50
|
}>;
|
|
51
|
+
/** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
|
|
52
|
+
gistSyncWarning?: string;
|
|
40
53
|
}
|
|
41
54
|
interface RepoOption {
|
|
42
55
|
repo: string;
|
|
@@ -52,6 +65,12 @@ interface StoreOptions extends RepoOption {
|
|
|
52
65
|
}
|
|
53
66
|
/** Read the per-repo guidelines for `repo`. Returns a `local-unavailable` envelope in non-Gist mode. */
|
|
54
67
|
export declare function runGuidelinesView(options: RepoOption): Promise<GuidelinesViewOutput>;
|
|
68
|
+
/**
|
|
69
|
+
* List every repo with non-empty stored guidelines. Never throws in local
|
|
70
|
+
* mode — returns an empty list with `storageMode: 'local-unavailable'` so
|
|
71
|
+
* hosts can distinguish "nothing stored" from "storage not configured".
|
|
72
|
+
*/
|
|
73
|
+
export declare function runGuidelinesList(): Promise<GuidelinesListOutput>;
|
|
55
74
|
/**
|
|
56
75
|
* Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
|
|
57
76
|
* when content exceeds the byte budget — the CLI surface relies on the
|