@oss-autopilot/core 1.16.2 → 1.17.1
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 +53 -11
- package/dist/cli.bundle.cjs +82 -69
- package/dist/cli.js +22 -10
- package/dist/commands/comments.js +38 -20
- package/dist/commands/config.d.ts +9 -2
- package/dist/commands/config.js +12 -3
- package/dist/commands/daily.d.ts +3 -1
- package/dist/commands/daily.js +126 -37
- package/dist/commands/dashboard-data.d.ts +26 -2
- package/dist/commands/dashboard-data.js +45 -19
- package/dist/commands/dashboard-server.d.ts +1 -1
- package/dist/commands/dashboard-server.js +104 -19
- package/dist/commands/dismiss.js +4 -1
- package/dist/commands/doctor.d.ts +49 -0
- package/dist/commands/doctor.js +358 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/move.d.ts +1 -2
- package/dist/commands/move.js +8 -4
- package/dist/commands/read.js +2 -1
- package/dist/commands/search.d.ts +0 -18
- package/dist/commands/search.js +38 -1
- package/dist/commands/setup.js +42 -2
- package/dist/commands/shelve.js +4 -1
- package/dist/commands/skip-add.js +1 -1
- package/dist/commands/startup.js +14 -4
- package/dist/commands/track.js +2 -1
- package/dist/commands/vet-list.d.ts +23 -2
- package/dist/commands/vet-list.js +57 -10
- package/dist/core/anti-llm-policy.d.ts +5 -0
- package/dist/core/anti-llm-policy.js +5 -0
- package/dist/core/ci-analysis.js +6 -1
- package/dist/core/config-registry.d.ts +44 -0
- package/dist/core/config-registry.js +286 -0
- package/dist/core/dashboard-data-schema.d.ts +78 -0
- package/dist/core/dashboard-data-schema.js +80 -0
- package/dist/core/errors.d.ts +14 -0
- package/dist/core/errors.js +22 -0
- package/dist/core/http-cache.d.ts +8 -1
- package/dist/core/http-cache.js +59 -1
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +3 -1
- package/dist/core/maintainer-analysis.js +9 -3
- package/dist/core/pr-monitor.d.ts +7 -0
- package/dist/core/pr-monitor.js +45 -4
- package/dist/core/repo-score-manager.d.ts +17 -3
- package/dist/core/repo-score-manager.js +48 -19
- package/dist/core/state-persistence.d.ts +14 -1
- package/dist/core/state-persistence.js +24 -2
- package/dist/core/state-schema.d.ts +2 -0
- package/dist/core/state-schema.js +5 -0
- package/dist/core/state.d.ts +26 -2
- package/dist/core/state.js +50 -5
- package/dist/core/status-determination.d.ts +16 -0
- package/dist/core/status-determination.js +44 -11
- package/dist/formatters/json.d.ts +40 -2
- package/dist/formatters/json.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime schema for the dashboard server's `GET /api/data` response (#1050).
|
|
3
|
+
*
|
|
4
|
+
* The server (`commands/dashboard-data.ts`) and client (`packages/dashboard/`)
|
|
5
|
+
* run in different processes. TypeScript can't cross the process boundary —
|
|
6
|
+
* if the server removes or renames a field, the client hits runtime
|
|
7
|
+
* `undefined` with no diagnostic. This schema is the shared runtime contract:
|
|
8
|
+
* the server can optionally self-check outgoing payloads, and the dashboard
|
|
9
|
+
* validates every `/api/data` response before committing to state.
|
|
10
|
+
*
|
|
11
|
+
* Intentional scope: this schema validates **top-level presence and primitive
|
|
12
|
+
* shape** (required vs optional fields, container types like array/object),
|
|
13
|
+
* but uses `z.array(z.unknown())` for nested PR/issue arrays rather than
|
|
14
|
+
* pinning every field. The top-level surface is the only place drift tends
|
|
15
|
+
* to go silently undetected — nested fields already blow up loudly at the
|
|
16
|
+
* render site when they change. Exhaustive nested validation would turn the
|
|
17
|
+
* schema into a maintenance burden.
|
|
18
|
+
*/
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
export const DashboardStatsSchema = z.object({
|
|
21
|
+
activePRs: z.number(),
|
|
22
|
+
shelvedPRs: z.number(),
|
|
23
|
+
mergedPRs: z.number(),
|
|
24
|
+
closedPRs: z.number(),
|
|
25
|
+
mergeRate: z.string(),
|
|
26
|
+
availableIssues: z.number().optional(),
|
|
27
|
+
});
|
|
28
|
+
const RepoStatsEntrySchema = z.object({
|
|
29
|
+
active: z.number(),
|
|
30
|
+
merged: z.number(),
|
|
31
|
+
closed: z.number(),
|
|
32
|
+
});
|
|
33
|
+
const TopRepoSchema = z.object({
|
|
34
|
+
repo: z.string(),
|
|
35
|
+
active: z.number(),
|
|
36
|
+
merged: z.number(),
|
|
37
|
+
closed: z.number(),
|
|
38
|
+
});
|
|
39
|
+
export const DashboardDataSchema = z.object({
|
|
40
|
+
stats: DashboardStatsSchema,
|
|
41
|
+
prsByRepo: z.record(z.string(), RepoStatsEntrySchema),
|
|
42
|
+
topRepos: z.array(TopRepoSchema),
|
|
43
|
+
monthlyMerged: z.record(z.string(), z.number()),
|
|
44
|
+
monthlyOpened: z.record(z.string(), z.number()),
|
|
45
|
+
monthlyClosed: z.record(z.string(), z.number()),
|
|
46
|
+
// PR / issue arrays are validated loosely — the domain shapes are pinned by
|
|
47
|
+
// AgentStateSchema (state-schema.ts) on the server side. Use `z.unknown()`
|
|
48
|
+
// here so server-side additions to the nested shape don't break the
|
|
49
|
+
// dashboard's parse; the UI handles unknown extra fields gracefully.
|
|
50
|
+
activePRs: z.array(z.unknown()),
|
|
51
|
+
shelvedPRUrls: z.array(z.string()),
|
|
52
|
+
recentlyMergedPRs: z.array(z.unknown()),
|
|
53
|
+
recentlyClosedPRs: z.array(z.unknown()),
|
|
54
|
+
autoUnshelvedPRs: z.array(z.unknown()),
|
|
55
|
+
commentedIssues: z.array(z.unknown()),
|
|
56
|
+
issueResponses: z.array(z.unknown()),
|
|
57
|
+
allMergedPRs: z.array(z.unknown()),
|
|
58
|
+
allClosedPRs: z.array(z.unknown()),
|
|
59
|
+
repoMetadata: z.record(z.string(), z.unknown()).optional(),
|
|
60
|
+
vettedIssues: z.unknown().nullable().optional(),
|
|
61
|
+
offline: z.boolean().optional(),
|
|
62
|
+
lastUpdated: z.string().optional(),
|
|
63
|
+
partialFailures: z.array(z.string()).optional(),
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* Validate a raw `/api/data` payload. Returns `{ok: true, data}` on success or
|
|
67
|
+
* `{ok: false, message}` with a condensed Zod error string on failure. Never
|
|
68
|
+
* throws. The dashboard's `useDashboard` hook surfaces the message in the UI.
|
|
69
|
+
*/
|
|
70
|
+
export function validateDashboardData(raw) {
|
|
71
|
+
const result = DashboardDataSchema.safeParse(raw);
|
|
72
|
+
if (result.success)
|
|
73
|
+
return { ok: true, data: result.data };
|
|
74
|
+
const issues = result.error.issues
|
|
75
|
+
.slice(0, 3)
|
|
76
|
+
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
|
77
|
+
.join('; ');
|
|
78
|
+
const more = result.error.issues.length > 3 ? ` (+${result.error.issues.length - 3} more)` : '';
|
|
79
|
+
return { ok: false, message: `Server response did not match expected shape — ${issues}${more}` };
|
|
80
|
+
}
|
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 an optimistic compare-and-swap on state.json detects that
|
|
37
|
+
* another process wrote the file between load and save. See issue #1030.
|
|
38
|
+
*
|
|
39
|
+
* Library consumers should call `stateManager.reloadIfChanged()` and
|
|
40
|
+
* re-apply their mutation. The runtime `message` is phrased for CLI
|
|
41
|
+
* end-users; the structured `expectedMtimeMs` / `actualMtimeMs` fields
|
|
42
|
+
* are for programmatic handling.
|
|
43
|
+
*/
|
|
44
|
+
export declare class ConcurrencyError extends OssAutopilotError {
|
|
45
|
+
readonly expectedMtimeMs: number;
|
|
46
|
+
readonly actualMtimeMs: number;
|
|
47
|
+
constructor(expectedMtimeMs: number, actualMtimeMs: number);
|
|
48
|
+
}
|
|
35
49
|
/**
|
|
36
50
|
* Extract a human-readable message from an unknown error value.
|
|
37
51
|
*/
|
package/dist/core/errors.js
CHANGED
|
@@ -49,6 +49,26 @@ export class GistPermissionError extends ConfigurationError {
|
|
|
49
49
|
this.name = 'GistPermissionError';
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Thrown when an optimistic compare-and-swap on state.json detects that
|
|
54
|
+
* another process wrote the file between load and save. See issue #1030.
|
|
55
|
+
*
|
|
56
|
+
* Library consumers should call `stateManager.reloadIfChanged()` and
|
|
57
|
+
* re-apply their mutation. The runtime `message` is phrased for CLI
|
|
58
|
+
* end-users; the structured `expectedMtimeMs` / `actualMtimeMs` fields
|
|
59
|
+
* are for programmatic handling.
|
|
60
|
+
*/
|
|
61
|
+
export class ConcurrencyError extends OssAutopilotError {
|
|
62
|
+
expectedMtimeMs;
|
|
63
|
+
actualMtimeMs;
|
|
64
|
+
constructor(expectedMtimeMs, actualMtimeMs) {
|
|
65
|
+
super('Another oss-autopilot process wrote state.json concurrently. ' +
|
|
66
|
+
'Re-run the command to retry — the last write wins and no data was lost from the other process.', 'CONCURRENCY_ERROR');
|
|
67
|
+
this.expectedMtimeMs = expectedMtimeMs;
|
|
68
|
+
this.actualMtimeMs = actualMtimeMs;
|
|
69
|
+
this.name = 'ConcurrencyError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
52
72
|
/**
|
|
53
73
|
* Extract a human-readable message from an unknown error value.
|
|
54
74
|
*/
|
|
@@ -126,6 +146,8 @@ export function resolveErrorCode(err) {
|
|
|
126
146
|
return 'CONFIGURATION';
|
|
127
147
|
if (err instanceof ValidationError)
|
|
128
148
|
return 'VALIDATION';
|
|
149
|
+
if (err instanceof ConcurrencyError)
|
|
150
|
+
return 'CONCURRENCY';
|
|
129
151
|
// Check HTTP status codes (Octokit errors)
|
|
130
152
|
const status = getHttpStatusCode(err);
|
|
131
153
|
if (status === 401)
|
|
@@ -25,9 +25,10 @@ export interface CacheEntry {
|
|
|
25
25
|
*/
|
|
26
26
|
export declare class HttpCache {
|
|
27
27
|
private readonly cacheDir;
|
|
28
|
+
private readonly maxEntries;
|
|
28
29
|
/** In-flight request deduplication map: URL -> Promise<response>. */
|
|
29
30
|
private readonly inflightRequests;
|
|
30
|
-
constructor(cacheDir?: string);
|
|
31
|
+
constructor(cacheDir?: string, maxEntries?: number);
|
|
31
32
|
/** Derive a filesystem-safe cache key from a URL. */
|
|
32
33
|
private keyFor;
|
|
33
34
|
/** Full path to the cache file for a given URL. */
|
|
@@ -46,6 +47,12 @@ export declare class HttpCache {
|
|
|
46
47
|
* Store a response with its ETag.
|
|
47
48
|
*/
|
|
48
49
|
set(url: string, etag: string, body: unknown): void;
|
|
50
|
+
/**
|
|
51
|
+
* If the cache directory exceeds `maxEntries`, evict the oldest entries
|
|
52
|
+
* (by mtime) until it's at the cap. Best-effort — any I/O failure is
|
|
53
|
+
* swallowed so cache-bookkeeping never breaks the request path.
|
|
54
|
+
*/
|
|
55
|
+
private evictIfExceeds;
|
|
49
56
|
/**
|
|
50
57
|
* Check whether a URL has an in-flight request.
|
|
51
58
|
*/
|
package/dist/core/http-cache.js
CHANGED
|
@@ -23,6 +23,15 @@ const MODULE = 'http-cache';
|
|
|
23
23
|
* `evictStale()` will remove them.
|
|
24
24
|
*/
|
|
25
25
|
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
/**
|
|
27
|
+
* Soft cap on the number of entries kept on disk (#1057 M27). `set()`
|
|
28
|
+
* opportunistically evicts the oldest entries when the directory grows past
|
|
29
|
+
* this ceiling. The cap is deliberately high (2000) so it doesn't thrash a
|
|
30
|
+
* normal day's traffic — it's a belt-and-suspenders guard against unbounded
|
|
31
|
+
* growth on long-running sessions that accumulate cache files between the
|
|
32
|
+
* daily `evictStale()` sweep.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_MAX_ENTRIES = 2000;
|
|
26
35
|
/**
|
|
27
36
|
* File-based HTTP cache backed by `~/.oss-autopilot/cache/`.
|
|
28
37
|
*
|
|
@@ -32,10 +41,12 @@ const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
|
32
41
|
*/
|
|
33
42
|
export class HttpCache {
|
|
34
43
|
cacheDir;
|
|
44
|
+
maxEntries;
|
|
35
45
|
/** In-flight request deduplication map: URL -> Promise<response>. */
|
|
36
46
|
inflightRequests = new Map();
|
|
37
|
-
constructor(cacheDir) {
|
|
47
|
+
constructor(cacheDir, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
38
48
|
this.cacheDir = cacheDir ?? getCacheDir();
|
|
49
|
+
this.maxEntries = maxEntries;
|
|
39
50
|
}
|
|
40
51
|
/** Derive a filesystem-safe cache key from a URL. */
|
|
41
52
|
keyFor(url) {
|
|
@@ -91,12 +102,59 @@ export class HttpCache {
|
|
|
91
102
|
try {
|
|
92
103
|
fs.writeFileSync(this.pathFor(url), JSON.stringify(entry), { encoding: 'utf-8', mode: 0o600 });
|
|
93
104
|
debug(MODULE, `Cached response for ${url}`);
|
|
105
|
+
// Best-effort size cap (#1057 M27). Runs after each write rather than on
|
|
106
|
+
// a schedule so long-lived sessions can't accumulate past the cap.
|
|
107
|
+
this.evictIfExceeds(this.maxEntries);
|
|
94
108
|
}
|
|
95
109
|
catch (err) {
|
|
96
110
|
// Non-fatal: cache write failure should not break the request
|
|
97
111
|
debug(MODULE, `Failed to write cache for ${url}`, err);
|
|
98
112
|
}
|
|
99
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* If the cache directory exceeds `maxEntries`, evict the oldest entries
|
|
116
|
+
* (by mtime) until it's at the cap. Best-effort — any I/O failure is
|
|
117
|
+
* swallowed so cache-bookkeeping never breaks the request path.
|
|
118
|
+
*/
|
|
119
|
+
evictIfExceeds(maxEntries) {
|
|
120
|
+
if (maxEntries <= 0)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
const entries = fs.readdirSync(this.cacheDir).filter((f) => f.endsWith('.json'));
|
|
124
|
+
if (entries.length <= maxEntries)
|
|
125
|
+
return;
|
|
126
|
+
// Stat once; sort oldest first so we evict the stalest files.
|
|
127
|
+
const withMtime = entries
|
|
128
|
+
.map((file) => {
|
|
129
|
+
const fullPath = path.join(this.cacheDir, file);
|
|
130
|
+
try {
|
|
131
|
+
return { fullPath, mtimeMs: fs.statSync(fullPath).mtimeMs };
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
.filter((e) => e !== null)
|
|
138
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
139
|
+
const toEvict = withMtime.length - maxEntries;
|
|
140
|
+
let evicted = 0;
|
|
141
|
+
for (let i = 0; i < toEvict; i++) {
|
|
142
|
+
try {
|
|
143
|
+
fs.unlinkSync(withMtime[i].fullPath);
|
|
144
|
+
evicted++;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Another process may have raced us — ignore.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (evicted > 0) {
|
|
151
|
+
debug(MODULE, `Capped cache at ${maxEntries} entries: evicted ${evicted} oldest`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Cache dir missing / unreadable — not fatal.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
100
158
|
/**
|
|
101
159
|
* Check whether a URL has an in-flight request.
|
|
102
160
|
*/
|
package/dist/core/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Core module exports
|
|
3
3
|
* Re-exports all core functionality for convenient imports
|
|
4
4
|
*/
|
|
5
|
-
export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, resetStateManager, type Stats, } from './state.js';
|
|
5
|
+
export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
|
|
6
6
|
export { GistStateStore } from './gist-state-store.js';
|
|
7
7
|
export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
|
|
8
8
|
export { IssueConversationMonitor } from './issue-conversation.js';
|
|
@@ -18,4 +18,6 @@ export { fetchPRTemplate, type PRTemplateResult } from './pr-template.js';
|
|
|
18
18
|
export { classifyLinkedPR, type LinkedPR, type LinkedPRClassification, type LinkedPRState, } from './linked-pr-classification.js';
|
|
19
19
|
export { scanForAntiLLMPolicy, type AntiLLMCategory, type AntiLLMMatch, type AntiLLMScanResult, } from './anti-llm-policy.js';
|
|
20
20
|
export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, type DetectedFormatter, type FormatterDetectionResult, type CIFormatterDiagnosis, type FormatterName, } from './formatter-detection.js';
|
|
21
|
+
export { CONFIG_KEY_REGISTRY, type ConfigKeyDef, type SettableVia, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';
|
|
22
|
+
export { DashboardDataSchema, DashboardStatsSchema, validateDashboardData, type DashboardDataParsed, } from './dashboard-data-schema.js';
|
|
21
23
|
export * from './types.js';
|
package/dist/core/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Core module exports
|
|
3
3
|
* Re-exports all core functionality for convenient imports
|
|
4
4
|
*/
|
|
5
|
-
export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, resetStateManager, } from './state.js';
|
|
5
|
+
export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, } from './state.js';
|
|
6
6
|
export { GistStateStore } from './gist-state-store.js';
|
|
7
7
|
export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
|
|
8
8
|
// Search/vetting now delegated to @oss-scout/core via commands/scout-bridge.ts
|
|
@@ -19,4 +19,6 @@ export { fetchPRTemplate } from './pr-template.js';
|
|
|
19
19
|
export { classifyLinkedPR, } from './linked-pr-classification.js';
|
|
20
20
|
export { scanForAntiLLMPolicy, } from './anti-llm-policy.js';
|
|
21
21
|
export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, } from './formatter-detection.js';
|
|
22
|
+
export { CONFIG_KEY_REGISTRY, isKnownKey, getKeyDef, getSetupKeys, getConfigKeys, suggestKey, formatUnknownKeyError, } from './config-registry.js';
|
|
23
|
+
export { DashboardDataSchema, DashboardStatsSchema, validateDashboardData, } from './dashboard-data-schema.js';
|
|
22
24
|
export * from './types.js';
|
|
@@ -50,9 +50,15 @@ export function extractMaintainerActionHints(commentBody, reviewDecision) {
|
|
|
50
50
|
if (docKeywords.some((kw) => lower.includes(kw))) {
|
|
51
51
|
hints.push('docs_requested');
|
|
52
52
|
}
|
|
53
|
-
// Rebase requests
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
// Rebase requests.
|
|
54
|
+
//
|
|
55
|
+
// The `rebase` term uses a word-boundary regex so past-tense mentions like
|
|
56
|
+
// "after rebasing this was fine" or "I already rebased this" don't trigger
|
|
57
|
+
// a rebase_requested hint (#1057 M30). The other phrases are specific
|
|
58
|
+
// enough that plain substring matching is sufficient.
|
|
59
|
+
const rebasePhrases = ['merge conflict', 'out of date', 'behind main', 'behind master'];
|
|
60
|
+
const hasRebaseWord = /\brebase\b/i.test(commentBody);
|
|
61
|
+
if (hasRebaseWord || rebasePhrases.some((kw) => lower.includes(kw))) {
|
|
56
62
|
hints.push('rebase_requested');
|
|
57
63
|
}
|
|
58
64
|
return hints;
|
|
@@ -33,6 +33,13 @@ export interface PRCheckFailure {
|
|
|
33
33
|
export interface FetchPRsResult {
|
|
34
34
|
prs: FetchedPR[];
|
|
35
35
|
failures: PRCheckFailure[];
|
|
36
|
+
/**
|
|
37
|
+
* Non-fatal warnings accumulated while fetching. Currently populated when
|
|
38
|
+
* the GitHub Search API's 1000-result ceiling truncates the user's PR
|
|
39
|
+
* list — callers (daily, dashboard) surface these so users know the data
|
|
40
|
+
* may be incomplete (#1057 M25).
|
|
41
|
+
*/
|
|
42
|
+
warnings?: string[];
|
|
36
43
|
}
|
|
37
44
|
/**
|
|
38
45
|
* Fetches and enriches open PRs from GitHub for the configured user.
|
package/dist/core/pr-monitor.js
CHANGED
|
@@ -17,7 +17,7 @@ import { getStateManager } from './state.js';
|
|
|
17
17
|
import { daysBetween, parseGitHubUrl, extractOwnerRepo, isOwnRepo, DEFAULT_CONCURRENCY } from './utils.js';
|
|
18
18
|
import { determineStatus } from './status-determination.js';
|
|
19
19
|
import { runWorkerPool } from './concurrency.js';
|
|
20
|
-
import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
|
|
20
|
+
import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitOrAuthError, } from './errors.js';
|
|
21
21
|
import { paginateAll } from './pagination.js';
|
|
22
22
|
import { debug, warn, timed } from './logger.js';
|
|
23
23
|
import { getHttpCache, cachedRequest } from './http-cache.js';
|
|
@@ -96,9 +96,50 @@ export class PRMonitor {
|
|
|
96
96
|
});
|
|
97
97
|
allItems.push(...firstPage.data.items);
|
|
98
98
|
const totalCount = firstPage.data.total_count;
|
|
99
|
-
debug(
|
|
99
|
+
debug(MODULE, `Found ${totalCount} open PRs`);
|
|
100
100
|
// Fetch remaining pages if needed (GitHub search API returns max 1000 results)
|
|
101
|
-
const
|
|
101
|
+
const SEARCH_API_RESULT_CAP = 1000;
|
|
102
|
+
const MAX_PAGES = Math.ceil(SEARCH_API_RESULT_CAP / perPage); // 10 pages at per_page=100
|
|
103
|
+
const totalPages = Math.min(Math.ceil(totalCount / perPage), MAX_PAGES);
|
|
104
|
+
// Non-fatal warnings threaded into the result (#1057 M25). When the
|
|
105
|
+
// Search API's hard 1000-result ceiling truncates the user's PR list we
|
|
106
|
+
// previously silently dropped the overflow; now the caller can surface
|
|
107
|
+
// it so the daily digest doesn't quietly report a partial view.
|
|
108
|
+
const warnings = [];
|
|
109
|
+
// Guardrail: if the Search API returned zero PRs, cross-check the
|
|
110
|
+
// configured username against the authenticated viewer. A real failure
|
|
111
|
+
// mode was a stale/placeholder username (e.g. "example-user") silently
|
|
112
|
+
// producing zero results with no error — the dashboard just showed
|
|
113
|
+
// "0 active PRs" and looked like a fresh install. getAuthenticated is
|
|
114
|
+
// advisory; a failure here never breaks the fetch.
|
|
115
|
+
if (totalCount === 0) {
|
|
116
|
+
try {
|
|
117
|
+
const { data: viewer } = await this.octokit.users.getAuthenticated();
|
|
118
|
+
if (viewer.login.toLowerCase() !== config.githubUsername.toLowerCase()) {
|
|
119
|
+
const message = `Configured GitHub username @${config.githubUsername} does not match ` +
|
|
120
|
+
`authenticated user @${viewer.login}. Did you mean to run ` +
|
|
121
|
+
`\`oss-autopilot config username ${viewer.login}\`? Zero PRs returned.`;
|
|
122
|
+
warnings.push(message);
|
|
123
|
+
warn(MODULE, message);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
// Rate-limit/401/403 errors must abort the run just like every sibling
|
|
128
|
+
// fetch in this pipeline — swallowing them here would mask the exact
|
|
129
|
+
// class of failure the guardrail is meant to surface (e.g. revoked
|
|
130
|
+
// token returning 401 while the unauthenticated Search above still
|
|
131
|
+
// succeeds with zero results).
|
|
132
|
+
if (isRateLimitOrAuthError(err))
|
|
133
|
+
throw err;
|
|
134
|
+
debug(MODULE, `Could not cross-check viewer login: ${errorMessage(err)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (totalCount > SEARCH_API_RESULT_CAP) {
|
|
138
|
+
warnings.push(`GitHub Search API returned ${totalCount} PRs for @${config.githubUsername}, ` +
|
|
139
|
+
`but results are capped at ${SEARCH_API_RESULT_CAP}. ` +
|
|
140
|
+
`Showing the ${SEARCH_API_RESULT_CAP} most recently updated PRs.`);
|
|
141
|
+
warn(MODULE, warnings[warnings.length - 1]);
|
|
142
|
+
}
|
|
102
143
|
while (page < totalPages) {
|
|
103
144
|
page++;
|
|
104
145
|
const nextPage = await this.octokit.search.issuesAndPullRequests({
|
|
@@ -149,7 +190,7 @@ export class PRMonitor {
|
|
|
149
190
|
return 0;
|
|
150
191
|
return a.status === 'needs_addressing' ? -1 : 1;
|
|
151
192
|
});
|
|
152
|
-
return { prs, failures };
|
|
193
|
+
return warnings.length > 0 ? { prs, failures, warnings } : { prs, failures };
|
|
153
194
|
}
|
|
154
195
|
/**
|
|
155
196
|
* Fetch detailed information for a single PR
|
|
@@ -3,12 +3,26 @@
|
|
|
3
3
|
* Functions that operate on AgentState for scoring, querying,
|
|
4
4
|
* and computing aggregate statistics. Mutation functions modify
|
|
5
5
|
* the passed state object in place; query functions are pure.
|
|
6
|
+
*
|
|
7
|
+
* **User-facing reference:** `docs/repo-scoring.md` — plain-language
|
|
8
|
+
* explanation of the formula and what a given score means.
|
|
6
9
|
*/
|
|
7
10
|
import { AgentState, RepoScore, RepoScoreUpdate, StoredMergedPR, StoredClosedPR } from './types.js';
|
|
8
11
|
/**
|
|
9
|
-
* Calculate
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
+
* Calculate a 1–10 score for a repo based on the user's PR history and the
|
|
13
|
+
* repo's maintainer-health signals.
|
|
14
|
+
*
|
|
15
|
+
* Formula (all constants named above with rationale):
|
|
16
|
+
* BASE_SCORE
|
|
17
|
+
* + min(round(log2(merged + 1) * MERGE_BONUS_COEFFICIENT), MERGE_BONUS_CAP)
|
|
18
|
+
* − min(closedWithoutMergeCount, CLOSED_PENALTY_CAP)
|
|
19
|
+
* + (lastMergedAt within RECENCY_WINDOW_DAYS ? RECENCY_BONUS : 0)
|
|
20
|
+
* + (isResponsive ? RESPONSIVENESS_BONUS : 0)
|
|
21
|
+
* − (hasHostileComments ? HOSTILITY_PENALTY : 0)
|
|
22
|
+
* clamped to [SCORE_MIN, SCORE_MAX].
|
|
23
|
+
*
|
|
24
|
+
* See `docs/repo-scoring.md` for user-facing intent and what a given
|
|
25
|
+
* score means in practice.
|
|
12
26
|
*/
|
|
13
27
|
export declare function calculateScore(repoScore: RepoScore): number;
|
|
14
28
|
/**
|
|
@@ -3,11 +3,36 @@
|
|
|
3
3
|
* Functions that operate on AgentState for scoring, querying,
|
|
4
4
|
* and computing aggregate statistics. Mutation functions modify
|
|
5
5
|
* the passed state object in place; query functions are pure.
|
|
6
|
+
*
|
|
7
|
+
* **User-facing reference:** `docs/repo-scoring.md` — plain-language
|
|
8
|
+
* explanation of the formula and what a given score means.
|
|
6
9
|
*/
|
|
7
10
|
import { isBelowMinStars } from './types.js';
|
|
8
11
|
import { debug, warn } from './logger.js';
|
|
9
12
|
import { parseGitHubUrl } from './utils.js';
|
|
10
13
|
const MODULE = 'scoring';
|
|
14
|
+
// ── Scoring constants (#1054) ─────────────────────────────────────────
|
|
15
|
+
// Previously inlined as magic numbers in `calculateScore`. Extracted with
|
|
16
|
+
// rationale comments so the formula is auditable without source spelunking.
|
|
17
|
+
// Changing any of these is a behavior change — update docs/repo-scoring.md
|
|
18
|
+
// and the tests below in lockstep.
|
|
19
|
+
/** Starting point before any signals are applied. Deliberately optimistic so first-time repos aren't punished. */
|
|
20
|
+
const BASE_SCORE = 5;
|
|
21
|
+
/** Logarithmic merge bonus: 1 merge→+2, 2→+3, 3→+4, 4+→+5. Log instead of linear so a 20th merge doesn't dominate. */
|
|
22
|
+
const MERGE_BONUS_COEFFICIENT = 2;
|
|
23
|
+
const MERGE_BONUS_CAP = 5;
|
|
24
|
+
/** −1 per closed-without-merge PR, capped so a few rejections don't zero out an otherwise-healthy repo. */
|
|
25
|
+
const CLOSED_PENALTY_CAP = 3;
|
|
26
|
+
/** Repos whose most-recent merge is within this window get a +1 freshness bonus. */
|
|
27
|
+
const RECENCY_WINDOW_DAYS = 90;
|
|
28
|
+
const RECENCY_BONUS = 1;
|
|
29
|
+
/** +1 when the repo's signals say maintainers respond to PRs (per-PR computed; see issue-grading.ts). */
|
|
30
|
+
const RESPONSIVENESS_BONUS = 1;
|
|
31
|
+
/** −2 when hostile maintainer comments have been detected (e.g., explicit rejection of PRs without review). */
|
|
32
|
+
const HOSTILITY_PENALTY = 2;
|
|
33
|
+
/** Final clamp — scores always land in this inclusive range. */
|
|
34
|
+
const SCORE_MIN = 1;
|
|
35
|
+
const SCORE_MAX = 10;
|
|
11
36
|
/** Repo scores older than this are considered stale and excluded from low-scoring lists. */
|
|
12
37
|
const SCORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
13
38
|
/**
|
|
@@ -16,7 +41,7 @@ const SCORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
|
16
41
|
function createDefaultRepoScore(repo) {
|
|
17
42
|
return {
|
|
18
43
|
repo,
|
|
19
|
-
score:
|
|
44
|
+
score: BASE_SCORE,
|
|
20
45
|
mergedPRCount: 0,
|
|
21
46
|
closedWithoutMergeCount: 0,
|
|
22
47
|
avgResponseDays: null,
|
|
@@ -29,21 +54,28 @@ function createDefaultRepoScore(repo) {
|
|
|
29
54
|
};
|
|
30
55
|
}
|
|
31
56
|
/**
|
|
32
|
-
* Calculate
|
|
33
|
-
*
|
|
34
|
-
*
|
|
57
|
+
* Calculate a 1–10 score for a repo based on the user's PR history and the
|
|
58
|
+
* repo's maintainer-health signals.
|
|
59
|
+
*
|
|
60
|
+
* Formula (all constants named above with rationale):
|
|
61
|
+
* BASE_SCORE
|
|
62
|
+
* + min(round(log2(merged + 1) * MERGE_BONUS_COEFFICIENT), MERGE_BONUS_CAP)
|
|
63
|
+
* − min(closedWithoutMergeCount, CLOSED_PENALTY_CAP)
|
|
64
|
+
* + (lastMergedAt within RECENCY_WINDOW_DAYS ? RECENCY_BONUS : 0)
|
|
65
|
+
* + (isResponsive ? RESPONSIVENESS_BONUS : 0)
|
|
66
|
+
* − (hasHostileComments ? HOSTILITY_PENALTY : 0)
|
|
67
|
+
* clamped to [SCORE_MIN, SCORE_MAX].
|
|
68
|
+
*
|
|
69
|
+
* See `docs/repo-scoring.md` for user-facing intent and what a given
|
|
70
|
+
* score means in practice.
|
|
35
71
|
*/
|
|
36
72
|
export function calculateScore(repoScore) {
|
|
37
|
-
let score =
|
|
38
|
-
// Logarithmic merge bonus (max +5): 1→+2, 2→+3, 3→+4, 4+→+5
|
|
73
|
+
let score = BASE_SCORE;
|
|
39
74
|
if (repoScore.mergedPRCount > 0) {
|
|
40
|
-
const mergedBonus = Math.min(Math.round(Math.log2(repoScore.mergedPRCount + 1) *
|
|
75
|
+
const mergedBonus = Math.min(Math.round(Math.log2(repoScore.mergedPRCount + 1) * MERGE_BONUS_COEFFICIENT), MERGE_BONUS_CAP);
|
|
41
76
|
score += mergedBonus;
|
|
42
77
|
}
|
|
43
|
-
|
|
44
|
-
const closedPenalty = Math.min(repoScore.closedWithoutMergeCount, 3);
|
|
45
|
-
score -= closedPenalty;
|
|
46
|
-
// +1 if lastMergedAt is set and within 90 days (recency)
|
|
78
|
+
score -= Math.min(repoScore.closedWithoutMergeCount, CLOSED_PENALTY_CAP);
|
|
47
79
|
if (repoScore.lastMergedAt) {
|
|
48
80
|
const lastMergedDate = new Date(repoScore.lastMergedAt);
|
|
49
81
|
if (isNaN(lastMergedDate.getTime())) {
|
|
@@ -52,21 +84,18 @@ export function calculateScore(repoScore) {
|
|
|
52
84
|
else {
|
|
53
85
|
const msPerDay = 1000 * 60 * 60 * 24;
|
|
54
86
|
const daysSince = Math.floor((Date.now() - lastMergedDate.getTime()) / msPerDay);
|
|
55
|
-
if (daysSince <=
|
|
56
|
-
score +=
|
|
87
|
+
if (daysSince <= RECENCY_WINDOW_DAYS) {
|
|
88
|
+
score += RECENCY_BONUS;
|
|
57
89
|
}
|
|
58
90
|
}
|
|
59
91
|
}
|
|
60
|
-
// +1 if responsive
|
|
61
92
|
if (repoScore.signals.isResponsive) {
|
|
62
|
-
score +=
|
|
93
|
+
score += RESPONSIVENESS_BONUS;
|
|
63
94
|
}
|
|
64
|
-
// -2 if hostile
|
|
65
95
|
if (repoScore.signals.hasHostileComments) {
|
|
66
|
-
score -=
|
|
96
|
+
score -= HOSTILITY_PENALTY;
|
|
67
97
|
}
|
|
68
|
-
|
|
69
|
-
return Math.max(1, Math.min(10, score));
|
|
98
|
+
return Math.max(SCORE_MIN, Math.min(SCORE_MAX, score));
|
|
70
99
|
}
|
|
71
100
|
/**
|
|
72
101
|
* Get the score record for a repository.
|
|
@@ -52,9 +52,22 @@ export declare function loadState(): {
|
|
|
52
52
|
/**
|
|
53
53
|
* Persist state to disk, creating a timestamped backup of the previous
|
|
54
54
|
* state file first. Retains at most 10 backup files.
|
|
55
|
+
*
|
|
56
|
+
* When `expectedMtimeMs` is provided (non-null, non-zero), implements
|
|
57
|
+
* optimistic compare-and-swap: if the on-disk file has been modified since
|
|
58
|
+
* the caller last loaded it, throws `ConcurrencyError` instead of
|
|
59
|
+
* overwriting. This prevents the classic read-modify-write lost-update
|
|
60
|
+
* race across processes (see issue #1030). Pass `null` / `0` to disable
|
|
61
|
+
* the check (first write, or when the caller has already reloaded).
|
|
62
|
+
*
|
|
63
|
+
* The check runs *inside* the advisory lock so the compare-and-swap is
|
|
64
|
+
* atomic with respect to the write.
|
|
65
|
+
*
|
|
55
66
|
* @returns The file's mtime after writing (for change detection).
|
|
67
|
+
* @throws ConcurrencyError when `expectedMtimeMs` is provided and the
|
|
68
|
+
* on-disk mtime no longer matches.
|
|
56
69
|
*/
|
|
57
|
-
export declare function saveState(state: Readonly<AgentState
|
|
70
|
+
export declare function saveState(state: Readonly<AgentState>, expectedMtimeMs?: number | null): number;
|
|
58
71
|
/**
|
|
59
72
|
* Re-read state from disk if the file has been modified since the last load/save.
|
|
60
73
|
* Uses mtime comparison (single statSync call) to avoid unnecessary JSON parsing.
|
|
@@ -7,7 +7,7 @@ import * as fs from 'fs';
|
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import { AgentStateSchema } from './state-schema.js';
|
|
9
9
|
import { getStatePath, getBackupDir, getDataDir } from './utils.js';
|
|
10
|
-
import { errorMessage } from './errors.js';
|
|
10
|
+
import { errorMessage, ConcurrencyError } from './errors.js';
|
|
11
11
|
import { debug, warn } from './logger.js';
|
|
12
12
|
const MODULE = 'state';
|
|
13
13
|
// Lock file timeout: if a lock is older than this, it is considered stale
|
|
@@ -439,15 +439,37 @@ function cleanupBackups() {
|
|
|
439
439
|
/**
|
|
440
440
|
* Persist state to disk, creating a timestamped backup of the previous
|
|
441
441
|
* state file first. Retains at most 10 backup files.
|
|
442
|
+
*
|
|
443
|
+
* When `expectedMtimeMs` is provided (non-null, non-zero), implements
|
|
444
|
+
* optimistic compare-and-swap: if the on-disk file has been modified since
|
|
445
|
+
* the caller last loaded it, throws `ConcurrencyError` instead of
|
|
446
|
+
* overwriting. This prevents the classic read-modify-write lost-update
|
|
447
|
+
* race across processes (see issue #1030). Pass `null` / `0` to disable
|
|
448
|
+
* the check (first write, or when the caller has already reloaded).
|
|
449
|
+
*
|
|
450
|
+
* The check runs *inside* the advisory lock so the compare-and-swap is
|
|
451
|
+
* atomic with respect to the write.
|
|
452
|
+
*
|
|
442
453
|
* @returns The file's mtime after writing (for change detection).
|
|
454
|
+
* @throws ConcurrencyError when `expectedMtimeMs` is provided and the
|
|
455
|
+
* on-disk mtime no longer matches.
|
|
443
456
|
*/
|
|
444
|
-
export function saveState(state) {
|
|
457
|
+
export function saveState(state, expectedMtimeMs = null) {
|
|
445
458
|
const statePath = getStatePath();
|
|
446
459
|
const lockPath = statePath + '.lock';
|
|
447
460
|
const backupDir = getBackupDir();
|
|
448
461
|
// Acquire advisory lock to prevent concurrent writes
|
|
449
462
|
acquireLock(lockPath);
|
|
450
463
|
try {
|
|
464
|
+
// Compare-and-swap: reject the write if the file changed externally
|
|
465
|
+
// between the caller's last load and now. Zero/null bypasses the
|
|
466
|
+
// check for first writes and Gist-mode local-cache paths.
|
|
467
|
+
if (expectedMtimeMs !== null && expectedMtimeMs > 0 && fs.existsSync(statePath)) {
|
|
468
|
+
const currentMtimeMs = safeGetMtimeMs(statePath);
|
|
469
|
+
if (currentMtimeMs !== expectedMtimeMs) {
|
|
470
|
+
throw new ConcurrencyError(expectedMtimeMs, currentMtimeMs);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
451
473
|
// Create backup of existing state (best-effort, non-fatal)
|
|
452
474
|
try {
|
|
453
475
|
if (fs.existsSync(statePath)) {
|
|
@@ -240,6 +240,7 @@ export declare const AgentConfigSchema: z.ZodObject<{
|
|
|
240
240
|
vscode: "vscode";
|
|
241
241
|
}>>;
|
|
242
242
|
diffToolCustomCommand: z.ZodOptional<z.ZodString>;
|
|
243
|
+
autoFormatBeforePush: z.ZodDefault<z.ZodBoolean>;
|
|
243
244
|
}, z.core.$strip>;
|
|
244
245
|
export declare const LocalRepoCacheSchema: z.ZodObject<{
|
|
245
246
|
repos: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -397,6 +398,7 @@ export declare const AgentStateSchema: z.ZodObject<{
|
|
|
397
398
|
vscode: "vscode";
|
|
398
399
|
}>>;
|
|
399
400
|
diffToolCustomCommand: z.ZodOptional<z.ZodString>;
|
|
401
|
+
autoFormatBeforePush: z.ZodDefault<z.ZodBoolean>;
|
|
400
402
|
}, z.core.$strip>>;
|
|
401
403
|
lastRunAt: z.ZodDefault<z.ZodString>;
|
|
402
404
|
lastDigestAt: z.ZodOptional<z.ZodString>;
|
|
@@ -146,6 +146,11 @@ export const AgentConfigSchema = z.object({
|
|
|
146
146
|
preferredOrgs: z.array(z.string()).default([]),
|
|
147
147
|
diffTool: DiffToolSchema.default('inline'),
|
|
148
148
|
diffToolCustomCommand: z.string().optional(),
|
|
149
|
+
/**
|
|
150
|
+
* Opt-in gate for the auto-format-before-push hook (#1045). Default false:
|
|
151
|
+
* the hook does nothing on every push unless the user explicitly enables it.
|
|
152
|
+
*/
|
|
153
|
+
autoFormatBeforePush: z.boolean().default(false),
|
|
149
154
|
});
|
|
150
155
|
// ── 6. Cache schemas ─────────────────────────────────────────────────
|
|
151
156
|
export const LocalRepoCacheSchema = z.object({
|