@oss-autopilot/core 3.2.0 → 3.4.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/README.md +1 -1
- package/dist/cli-registry.js +39 -3
- package/dist/cli.bundle.cjs +103 -75
- package/dist/cli.js +17 -3
- package/dist/commands/check-integration.js +8 -8
- package/dist/commands/comments.js +3 -0
- package/dist/commands/config.js +14 -7
- package/dist/commands/daily-render.js +10 -5
- package/dist/commands/daily.d.ts +3 -9
- package/dist/commands/daily.js +12 -21
- package/dist/commands/dashboard-data.js +1 -1
- package/dist/commands/dashboard-lifecycle.js +1 -1
- package/dist/commands/dashboard-process.js +4 -4
- package/dist/commands/dashboard-server.js +26 -7
- package/dist/commands/dashboard.js +2 -2
- package/dist/commands/detect-formatters.js +3 -3
- package/dist/commands/doctor.js +5 -5
- package/dist/commands/guidelines.d.ts +10 -0
- package/dist/commands/guidelines.js +25 -6
- package/dist/commands/list-move-tier.js +5 -5
- package/dist/commands/local-repos.js +9 -9
- package/dist/commands/parse-list.js +10 -10
- package/dist/commands/scout-bridge.js +2 -2
- package/dist/commands/setup.js +24 -13
- package/dist/commands/skip-add.js +6 -3
- package/dist/commands/skip-file-parser.js +3 -3
- package/dist/commands/startup.js +11 -8
- package/dist/commands/state-cmd.js +1 -1
- package/dist/commands/status.js +7 -0
- package/dist/commands/validation.js +12 -3
- package/dist/commands/vet-list.js +12 -8
- package/dist/commands/vet.js +1 -2
- package/dist/core/__fixtures__/prompt-injection-payloads.d.ts +22 -0
- package/dist/core/__fixtures__/prompt-injection-payloads.js +109 -0
- package/dist/core/anti-llm-policy.js +5 -5
- package/dist/core/auth.js +12 -8
- package/dist/core/daily-logic.d.ts +13 -1
- package/dist/core/daily-logic.js +31 -4
- package/dist/core/dates.js +3 -3
- package/dist/core/errors.d.ts +29 -0
- package/dist/core/errors.js +63 -0
- package/dist/core/formatter-detection.js +9 -9
- package/dist/core/gist-state-store.d.ts +42 -3
- package/dist/core/gist-state-store.js +89 -19
- package/dist/core/guidelines-store.js +2 -2
- package/dist/core/http-cache.js +16 -7
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +6 -1
- package/dist/core/issue-conversation.js +3 -1
- package/dist/core/paths.js +4 -4
- package/dist/core/placeholder-usernames.d.ts +1 -0
- package/dist/core/placeholder-usernames.js +27 -0
- package/dist/core/pr-comments-fetcher.d.ts +14 -6
- package/dist/core/pr-comments-fetcher.js +8 -14
- package/dist/core/pr-monitor.d.ts +0 -2
- package/dist/core/pr-monitor.js +2 -25
- package/dist/core/pr-template.js +1 -1
- package/dist/core/state-persistence.d.ts +2 -2
- package/dist/core/state-persistence.js +15 -12
- package/dist/core/state-schema.js +8 -4
- package/dist/core/state.d.ts +27 -0
- package/dist/core/state.js +71 -14
- package/dist/core/untrusted-content.d.ts +48 -0
- package/dist/core/untrusted-content.js +106 -0
- package/dist/core/urls.js +2 -2
- package/dist/formatters/json.d.ts +53 -3
- package/dist/formatters/json.js +49 -14
- package/package.json +3 -3
package/dist/core/auth.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Extracted from utils.ts under #1116.
|
|
8
8
|
*/
|
|
9
|
-
import { execFileSync, execFile } from 'child_process';
|
|
9
|
+
import { execFileSync, execFile } from 'node:child_process';
|
|
10
10
|
import { ConfigurationError } from './errors.js';
|
|
11
|
-
import { debug } from './logger.js';
|
|
11
|
+
import { debug, warn } from './logger.js';
|
|
12
12
|
const MODULE = 'auth';
|
|
13
13
|
// Cached GitHub token (fetched once per session)
|
|
14
14
|
let cachedGitHubToken = null;
|
|
@@ -36,7 +36,7 @@ export function getGitHubToken() {
|
|
|
36
36
|
}
|
|
37
37
|
try {
|
|
38
38
|
const token = execFileSync('gh', ['auth', 'token'], {
|
|
39
|
-
encoding: '
|
|
39
|
+
encoding: 'utf8',
|
|
40
40
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
41
|
timeout: 2000,
|
|
42
42
|
}).trim();
|
|
@@ -47,7 +47,10 @@ export function getGitHubToken() {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
catch (err) {
|
|
50
|
-
|
|
50
|
+
// Promote to warn-once-per-session so a slow `gh` (2s timeout) or a
|
|
51
|
+
// misconfigured CLI is visible without DEBUG=1 (#1209 L6). The
|
|
52
|
+
// tokenFetchAttempted cache means subsequent calls don't re-warn.
|
|
53
|
+
warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
|
|
51
54
|
}
|
|
52
55
|
return null;
|
|
53
56
|
}
|
|
@@ -101,7 +104,7 @@ export async function getGitHubTokenAsync() {
|
|
|
101
104
|
}
|
|
102
105
|
try {
|
|
103
106
|
const token = await new Promise((resolve, reject) => {
|
|
104
|
-
execFile('gh', ['auth', 'token'], { encoding: '
|
|
107
|
+
execFile('gh', ['auth', 'token'], { encoding: 'utf8', timeout: 2000 }, (error, stdout) => {
|
|
105
108
|
if (error) {
|
|
106
109
|
reject(error);
|
|
107
110
|
}
|
|
@@ -117,7 +120,8 @@ export async function getGitHubTokenAsync() {
|
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
catch (err) {
|
|
120
|
-
|
|
123
|
+
// Same warn-once promotion as the sync version (#1209 L6).
|
|
124
|
+
warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
|
|
121
125
|
}
|
|
122
126
|
return null;
|
|
123
127
|
}
|
|
@@ -126,7 +130,7 @@ export async function getGitHubTokenAsync() {
|
|
|
126
130
|
* Usernames must start with an alphanumeric character, can contain hyphens
|
|
127
131
|
* (but not consecutive ones and not at the end), and be 1-39 characters.
|
|
128
132
|
*/
|
|
129
|
-
const GITHUB_USERNAME_RE = /^[
|
|
133
|
+
const GITHUB_USERNAME_RE = /^[\da-z](?:[\da-z]|-(?=[\da-z])){0,38}$/i;
|
|
130
134
|
/**
|
|
131
135
|
* Detect the authenticated GitHub username via the `gh` CLI.
|
|
132
136
|
*
|
|
@@ -137,7 +141,7 @@ const GITHUB_USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/
|
|
|
137
141
|
export async function detectGitHubUsername() {
|
|
138
142
|
try {
|
|
139
143
|
const login = await new Promise((resolve, reject) => {
|
|
140
|
-
execFile('gh', ['api', 'user', '--jq', '.login'], { encoding: '
|
|
144
|
+
execFile('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', timeout: 5000 }, (error, stdout) => {
|
|
141
145
|
if (error) {
|
|
142
146
|
reject(error);
|
|
143
147
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* re-exports them at the bottom so existing imports keep working without
|
|
15
15
|
* a sweep.
|
|
16
16
|
*/
|
|
17
|
-
import type { FetchedPR, FetchedPRStatus, StalenessTier, ActionReason, AgentState, ShelvedPRRef, ComputedRepoSignals, RepoGroup, CommentedIssue, CapacityAssessment, ActionableIssue, ActionMenu } from './types.js';
|
|
17
|
+
import type { FetchedPR, FetchedPRStatus, StalenessTier, ActionReason, AgentState, ShelvedPRRef, ComputedRepoSignals, RepoGroup, CommentedIssue, CapacityAssessment, ActionableIssue, ActionMenu, StarFilter } from './types.js';
|
|
18
18
|
/**
|
|
19
19
|
* Statuses indicating action needed from the contributor.
|
|
20
20
|
* Used for auto-unshelving shelved PRs.
|
|
@@ -49,6 +49,18 @@ export declare function applyStatusOverrides(prs: FetchedPR[], state: Readonly<A
|
|
|
49
49
|
* @returns Lightweight reference for display
|
|
50
50
|
*/
|
|
51
51
|
export declare function toShelvedPRRef(pr: ShelvedPRRef): ShelvedPRRef;
|
|
52
|
+
/**
|
|
53
|
+
* Build a star filter from state for use in fetchUserPRCounts.
|
|
54
|
+
*
|
|
55
|
+
* Returns undefined if no star data is available (first run). Pure logic
|
|
56
|
+
* over `Readonly<AgentState>` — lives here in core/daily-logic.ts so the
|
|
57
|
+
* dashboard layer can reuse it without importing from a sibling command
|
|
58
|
+
* module (#1208 M7).
|
|
59
|
+
*
|
|
60
|
+
* @param state - Current agent state (read-only)
|
|
61
|
+
* @returns Star filter with minimum threshold and known counts, or undefined on first run
|
|
62
|
+
*/
|
|
63
|
+
export declare function buildStarFilter(state: Readonly<AgentState>): StarFilter | undefined;
|
|
52
64
|
/**
|
|
53
65
|
* Group PRs by repository (#80).
|
|
54
66
|
* Ensures one agent per repo during parallel dispatch, preventing branch checkout conflicts.
|
package/dist/core/daily-logic.js
CHANGED
|
@@ -129,6 +129,29 @@ export function toShelvedPRRef(pr) {
|
|
|
129
129
|
status: pr.status,
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Build a star filter from state for use in fetchUserPRCounts.
|
|
134
|
+
*
|
|
135
|
+
* Returns undefined if no star data is available (first run). Pure logic
|
|
136
|
+
* over `Readonly<AgentState>` — lives here in core/daily-logic.ts so the
|
|
137
|
+
* dashboard layer can reuse it without importing from a sibling command
|
|
138
|
+
* module (#1208 M7).
|
|
139
|
+
*
|
|
140
|
+
* @param state - Current agent state (read-only)
|
|
141
|
+
* @returns Star filter with minimum threshold and known counts, or undefined on first run
|
|
142
|
+
*/
|
|
143
|
+
export function buildStarFilter(state) {
|
|
144
|
+
const minStars = state.config.minStars ?? 50;
|
|
145
|
+
const knownStarCounts = new Map();
|
|
146
|
+
for (const [repo, score] of Object.entries(state.repoScores)) {
|
|
147
|
+
if (score.stargazersCount !== undefined) {
|
|
148
|
+
knownStarCounts.set(repo, score.stargazersCount);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (knownStarCounts.size === 0)
|
|
152
|
+
return undefined;
|
|
153
|
+
return { minStars, knownStarCounts };
|
|
154
|
+
}
|
|
132
155
|
/**
|
|
133
156
|
* Group PRs by repository (#80).
|
|
134
157
|
* Ensures one agent per repo during parallel dispatch, preventing branch checkout conflicts.
|
|
@@ -232,37 +255,41 @@ export function collectActionableIssues(prs, lastDigestAt) {
|
|
|
232
255
|
let label;
|
|
233
256
|
let type;
|
|
234
257
|
switch (reason) {
|
|
235
|
-
case 'needs_response':
|
|
258
|
+
case 'needs_response': {
|
|
236
259
|
label = '[Needs Response]';
|
|
237
260
|
type = 'needs_response';
|
|
238
261
|
break;
|
|
239
|
-
|
|
262
|
+
}
|
|
263
|
+
case 'needs_changes': {
|
|
240
264
|
label = '[Needs Changes]';
|
|
241
265
|
type = 'needs_changes';
|
|
242
266
|
break;
|
|
267
|
+
}
|
|
243
268
|
case 'failing_ci': {
|
|
244
269
|
const checkInfo = pr.failingCheckNames.length > 0 ? ` (${pr.failingCheckNames.join(', ')})` : '';
|
|
245
270
|
label = `[CI Failing${checkInfo}]`;
|
|
246
271
|
type = 'ci_failing';
|
|
247
272
|
break;
|
|
248
273
|
}
|
|
249
|
-
case 'merge_conflict':
|
|
274
|
+
case 'merge_conflict': {
|
|
250
275
|
label = '[Merge Conflict]';
|
|
251
276
|
type = 'merge_conflict';
|
|
252
277
|
break;
|
|
278
|
+
}
|
|
253
279
|
case 'incomplete_checklist': {
|
|
254
280
|
const stats = pr.checklistStats ? ` (${pr.checklistStats.checked}/${pr.checklistStats.total})` : '';
|
|
255
281
|
label = `[Incomplete Checklist${stats}]`;
|
|
256
282
|
type = 'incomplete_checklist';
|
|
257
283
|
break;
|
|
258
284
|
}
|
|
259
|
-
default:
|
|
285
|
+
default: {
|
|
260
286
|
// Defensive fallback for ActionReason values not explicitly handled
|
|
261
287
|
// above (e.g. ci_not_running, needs_rebase, missing_required_files).
|
|
262
288
|
// These aren't in reasonOrder today but this guards future additions.
|
|
263
289
|
warn('daily-logic', `Unhandled ActionReason "${reason}" for PR ${pr.url} — falling back to needs_response`);
|
|
264
290
|
label = `[${reason}]`;
|
|
265
291
|
type = 'needs_response';
|
|
292
|
+
}
|
|
266
293
|
}
|
|
267
294
|
// A PR is "new" if it was created after the last daily digest (first time seen).
|
|
268
295
|
// If there's no previous digest (first run) or createdAt is invalid, assume new.
|
package/dist/core/dates.js
CHANGED
|
@@ -30,9 +30,9 @@ export function formatRelativeTime(dateStr) {
|
|
|
30
30
|
const diffMs = Date.now() - date.getTime();
|
|
31
31
|
if (diffMs < 0)
|
|
32
32
|
return 'just now';
|
|
33
|
-
const diffMins = Math.floor(diffMs /
|
|
34
|
-
const diffHours = Math.floor(diffMs /
|
|
35
|
-
const diffDays = Math.floor(diffMs /
|
|
33
|
+
const diffMins = Math.floor(diffMs / 60_000);
|
|
34
|
+
const diffHours = Math.floor(diffMs / 3_600_000);
|
|
35
|
+
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
36
36
|
if (diffMins < 60)
|
|
37
37
|
return `${diffMins}m ago`;
|
|
38
38
|
if (diffHours < 24)
|
package/dist/core/errors.d.ts
CHANGED
|
@@ -32,6 +32,20 @@ export declare class ValidationError extends OssAutopilotError {
|
|
|
32
32
|
export declare class GistPermissionError extends ConfigurationError {
|
|
33
33
|
constructor(message?: string);
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Thrown when state.json fetched from a Gist is malformed or fails Zod
|
|
37
|
+
* validation. The Gist content is preserved as a `.rejected-<ts>.json` file
|
|
38
|
+
* in the local cache directory so the user can inspect or recover from it
|
|
39
|
+
* before the next push overwrites the Gist with fresh state.
|
|
40
|
+
*
|
|
41
|
+
* See issue #1201.
|
|
42
|
+
*/
|
|
43
|
+
export declare class GistCorruptError extends ConfigurationError {
|
|
44
|
+
readonly gistId: string;
|
|
45
|
+
readonly rejectedPath: string | null;
|
|
46
|
+
readonly cause: unknown;
|
|
47
|
+
constructor(gistId: string, rejectedPath: string | null, cause: unknown);
|
|
48
|
+
}
|
|
35
49
|
/**
|
|
36
50
|
* Thrown when an optimistic compare-and-swap on state.json detects that
|
|
37
51
|
* another process wrote the file between load and save. See issue #1030.
|
|
@@ -73,6 +87,21 @@ export declare function getHttpStatusCode(error: unknown): number | undefined;
|
|
|
73
87
|
export declare function isRateLimitError(error: unknown): boolean;
|
|
74
88
|
/** Return true for errors that should propagate (not degrade gracefully): rate limits, auth failures, abuse detection. */
|
|
75
89
|
export declare function isRateLimitOrAuthError(err: unknown): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Check if an error is a transient network/server-side failure that's safe
|
|
92
|
+
* to retry or fall back from (vs. a permanent error that should surface).
|
|
93
|
+
*
|
|
94
|
+
* Returns true for:
|
|
95
|
+
* - Node socket errors: ECONNRESET, ETIMEDOUT, ENETUNREACH, ENOTFOUND, ECONNREFUSED
|
|
96
|
+
* - HTTP 5xx (server errors)
|
|
97
|
+
* - AbortError (timeout)
|
|
98
|
+
*
|
|
99
|
+
* Returns false for everything else (including 4xx, schema errors, config errors).
|
|
100
|
+
*
|
|
101
|
+
* Used by {@link getStateManagerAsync} to decide whether a Gist init failure
|
|
102
|
+
* is recoverable enough to silently fall back to local-only mode (#1202).
|
|
103
|
+
*/
|
|
104
|
+
export declare function isTransientNetworkError(err: unknown): boolean;
|
|
76
105
|
/**
|
|
77
106
|
* Build a `.catch()` handler for the "non-fatal parallel fetch" pattern used
|
|
78
107
|
* by daily.ts and dashboard-data.ts (#960). When a sibling fetch fails during
|
package/dist/core/errors.js
CHANGED
|
@@ -49,6 +49,31 @@ export class GistPermissionError extends ConfigurationError {
|
|
|
49
49
|
this.name = 'GistPermissionError';
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Thrown when state.json fetched from a Gist is malformed or fails Zod
|
|
54
|
+
* validation. The Gist content is preserved as a `.rejected-<ts>.json` file
|
|
55
|
+
* in the local cache directory so the user can inspect or recover from it
|
|
56
|
+
* before the next push overwrites the Gist with fresh state.
|
|
57
|
+
*
|
|
58
|
+
* See issue #1201.
|
|
59
|
+
*/
|
|
60
|
+
export class GistCorruptError extends ConfigurationError {
|
|
61
|
+
gistId;
|
|
62
|
+
rejectedPath;
|
|
63
|
+
cause;
|
|
64
|
+
constructor(gistId, rejectedPath, cause) {
|
|
65
|
+
const causeMsg = cause instanceof Error ? cause.message : String(cause);
|
|
66
|
+
const recoverHint = rejectedPath
|
|
67
|
+
? `\nCorrupt content preserved at: ${rejectedPath}\n` +
|
|
68
|
+
'Inspect or restore manually, then re-run. Falling back to a fresh state would overwrite your Gist.'
|
|
69
|
+
: '\nCould not preserve the rejected content; do not push without inspecting the Gist manually.';
|
|
70
|
+
super(`Gist ${gistId} state.json is corrupt or fails schema validation: ${causeMsg}${recoverHint}`);
|
|
71
|
+
this.gistId = gistId;
|
|
72
|
+
this.rejectedPath = rejectedPath;
|
|
73
|
+
this.cause = cause;
|
|
74
|
+
this.name = 'GistCorruptError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
52
77
|
/**
|
|
53
78
|
* Thrown when an optimistic compare-and-swap on state.json detects that
|
|
54
79
|
* another process wrote the file between load and save. See issue #1030.
|
|
@@ -128,6 +153,44 @@ export function isRateLimitOrAuthError(err) {
|
|
|
128
153
|
}
|
|
129
154
|
return false;
|
|
130
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Check if an error is a transient network/server-side failure that's safe
|
|
158
|
+
* to retry or fall back from (vs. a permanent error that should surface).
|
|
159
|
+
*
|
|
160
|
+
* Returns true for:
|
|
161
|
+
* - Node socket errors: ECONNRESET, ETIMEDOUT, ENETUNREACH, ENOTFOUND, ECONNREFUSED
|
|
162
|
+
* - HTTP 5xx (server errors)
|
|
163
|
+
* - AbortError (timeout)
|
|
164
|
+
*
|
|
165
|
+
* Returns false for everything else (including 4xx, schema errors, config errors).
|
|
166
|
+
*
|
|
167
|
+
* Used by {@link getStateManagerAsync} to decide whether a Gist init failure
|
|
168
|
+
* is recoverable enough to silently fall back to local-only mode (#1202).
|
|
169
|
+
*/
|
|
170
|
+
export function isTransientNetworkError(err) {
|
|
171
|
+
if (!err || typeof err !== 'object')
|
|
172
|
+
return false;
|
|
173
|
+
const status = getHttpStatusCode(err);
|
|
174
|
+
if (typeof status === 'number' && status >= 500 && status < 600)
|
|
175
|
+
return true;
|
|
176
|
+
// Node socket-level errors expose `code`
|
|
177
|
+
const code = err.code;
|
|
178
|
+
if (typeof code === 'string') {
|
|
179
|
+
if (code === 'ECONNRESET' ||
|
|
180
|
+
code === 'ETIMEDOUT' ||
|
|
181
|
+
code === 'ENETUNREACH' ||
|
|
182
|
+
code === 'ENOTFOUND' ||
|
|
183
|
+
code === 'ECONNREFUSED' ||
|
|
184
|
+
code === 'EAI_AGAIN') {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Octokit's RequestError surfaces the underlying name; fetch timeout is AbortError
|
|
189
|
+
const name = err.name;
|
|
190
|
+
if (typeof name === 'string' && (name === 'AbortError' || name === 'TimeoutError'))
|
|
191
|
+
return true;
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
131
194
|
/**
|
|
132
195
|
* Build a `.catch()` handler for the "non-fatal parallel fetch" pattern used
|
|
133
196
|
* by daily.ts and dashboard-data.ts (#960). When a sibling fetch fails during
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Programmatically detects formatters/linters configured in a local repo directory
|
|
5
5
|
* and diagnoses CI formatting failures from log output.
|
|
6
6
|
*/
|
|
7
|
-
import * as fs from 'fs';
|
|
8
|
-
import * as path from 'path';
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
9
|
import { debug } from './logger.js';
|
|
10
10
|
const MODULE = 'formatter-detection';
|
|
11
11
|
// ── Prettier config file patterns ──────────────────────────────────────────
|
|
@@ -42,7 +42,7 @@ const FORMAT_SCRIPT_NAMES = ['lint:fix', 'format', 'fmt', 'lint', 'format:check'
|
|
|
42
42
|
const CI_PATTERNS = [
|
|
43
43
|
{
|
|
44
44
|
formatter: 'prettier',
|
|
45
|
-
patterns: [/
|
|
45
|
+
patterns: [/code style issues found/i, /forgot to run prettier/i, /prettier --check/i],
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
formatter: 'ruff',
|
|
@@ -50,19 +50,19 @@ const CI_PATTERNS = [
|
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
formatter: 'black',
|
|
53
|
-
patterns: [/
|
|
53
|
+
patterns: [/oh no! .* files? would be reformatted/i, /black --check/i],
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
formatter: 'rustfmt',
|
|
57
|
-
patterns: [/
|
|
57
|
+
patterns: [/diff in .*\.rs/i, /rustfmt --check/i, /cargo fmt.*--check/i],
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
60
|
formatter: 'biome',
|
|
61
|
-
patterns: [/biome check/i, /biome ci/i, /
|
|
61
|
+
patterns: [/biome check/i, /biome ci/i, /found \d+ fixable diagnostics?/i],
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
formatter: 'eslint',
|
|
65
|
-
patterns: [/eslint.*--fix/i, /eslint
|
|
65
|
+
patterns: [/eslint.*--fix/i, /eslint\D+\d+ problems?/i],
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
formatter: 'gofmt',
|
|
@@ -82,7 +82,7 @@ const CI_PATTERNS = [
|
|
|
82
82
|
*/
|
|
83
83
|
function readJsonFile(filePath) {
|
|
84
84
|
try {
|
|
85
|
-
const content = fs.readFileSync(filePath, '
|
|
85
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
86
86
|
return JSON.parse(content);
|
|
87
87
|
}
|
|
88
88
|
catch (err) {
|
|
@@ -95,7 +95,7 @@ function readJsonFile(filePath) {
|
|
|
95
95
|
*/
|
|
96
96
|
function readTextFile(filePath) {
|
|
97
97
|
try {
|
|
98
|
-
return fs.readFileSync(filePath, '
|
|
98
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
99
99
|
}
|
|
100
100
|
catch (err) {
|
|
101
101
|
debug(MODULE, `Failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -44,6 +44,22 @@ export interface BootstrapResult {
|
|
|
44
44
|
/** True when state was loaded from local cache due to API failure. */
|
|
45
45
|
degraded?: boolean;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Discriminated outcome of {@link GistStateStore.refreshFromGist} (#1209 L9).
|
|
49
|
+
* Lets callers distinguish "got fresh data" from "throttled / no-op / failed"
|
|
50
|
+
* without conflating them as a single boolean.
|
|
51
|
+
*/
|
|
52
|
+
export type RefreshResult = {
|
|
53
|
+
status: 'refreshed';
|
|
54
|
+
} | {
|
|
55
|
+
status: 'no-gist';
|
|
56
|
+
} | {
|
|
57
|
+
status: 'throttled';
|
|
58
|
+
sinceLastMs: number;
|
|
59
|
+
} | {
|
|
60
|
+
status: 'error';
|
|
61
|
+
error: Error;
|
|
62
|
+
};
|
|
47
63
|
/**
|
|
48
64
|
* Minimal Octokit-shaped interface for the Gist API methods we use.
|
|
49
65
|
* Accepts the real ThrottledOctokit or a plain mock object in tests.
|
|
@@ -121,6 +137,14 @@ export declare class GistStateStore {
|
|
|
121
137
|
private readonly octokit;
|
|
122
138
|
private lastRefreshAt;
|
|
123
139
|
private static readonly REFRESH_THROTTLE_MS;
|
|
140
|
+
/**
|
|
141
|
+
* Most recent error from a `refreshFromGist()` attempt, or `null` when the
|
|
142
|
+
* last attempt succeeded or was skipped by the throttle. Lets callers
|
|
143
|
+
* (StateManager) distinguish "throttled, nothing new to see" from "fetch
|
|
144
|
+
* failed, you're now operating on stale state" without changing the
|
|
145
|
+
* existing boolean return contract (#1193).
|
|
146
|
+
*/
|
|
147
|
+
lastRefreshError: Error | null;
|
|
124
148
|
constructor(octokit: OctokitLike);
|
|
125
149
|
/**
|
|
126
150
|
* Bootstrap the Gist store: locate or create the backing Gist,
|
|
@@ -186,8 +210,15 @@ export declare class GistStateStore {
|
|
|
186
210
|
/**
|
|
187
211
|
* Re-fetch the Gist and update the in-memory cache.
|
|
188
212
|
* Throttled to at most once per 30 seconds.
|
|
213
|
+
*
|
|
214
|
+
* Returns a discriminated union so callers can tell apart the four
|
|
215
|
+
* outcomes that previously collapsed into a single boolean (#1209 L9):
|
|
216
|
+
* - `{ status: 'refreshed' }` — fresh data loaded successfully.
|
|
217
|
+
* - `{ status: 'no-gist' }` — store not in Gist mode (e.g. degraded).
|
|
218
|
+
* - `{ status: 'throttled', sinceLastMs }` — within the 30s throttle.
|
|
219
|
+
* - `{ status: 'error', error }` — fetch attempt failed.
|
|
189
220
|
*/
|
|
190
|
-
refreshFromGist(): Promise<
|
|
221
|
+
refreshFromGist(): Promise<RefreshResult>;
|
|
191
222
|
/**
|
|
192
223
|
* Preflight check: verify the token has Gist API scope.
|
|
193
224
|
* Costs one cheap API call; catches permission issues early with a clear message.
|
|
@@ -199,9 +230,17 @@ export declare class GistStateStore {
|
|
|
199
230
|
*/
|
|
200
231
|
private fetchAndCache;
|
|
201
232
|
/**
|
|
202
|
-
* Parse `state.json` from the in-memory cache. Handles
|
|
233
|
+
* Parse `state.json` from the in-memory cache. Handles v1→v4 migration
|
|
203
234
|
* by running through the Zod schema (which requires version: 4).
|
|
204
|
-
*
|
|
235
|
+
*
|
|
236
|
+
* Throws {@link GistCorruptError} on parse or schema-validation failure.
|
|
237
|
+
* The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
|
|
238
|
+
* so the caller can recover (#1201).
|
|
239
|
+
*
|
|
240
|
+
* Returning fresh state on failure (the previous behavior) is unsafe in
|
|
241
|
+
* Gist mode because the next `push()` would overwrite the Gist with the
|
|
242
|
+
* empty fallback, silently destroying repoScores, dismissedIssues,
|
|
243
|
+
* guidelines pointers, and digest history.
|
|
205
244
|
*/
|
|
206
245
|
private parseStateFromCache;
|
|
207
246
|
/**
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
* cells, which matches the "last-write-wins by intent" model the rest of
|
|
32
32
|
* oss-autopilot already assumes for state.json.
|
|
33
33
|
*/
|
|
34
|
-
import * as fs from 'fs';
|
|
34
|
+
import * as fs from 'node:fs';
|
|
35
35
|
import { AgentStateSchema } from './state-schema.js';
|
|
36
36
|
import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3, migrateV3ToV4, } from './state-persistence.js';
|
|
37
37
|
import { getGistIdPath, getStateCachePath } from './paths.js';
|
|
38
38
|
import { debug, warn } from './logger.js';
|
|
39
|
-
import { GistPermissionError, GistConcurrencyError, isRateLimitError } from './errors.js';
|
|
39
|
+
import { GistPermissionError, GistConcurrencyError, GistCorruptError, isRateLimitError } from './errors.js';
|
|
40
40
|
const MODULE = 'gist-store';
|
|
41
41
|
/**
|
|
42
42
|
* Extract the ETag header from an Octokit response, tolerating both lower-
|
|
@@ -48,6 +48,24 @@ function extractEtag(headers) {
|
|
|
48
48
|
return null;
|
|
49
49
|
return headers.etag ?? headers.ETag ?? null;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Preserve corrupt Gist content for user recovery. Returns the path written,
|
|
53
|
+
* or null if the preservation itself failed (logged at warn). Mirrors the
|
|
54
|
+
* pattern in state-persistence.ts:401-407 for the local-state path.
|
|
55
|
+
*
|
|
56
|
+
* See {@link GistCorruptError} and issue #1201.
|
|
57
|
+
*/
|
|
58
|
+
function preserveRejectedGistContent(raw, gistId) {
|
|
59
|
+
try {
|
|
60
|
+
const rejectedPath = `${getStateCachePath()}.rejected-${Date.now()}`;
|
|
61
|
+
fs.writeFileSync(rejectedPath, raw, { encoding: 'utf8', mode: 0o600 });
|
|
62
|
+
return rejectedPath;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
warn(MODULE, `Could not preserve rejected Gist content for ${gistId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
51
69
|
/** Well-known Gist description used for search-based discovery. */
|
|
52
70
|
export const GIST_DESCRIPTION = 'oss-autopilot-state';
|
|
53
71
|
/** Primary state file name inside the Gist. */
|
|
@@ -70,6 +88,14 @@ export class GistStateStore {
|
|
|
70
88
|
octokit;
|
|
71
89
|
lastRefreshAt = 0;
|
|
72
90
|
static REFRESH_THROTTLE_MS = 30_000;
|
|
91
|
+
/**
|
|
92
|
+
* Most recent error from a `refreshFromGist()` attempt, or `null` when the
|
|
93
|
+
* last attempt succeeded or was skipped by the throttle. Lets callers
|
|
94
|
+
* (StateManager) distinguish "throttled, nothing new to see" from "fetch
|
|
95
|
+
* failed, you're now operating on stale state" without changing the
|
|
96
|
+
* existing boolean return contract (#1193).
|
|
97
|
+
*/
|
|
98
|
+
lastRefreshError = null;
|
|
73
99
|
constructor(octokit) {
|
|
74
100
|
this.octokit = octokit;
|
|
75
101
|
}
|
|
@@ -116,10 +142,12 @@ export class GistStateStore {
|
|
|
116
142
|
// All API paths failed — enter degraded mode
|
|
117
143
|
warn(MODULE, 'All Gist API paths failed, entering degraded mode', err);
|
|
118
144
|
// Try reading from local cache file
|
|
145
|
+
const cachePath = getStateCachePath();
|
|
146
|
+
let cacheRaw = null;
|
|
119
147
|
try {
|
|
120
|
-
const cachePath = getStateCachePath();
|
|
121
148
|
if (fs.existsSync(cachePath)) {
|
|
122
|
-
|
|
149
|
+
cacheRaw = fs.readFileSync(cachePath, 'utf8');
|
|
150
|
+
let obj = JSON.parse(cacheRaw);
|
|
123
151
|
// Chain migrations
|
|
124
152
|
if (typeof obj === 'object' && obj !== null) {
|
|
125
153
|
const record = obj;
|
|
@@ -136,7 +164,16 @@ export class GistStateStore {
|
|
|
136
164
|
}
|
|
137
165
|
}
|
|
138
166
|
catch (cacheErr) {
|
|
139
|
-
|
|
167
|
+
// Promote to warn (#1201) and preserve corrupt cache so fresh-state
|
|
168
|
+
// fallback doesn't silently destroy recoverable data on next push.
|
|
169
|
+
if (cacheRaw) {
|
|
170
|
+
const rejectedPath = preserveRejectedGistContent(cacheRaw, 'local-cache');
|
|
171
|
+
warn(MODULE, `Local state cache failed to parse in degraded mode: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}. ` +
|
|
172
|
+
`Corrupt cache preserved at: ${rejectedPath ?? '(could not preserve)'}`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
warn(MODULE, `Failed to read local cache in degraded mode: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}`);
|
|
176
|
+
}
|
|
140
177
|
}
|
|
141
178
|
// No cache either — return fresh state in degraded mode
|
|
142
179
|
debug(MODULE, 'No local cache found, returning fresh state in degraded mode');
|
|
@@ -188,10 +225,12 @@ export class GistStateStore {
|
|
|
188
225
|
// All API paths failed — enter degraded mode
|
|
189
226
|
warn(MODULE, 'bootstrapWithMigration: all Gist API paths failed, entering degraded mode', err);
|
|
190
227
|
// Try reading from local cache file
|
|
228
|
+
const cachePath = getStateCachePath();
|
|
229
|
+
let cacheRaw = null;
|
|
191
230
|
try {
|
|
192
|
-
const cachePath = getStateCachePath();
|
|
193
231
|
if (fs.existsSync(cachePath)) {
|
|
194
|
-
|
|
232
|
+
cacheRaw = fs.readFileSync(cachePath, 'utf8');
|
|
233
|
+
let obj = JSON.parse(cacheRaw);
|
|
195
234
|
// Chain migrations
|
|
196
235
|
if (typeof obj === 'object' && obj !== null) {
|
|
197
236
|
const record = obj;
|
|
@@ -208,7 +247,14 @@ export class GistStateStore {
|
|
|
208
247
|
}
|
|
209
248
|
}
|
|
210
249
|
catch (cacheErr) {
|
|
211
|
-
|
|
250
|
+
if (cacheRaw) {
|
|
251
|
+
const rejectedPath = preserveRejectedGistContent(cacheRaw, 'local-cache');
|
|
252
|
+
warn(MODULE, `bootstrapWithMigration: local cache failed to parse: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}. ` +
|
|
253
|
+
`Corrupt cache preserved at: ${rejectedPath ?? '(could not preserve)'}`);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
warn(MODULE, `bootstrapWithMigration: failed to read local cache: ${cacheErr instanceof Error ? cacheErr.message : String(cacheErr)}`);
|
|
257
|
+
}
|
|
212
258
|
}
|
|
213
259
|
// No cache either — use the provided existingState in degraded mode
|
|
214
260
|
debug(MODULE, 'bootstrapWithMigration: no local cache found, returning existing state in degraded mode');
|
|
@@ -376,21 +422,35 @@ export class GistStateStore {
|
|
|
376
422
|
/**
|
|
377
423
|
* Re-fetch the Gist and update the in-memory cache.
|
|
378
424
|
* Throttled to at most once per 30 seconds.
|
|
425
|
+
*
|
|
426
|
+
* Returns a discriminated union so callers can tell apart the four
|
|
427
|
+
* outcomes that previously collapsed into a single boolean (#1209 L9):
|
|
428
|
+
* - `{ status: 'refreshed' }` — fresh data loaded successfully.
|
|
429
|
+
* - `{ status: 'no-gist' }` — store not in Gist mode (e.g. degraded).
|
|
430
|
+
* - `{ status: 'throttled', sinceLastMs }` — within the 30s throttle.
|
|
431
|
+
* - `{ status: 'error', error }` — fetch attempt failed.
|
|
379
432
|
*/
|
|
380
433
|
async refreshFromGist() {
|
|
381
434
|
if (!this.gistId)
|
|
382
|
-
return
|
|
435
|
+
return { status: 'no-gist' };
|
|
383
436
|
const now = Date.now();
|
|
384
|
-
|
|
385
|
-
|
|
437
|
+
const sinceLastMs = now - this.lastRefreshAt;
|
|
438
|
+
// Throttle hits are not failures — preserve any previous lastRefreshError
|
|
439
|
+
// for the caller to inspect.
|
|
440
|
+
if (sinceLastMs < GistStateStore.REFRESH_THROTTLE_MS) {
|
|
441
|
+
return { status: 'throttled', sinceLastMs };
|
|
442
|
+
}
|
|
386
443
|
try {
|
|
387
444
|
await this.fetchAndCache(this.gistId);
|
|
388
445
|
this.lastRefreshAt = now;
|
|
389
|
-
|
|
446
|
+
this.lastRefreshError = null;
|
|
447
|
+
return { status: 'refreshed' };
|
|
390
448
|
}
|
|
391
449
|
catch (err) {
|
|
392
|
-
|
|
393
|
-
|
|
450
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
451
|
+
warn(MODULE, `refreshFromGist failed: ${error.message}`);
|
|
452
|
+
this.lastRefreshError = error;
|
|
453
|
+
return { status: 'error', error };
|
|
394
454
|
}
|
|
395
455
|
}
|
|
396
456
|
// ── Private helpers ─────────────────────────────────────────────────
|
|
@@ -436,9 +496,17 @@ export class GistStateStore {
|
|
|
436
496
|
return state;
|
|
437
497
|
}
|
|
438
498
|
/**
|
|
439
|
-
* Parse `state.json` from the in-memory cache. Handles
|
|
499
|
+
* Parse `state.json` from the in-memory cache. Handles v1→v4 migration
|
|
440
500
|
* by running through the Zod schema (which requires version: 4).
|
|
441
|
-
*
|
|
501
|
+
*
|
|
502
|
+
* Throws {@link GistCorruptError} on parse or schema-validation failure.
|
|
503
|
+
* The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
|
|
504
|
+
* so the caller can recover (#1201).
|
|
505
|
+
*
|
|
506
|
+
* Returning fresh state on failure (the previous behavior) is unsafe in
|
|
507
|
+
* Gist mode because the next `push()` would overwrite the Gist with the
|
|
508
|
+
* empty fallback, silently destroying repoScores, dismissedIssues,
|
|
509
|
+
* guidelines pointers, and digest history.
|
|
442
510
|
*/
|
|
443
511
|
parseStateFromCache() {
|
|
444
512
|
const raw = this.cachedFiles.get(STATE_FILE_NAME);
|
|
@@ -461,8 +529,10 @@ export class GistStateStore {
|
|
|
461
529
|
return AgentStateSchema.parse(obj);
|
|
462
530
|
}
|
|
463
531
|
catch (err) {
|
|
464
|
-
|
|
465
|
-
|
|
532
|
+
const rejectedPath = preserveRejectedGistContent(raw, this.gistId ?? 'unknown');
|
|
533
|
+
warn(MODULE, `Gist state.json failed to parse — refusing to overwrite with fresh state. ` +
|
|
534
|
+
`Corrupt content preserved at: ${rejectedPath ?? '(could not preserve)'}`);
|
|
535
|
+
throw new GistCorruptError(this.gistId ?? 'unknown', rejectedPath, err);
|
|
466
536
|
}
|
|
467
537
|
}
|
|
468
538
|
/**
|
|
@@ -541,7 +611,7 @@ export class GistStateStore {
|
|
|
541
611
|
try {
|
|
542
612
|
const gistIdPath = getGistIdPath();
|
|
543
613
|
if (fs.existsSync(gistIdPath)) {
|
|
544
|
-
const id = fs.readFileSync(gistIdPath, '
|
|
614
|
+
const id = fs.readFileSync(gistIdPath, 'utf8').trim();
|
|
545
615
|
return id || null;
|
|
546
616
|
}
|
|
547
617
|
}
|
|
@@ -2,7 +2,7 @@ import { OssAutopilotError } from './errors.js';
|
|
|
2
2
|
/** Filename prefix shared by every guidelines file in the Gist. */
|
|
3
3
|
export const GUIDELINES_FILE_PREFIX = 'guidelines--';
|
|
4
4
|
/** Hard byte budget for a single guidelines file (#867 design log §1). */
|
|
5
|
-
export const GUIDELINES_MAX_BYTES =
|
|
5
|
+
export const GUIDELINES_MAX_BYTES = 8192;
|
|
6
6
|
/** Suffix appended to the filename so it renders as markdown in Gist. */
|
|
7
7
|
const GUIDELINES_FILE_SUFFIX = '.md';
|
|
8
8
|
/**
|
|
@@ -83,7 +83,7 @@ export function getGuidelines(store, repo) {
|
|
|
83
83
|
export function setGuidelines(store, repo, content) {
|
|
84
84
|
if (!store)
|
|
85
85
|
throw new GuidelinesNotAvailableError();
|
|
86
|
-
const byteSize = Buffer.byteLength(content, '
|
|
86
|
+
const byteSize = Buffer.byteLength(content, 'utf8');
|
|
87
87
|
if (byteSize > GUIDELINES_MAX_BYTES) {
|
|
88
88
|
throw new GuidelinesTooLargeError(byteSize);
|
|
89
89
|
}
|