@oss-autopilot/core 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-registry.js +113 -3
- package/dist/cli.bundle.cjs +96 -92
- 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.js +6 -1
- package/dist/commands/dashboard-lifecycle.js +1 -1
- package/dist/commands/dashboard-process.js +4 -4
- package/dist/commands/dashboard-server.js +7 -6
- 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 +67 -0
- package/dist/commands/guidelines.js +159 -0
- package/dist/commands/index.d.ts +9 -0
- package/dist/commands/index.js +9 -0
- 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 +3 -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 +5 -5
- package/dist/core/daily-logic.js +8 -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 +19 -3
- package/dist/core/gist-state-store.js +81 -15
- package/dist/core/guidelines-store.d.ts +74 -0
- package/dist/core/guidelines-store.js +130 -0
- package/dist/core/http-cache.js +6 -6
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/issue-conversation.js +3 -1
- package/dist/core/paths.js +4 -4
- package/dist/core/pr-comments-fetcher.d.ts +67 -0
- package/dist/core/pr-comments-fetcher.js +125 -0
- package/dist/core/pr-monitor.js +1 -2
- package/dist/core/pr-template.js +1 -1
- package/dist/core/state-persistence.d.ts +6 -0
- package/dist/core/state-persistence.js +27 -9
- package/dist/core/state-schema.d.ts +5 -1
- package/dist/core/state-schema.js +7 -1
- package/dist/core/state.d.ts +60 -0
- package/dist/core/state.js +136 -13
- package/dist/core/types.d.ts +1 -1
- package/dist/core/types.js +2 -2
- 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 +1 -1
package/dist/core/auth.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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
11
|
import { debug } from './logger.js';
|
|
12
12
|
const MODULE = 'auth';
|
|
@@ -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();
|
|
@@ -101,7 +101,7 @@ export async function getGitHubTokenAsync() {
|
|
|
101
101
|
}
|
|
102
102
|
try {
|
|
103
103
|
const token = await new Promise((resolve, reject) => {
|
|
104
|
-
execFile('gh', ['auth', 'token'], { encoding: '
|
|
104
|
+
execFile('gh', ['auth', 'token'], { encoding: 'utf8', timeout: 2000 }, (error, stdout) => {
|
|
105
105
|
if (error) {
|
|
106
106
|
reject(error);
|
|
107
107
|
}
|
|
@@ -126,7 +126,7 @@ export async function getGitHubTokenAsync() {
|
|
|
126
126
|
* Usernames must start with an alphanumeric character, can contain hyphens
|
|
127
127
|
* (but not consecutive ones and not at the end), and be 1-39 characters.
|
|
128
128
|
*/
|
|
129
|
-
const GITHUB_USERNAME_RE = /^[
|
|
129
|
+
const GITHUB_USERNAME_RE = /^[\da-z](?:[\da-z]|-(?=[\da-z])){0,38}$/i;
|
|
130
130
|
/**
|
|
131
131
|
* Detect the authenticated GitHub username via the `gh` CLI.
|
|
132
132
|
*
|
|
@@ -137,7 +137,7 @@ const GITHUB_USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/
|
|
|
137
137
|
export async function detectGitHubUsername() {
|
|
138
138
|
try {
|
|
139
139
|
const login = await new Promise((resolve, reject) => {
|
|
140
|
-
execFile('gh', ['api', 'user', '--jq', '.login'], { encoding: '
|
|
140
|
+
execFile('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', timeout: 5000 }, (error, stdout) => {
|
|
141
141
|
if (error) {
|
|
142
142
|
reject(error);
|
|
143
143
|
}
|
package/dist/core/daily-logic.js
CHANGED
|
@@ -232,37 +232,41 @@ export function collectActionableIssues(prs, lastDigestAt) {
|
|
|
232
232
|
let label;
|
|
233
233
|
let type;
|
|
234
234
|
switch (reason) {
|
|
235
|
-
case 'needs_response':
|
|
235
|
+
case 'needs_response': {
|
|
236
236
|
label = '[Needs Response]';
|
|
237
237
|
type = 'needs_response';
|
|
238
238
|
break;
|
|
239
|
-
|
|
239
|
+
}
|
|
240
|
+
case 'needs_changes': {
|
|
240
241
|
label = '[Needs Changes]';
|
|
241
242
|
type = 'needs_changes';
|
|
242
243
|
break;
|
|
244
|
+
}
|
|
243
245
|
case 'failing_ci': {
|
|
244
246
|
const checkInfo = pr.failingCheckNames.length > 0 ? ` (${pr.failingCheckNames.join(', ')})` : '';
|
|
245
247
|
label = `[CI Failing${checkInfo}]`;
|
|
246
248
|
type = 'ci_failing';
|
|
247
249
|
break;
|
|
248
250
|
}
|
|
249
|
-
case 'merge_conflict':
|
|
251
|
+
case 'merge_conflict': {
|
|
250
252
|
label = '[Merge Conflict]';
|
|
251
253
|
type = 'merge_conflict';
|
|
252
254
|
break;
|
|
255
|
+
}
|
|
253
256
|
case 'incomplete_checklist': {
|
|
254
257
|
const stats = pr.checklistStats ? ` (${pr.checklistStats.checked}/${pr.checklistStats.total})` : '';
|
|
255
258
|
label = `[Incomplete Checklist${stats}]`;
|
|
256
259
|
type = 'incomplete_checklist';
|
|
257
260
|
break;
|
|
258
261
|
}
|
|
259
|
-
default:
|
|
262
|
+
default: {
|
|
260
263
|
// Defensive fallback for ActionReason values not explicitly handled
|
|
261
264
|
// above (e.g. ci_not_running, needs_rebase, missing_required_files).
|
|
262
265
|
// These aren't in reasonOrder today but this guards future additions.
|
|
263
266
|
warn('daily-logic', `Unhandled ActionReason "${reason}" for PR ${pr.url} — falling back to needs_response`);
|
|
264
267
|
label = `[${reason}]`;
|
|
265
268
|
type = 'needs_response';
|
|
269
|
+
}
|
|
266
270
|
}
|
|
267
271
|
// A PR is "new" if it was created after the last daily digest (first time seen).
|
|
268
272
|
// 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)}`);
|
|
@@ -121,6 +121,14 @@ export declare class GistStateStore {
|
|
|
121
121
|
private readonly octokit;
|
|
122
122
|
private lastRefreshAt;
|
|
123
123
|
private static readonly REFRESH_THROTTLE_MS;
|
|
124
|
+
/**
|
|
125
|
+
* Most recent error from a `refreshFromGist()` attempt, or `null` when the
|
|
126
|
+
* last attempt succeeded or was skipped by the throttle. Lets callers
|
|
127
|
+
* (StateManager) distinguish "throttled, nothing new to see" from "fetch
|
|
128
|
+
* failed, you're now operating on stale state" without changing the
|
|
129
|
+
* existing boolean return contract (#1193).
|
|
130
|
+
*/
|
|
131
|
+
lastRefreshError: Error | null;
|
|
124
132
|
constructor(octokit: OctokitLike);
|
|
125
133
|
/**
|
|
126
134
|
* Bootstrap the Gist store: locate or create the backing Gist,
|
|
@@ -199,9 +207,17 @@ export declare class GistStateStore {
|
|
|
199
207
|
*/
|
|
200
208
|
private fetchAndCache;
|
|
201
209
|
/**
|
|
202
|
-
* Parse `state.json` from the in-memory cache. Handles
|
|
203
|
-
* by running through the Zod schema (which requires version:
|
|
204
|
-
*
|
|
210
|
+
* Parse `state.json` from the in-memory cache. Handles v1→v4 migration
|
|
211
|
+
* by running through the Zod schema (which requires version: 4).
|
|
212
|
+
*
|
|
213
|
+
* Throws {@link GistCorruptError} on parse or schema-validation failure.
|
|
214
|
+
* The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
|
|
215
|
+
* so the caller can recover (#1201).
|
|
216
|
+
*
|
|
217
|
+
* Returning fresh state on failure (the previous behavior) is unsafe in
|
|
218
|
+
* Gist mode because the next `push()` would overwrite the Gist with the
|
|
219
|
+
* empty fallback, silently destroying repoScores, dismissedIssues,
|
|
220
|
+
* guidelines pointers, and digest history.
|
|
205
221
|
*/
|
|
206
222
|
private parseStateFromCache;
|
|
207
223
|
/**
|
|
@@ -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
|
-
import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3 } from './state-persistence.js';
|
|
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;
|
|
@@ -127,6 +155,8 @@ export class GistStateStore {
|
|
|
127
155
|
obj = migrateV1ToV2(record);
|
|
128
156
|
if (obj.version === 2)
|
|
129
157
|
obj = migrateV2ToV3(obj);
|
|
158
|
+
if (obj.version === 3)
|
|
159
|
+
obj = migrateV3ToV4(obj);
|
|
130
160
|
}
|
|
131
161
|
const cachedState = AgentStateSchema.parse(obj);
|
|
132
162
|
debug(MODULE, 'Loaded state from local cache in degraded mode');
|
|
@@ -134,7 +164,16 @@ export class GistStateStore {
|
|
|
134
164
|
}
|
|
135
165
|
}
|
|
136
166
|
catch (cacheErr) {
|
|
137
|
-
|
|
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
|
+
}
|
|
138
177
|
}
|
|
139
178
|
// No cache either — return fresh state in degraded mode
|
|
140
179
|
debug(MODULE, 'No local cache found, returning fresh state in degraded mode');
|
|
@@ -186,10 +225,12 @@ export class GistStateStore {
|
|
|
186
225
|
// All API paths failed — enter degraded mode
|
|
187
226
|
warn(MODULE, 'bootstrapWithMigration: all Gist API paths failed, entering degraded mode', err);
|
|
188
227
|
// Try reading from local cache file
|
|
228
|
+
const cachePath = getStateCachePath();
|
|
229
|
+
let cacheRaw = null;
|
|
189
230
|
try {
|
|
190
|
-
const cachePath = getStateCachePath();
|
|
191
231
|
if (fs.existsSync(cachePath)) {
|
|
192
|
-
|
|
232
|
+
cacheRaw = fs.readFileSync(cachePath, 'utf8');
|
|
233
|
+
let obj = JSON.parse(cacheRaw);
|
|
193
234
|
// Chain migrations
|
|
194
235
|
if (typeof obj === 'object' && obj !== null) {
|
|
195
236
|
const record = obj;
|
|
@@ -197,6 +238,8 @@ export class GistStateStore {
|
|
|
197
238
|
obj = migrateV1ToV2(record);
|
|
198
239
|
if (obj.version === 2)
|
|
199
240
|
obj = migrateV2ToV3(obj);
|
|
241
|
+
if (obj.version === 3)
|
|
242
|
+
obj = migrateV3ToV4(obj);
|
|
200
243
|
}
|
|
201
244
|
const cachedState = AgentStateSchema.parse(obj);
|
|
202
245
|
debug(MODULE, 'bootstrapWithMigration: loaded state from local cache in degraded mode');
|
|
@@ -204,7 +247,14 @@ export class GistStateStore {
|
|
|
204
247
|
}
|
|
205
248
|
}
|
|
206
249
|
catch (cacheErr) {
|
|
207
|
-
|
|
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
|
+
}
|
|
208
258
|
}
|
|
209
259
|
// No cache either — use the provided existingState in degraded mode
|
|
210
260
|
debug(MODULE, 'bootstrapWithMigration: no local cache found, returning existing state in degraded mode');
|
|
@@ -377,15 +427,19 @@ export class GistStateStore {
|
|
|
377
427
|
if (!this.gistId)
|
|
378
428
|
return false;
|
|
379
429
|
const now = Date.now();
|
|
430
|
+
// Throttle hits are not failures — preserve any previous lastRefreshError
|
|
431
|
+
// for the caller to inspect.
|
|
380
432
|
if (now - this.lastRefreshAt < GistStateStore.REFRESH_THROTTLE_MS)
|
|
381
433
|
return false;
|
|
382
434
|
try {
|
|
383
435
|
await this.fetchAndCache(this.gistId);
|
|
384
436
|
this.lastRefreshAt = now;
|
|
437
|
+
this.lastRefreshError = null;
|
|
385
438
|
return true;
|
|
386
439
|
}
|
|
387
440
|
catch (err) {
|
|
388
441
|
warn(MODULE, `refreshFromGist failed: ${err}`);
|
|
442
|
+
this.lastRefreshError = err instanceof Error ? err : new Error(String(err));
|
|
389
443
|
return false;
|
|
390
444
|
}
|
|
391
445
|
}
|
|
@@ -432,9 +486,17 @@ export class GistStateStore {
|
|
|
432
486
|
return state;
|
|
433
487
|
}
|
|
434
488
|
/**
|
|
435
|
-
* Parse `state.json` from the in-memory cache. Handles
|
|
436
|
-
* by running through the Zod schema (which requires version:
|
|
437
|
-
*
|
|
489
|
+
* Parse `state.json` from the in-memory cache. Handles v1→v4 migration
|
|
490
|
+
* by running through the Zod schema (which requires version: 4).
|
|
491
|
+
*
|
|
492
|
+
* Throws {@link GistCorruptError} on parse or schema-validation failure.
|
|
493
|
+
* The corrupt raw content is preserved as `<state-cache-path>.rejected-<ts>`
|
|
494
|
+
* so the caller can recover (#1201).
|
|
495
|
+
*
|
|
496
|
+
* Returning fresh state on failure (the previous behavior) is unsafe in
|
|
497
|
+
* Gist mode because the next `push()` would overwrite the Gist with the
|
|
498
|
+
* empty fallback, silently destroying repoScores, dismissedIssues,
|
|
499
|
+
* guidelines pointers, and digest history.
|
|
438
500
|
*/
|
|
439
501
|
parseStateFromCache() {
|
|
440
502
|
const raw = this.cachedFiles.get(STATE_FILE_NAME);
|
|
@@ -451,12 +513,16 @@ export class GistStateStore {
|
|
|
451
513
|
obj = migrateV1ToV2(record);
|
|
452
514
|
if (obj.version === 2)
|
|
453
515
|
obj = migrateV2ToV3(obj);
|
|
516
|
+
if (obj.version === 3)
|
|
517
|
+
obj = migrateV3ToV4(obj);
|
|
454
518
|
}
|
|
455
519
|
return AgentStateSchema.parse(obj);
|
|
456
520
|
}
|
|
457
521
|
catch (err) {
|
|
458
|
-
|
|
459
|
-
|
|
522
|
+
const rejectedPath = preserveRejectedGistContent(raw, this.gistId ?? 'unknown');
|
|
523
|
+
warn(MODULE, `Gist state.json failed to parse — refusing to overwrite with fresh state. ` +
|
|
524
|
+
`Corrupt content preserved at: ${rejectedPath ?? '(could not preserve)'}`);
|
|
525
|
+
throw new GistCorruptError(this.gistId ?? 'unknown', rejectedPath, err);
|
|
460
526
|
}
|
|
461
527
|
}
|
|
462
528
|
/**
|
|
@@ -535,7 +601,7 @@ export class GistStateStore {
|
|
|
535
601
|
try {
|
|
536
602
|
const gistIdPath = getGistIdPath();
|
|
537
603
|
if (fs.existsSync(gistIdPath)) {
|
|
538
|
-
const id = fs.readFileSync(gistIdPath, '
|
|
604
|
+
const id = fs.readFileSync(gistIdPath, 'utf8').trim();
|
|
539
605
|
return id || null;
|
|
540
606
|
}
|
|
541
607
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-repo guidelines persistence on top of the Gist freeform-document API
|
|
3
|
+
* (#867 PR 2).
|
|
4
|
+
*
|
|
5
|
+
* Each repo gets one markdown file named `guidelines--{owner}--{repo}.md`
|
|
6
|
+
* stored in the user's oss-autopilot Gist alongside `state.json`. The file
|
|
7
|
+
* holds extracted guidance from past PR review feedback. Reads and writes go
|
|
8
|
+
* through the in-memory cache that `GistStateStore` already maintains; the
|
|
9
|
+
* file persists on the next `push()`.
|
|
10
|
+
*
|
|
11
|
+
* The byte cap (8 KB) is enforced at write time to keep claim-time context
|
|
12
|
+
* injection small. Larger guidance should be split across categories or
|
|
13
|
+
* deferred to a follow-up consolidation pass.
|
|
14
|
+
*/
|
|
15
|
+
import type { GistStateStore } from './gist-state-store.js';
|
|
16
|
+
import { OssAutopilotError } from './errors.js';
|
|
17
|
+
/** Filename prefix shared by every guidelines file in the Gist. */
|
|
18
|
+
export declare const GUIDELINES_FILE_PREFIX = "guidelines--";
|
|
19
|
+
/** Hard byte budget for a single guidelines file (#867 design log §1). */
|
|
20
|
+
export declare const GUIDELINES_MAX_BYTES = 8192;
|
|
21
|
+
/**
|
|
22
|
+
* Convert an `owner/repo` pair into the filename used inside the Gist.
|
|
23
|
+
* Slashes are escaped as `--` so the filename is filesystem-safe and
|
|
24
|
+
* unambiguous when parsing back to a repo string.
|
|
25
|
+
*/
|
|
26
|
+
export declare function guidelinesFilename(repo: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Inverse of {@link guidelinesFilename}. Returns null when the filename
|
|
29
|
+
* doesn't match the guidelines convention.
|
|
30
|
+
*/
|
|
31
|
+
export declare function repoFromGuidelinesFilename(filename: string): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Thrown by {@link setGuidelines} / {@link deleteGuidelines} when the
|
|
34
|
+
* StateManager is not in Gist mode. Catch + degrade gracefully when surfacing
|
|
35
|
+
* to user-facing flows: per-repo guidelines simply aren't available without a
|
|
36
|
+
* Gist to store them in.
|
|
37
|
+
*/
|
|
38
|
+
export declare class GuidelinesNotAvailableError extends OssAutopilotError {
|
|
39
|
+
constructor(message?: string);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Thrown by {@link setGuidelines} when content exceeds {@link GUIDELINES_MAX_BYTES}.
|
|
43
|
+
* Surfaced separately from generic validation errors so consumers can prompt the
|
|
44
|
+
* user with a "trim or split" UX rather than a generic shape rejection.
|
|
45
|
+
*/
|
|
46
|
+
export declare class GuidelinesTooLargeError extends OssAutopilotError {
|
|
47
|
+
constructor(byteSize: number, max?: number);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Read the guidelines file for a repo from the Gist cache. Returns null when
|
|
51
|
+
* the store is not in Gist mode, the file does not exist, or the file is
|
|
52
|
+
* present but empty (treated as a tombstone left by {@link deleteGuidelines}).
|
|
53
|
+
*/
|
|
54
|
+
export declare function getGuidelines(store: GistStateStore | null, repo: string): string | null;
|
|
55
|
+
/**
|
|
56
|
+
* Write or replace the guidelines file for a repo. Throws if the store is not
|
|
57
|
+
* in Gist mode or the content exceeds the byte budget.
|
|
58
|
+
*/
|
|
59
|
+
export declare function setGuidelines(store: GistStateStore | null, repo: string, content: string): void;
|
|
60
|
+
/**
|
|
61
|
+
* Delete the guidelines file for a repo. No-op if the file doesn't exist.
|
|
62
|
+
* Implementation: write an empty string. The Gist API treats files with
|
|
63
|
+
* empty content as deletions on the next push, matching the existing
|
|
64
|
+
* single-source-of-truth model.
|
|
65
|
+
*/
|
|
66
|
+
export declare function deleteGuidelines(store: GistStateStore | null, repo: string): void;
|
|
67
|
+
/**
|
|
68
|
+
* List every repo (as `owner/repo`) that has a non-empty guidelines file in
|
|
69
|
+
* the cache. Tombstoned (empty-content) files are excluded so the result
|
|
70
|
+
* matches what {@link getGuidelines} would actually return.
|
|
71
|
+
*
|
|
72
|
+
* Returns an empty array when the store is null or no files exist.
|
|
73
|
+
*/
|
|
74
|
+
export declare function listGuidelinesRepos(store: GistStateStore | null): string[];
|