@rigour-labs/core 6.8.1 → 6.9.0-rc.2
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/goal/goal.d.ts +34 -0
- package/dist/goal/goal.js +188 -0
- package/dist/goal/hook.d.ts +7 -0
- package/dist/goal/hook.js +100 -0
- package/dist/hooks/stop-review.js +4 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/outcomes/outcome.d.ts +80 -0
- package/dist/outcomes/outcome.js +161 -0
- package/dist/outcomes/run.d.ts +26 -0
- package/dist/outcomes/run.js +63 -0
- package/dist/review/backtest-init.d.ts +9 -4
- package/dist/review/backtest-init.js +2 -2
- package/dist/review/quiet.js +2 -1
- package/dist/review/review.d.ts +5 -0
- package/dist/review/review.js +23 -2
- package/dist/review/reviewer/context.d.ts +11 -0
- package/dist/review/reviewer/context.js +10 -4
- package/dist/review/reviewer/prompt.d.ts +8 -1
- package/dist/review/reviewer/prompt.js +21 -2
- package/dist/review/reviewer/verdict.d.ts +12 -1
- package/dist/review/reviewer/verdict.js +8 -1
- package/dist/review/reviewer.d.ts +4 -0
- package/dist/review/reviewer.js +14 -6
- package/dist/review-learning/lessons.d.ts +11 -3
- package/dist/review-learning/lessons.js +3 -0
- package/dist/review-learning/outcome-evidence.d.ts +41 -0
- package/dist/review-learning/outcome-evidence.js +72 -0
- package/dist/review-learning/outcomes.d.ts +8 -0
- package/dist/review-learning/outcomes.js +11 -5
- package/dist/settings.d.ts +2 -0
- package/dist/switches.d.ts +640 -0
- package/dist/switches.js +36 -0
- package/dist/task/thread.d.ts +9 -1
- package/dist/task/thread.js +33 -1
- package/dist/templates/universal-config.js +4 -0
- package/dist/types/fix-packet.d.ts +1 -1
- package/dist/types/index.d.ts +56 -0
- package/dist/types/index.js +18 -0
- package/package.json +6 -6
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Failure } from '../types/index.js';
|
|
2
|
+
export interface DoneItem {
|
|
3
|
+
text: string;
|
|
4
|
+
/** What the item names in backticks: files or paths, and symbols. */
|
|
5
|
+
paths: string[];
|
|
6
|
+
symbols: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface Goal {
|
|
9
|
+
doneWhen: DoneItem[];
|
|
10
|
+
scope: string[];
|
|
11
|
+
outOfScope: string[];
|
|
12
|
+
invariants: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The goal a pull request's description declares; empty when it declares none. `exists` tells a bare file name from a
|
|
16
|
+
* member access (isPath); without it, every bare name with a known file extension is a file.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseGoal(description: string, exists?: (name: string) => boolean): Goal;
|
|
19
|
+
/** Whether the description declares anything the deterministic checks can check. */
|
|
20
|
+
export declare function hasCheckableGoal(goal: Goal): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The goal's deterministic findings for a change. `changedLines` is the review's changed lines per file (generated files
|
|
23
|
+
* already left out); a deleted file has none and is read from the diff's headers, left out too when `isGenerated` says
|
|
24
|
+
* so. A named symbol must appear on an added or removed line.
|
|
25
|
+
*/
|
|
26
|
+
export declare function goalFailures(goal: Goal, changedLines: Record<string, Set<number>>, diff: string, isGenerated?: (file: string) => boolean): Failure[];
|
|
27
|
+
/**
|
|
28
|
+
* What only a reader of the code can judge: the "Done when" items that name no file (prose, or a symbol) and the
|
|
29
|
+
* invariants. An item that names a file is proven or broken by goalFailures, without a model.
|
|
30
|
+
*/
|
|
31
|
+
export declare function modelGoalItems(goal: Goal): Array<{
|
|
32
|
+
kind: 'done' | 'invariant';
|
|
33
|
+
text: string;
|
|
34
|
+
}>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The goal of a change, as its author declared it, and the deterministic checks of the change against it.
|
|
3
|
+
*
|
|
4
|
+
* The goal is read from the pull request's description, from headings the author writes on purpose:
|
|
5
|
+
*
|
|
6
|
+
* ## Done when a checklist; an item that names a file, path, symbol or migration in backticks is checkable
|
|
7
|
+
* ## Scope the paths the change may touch (globs or folders, in backticks or one per bullet)
|
|
8
|
+
* ## Out of scope paths it must not touch
|
|
9
|
+
* ## Invariants what must stay true (recorded with the goal; not checked here)
|
|
10
|
+
*
|
|
11
|
+
* Only what the author declared is checked: a description without these headings has no goal to check against, and
|
|
12
|
+
* nothing here ever blocks it. The checks are deterministic (no model): a changed file outside the declared scope, a
|
|
13
|
+
* changed file inside the declared out-of-scope, and a "done when" item whose named files the change never touches. A
|
|
14
|
+
* named symbol the change never touches is a note, never a block: an item can state a property the change keeps. Tests,
|
|
15
|
+
* snapshots, lockfiles, changelogs and release notes are exempt from the scope check (they follow the code they belong
|
|
16
|
+
* to), and so is a file a "Done when" item names.
|
|
17
|
+
*/
|
|
18
|
+
import micromatch from 'micromatch';
|
|
19
|
+
const HEADINGS = [
|
|
20
|
+
// The headings teams already write a goal under; a checklist of procedure ("Test plan", "Review checklist") is not one.
|
|
21
|
+
[/^(?:done when|acceptance criteria|definition of done|success criteria)$/, 'doneWhen'],
|
|
22
|
+
[/^(?:scope|boundary|in scope)$/, 'scope'],
|
|
23
|
+
[/^(?:out of scope|not in scope|out-of-scope)$/, 'outOfScope'],
|
|
24
|
+
[/^invariants?$/, 'invariants'],
|
|
25
|
+
];
|
|
26
|
+
/** The section a line opens, if it is a heading (`## Scope`, `**Scope**`, `Scope:` alone on a line); `null` for another heading. */
|
|
27
|
+
function headingOf(line) {
|
|
28
|
+
const markdown = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
|
|
29
|
+
const bold = /^\s{0,3}(?:\*\*|__)(.+?)(?:\*\*|__)\s*:?\s*$/.exec(line);
|
|
30
|
+
const label = /^\s{0,3}([A-Za-z][A-Za-z -]{1,30}):\s*$/.exec(line);
|
|
31
|
+
const text = (markdown ?? bold ?? label)?.[1];
|
|
32
|
+
if (!text)
|
|
33
|
+
return undefined;
|
|
34
|
+
const name = text.replace(/[:*_`]/g, '').trim().toLowerCase();
|
|
35
|
+
for (const [pattern, section] of HEADINGS)
|
|
36
|
+
if (pattern.test(name))
|
|
37
|
+
return section;
|
|
38
|
+
return markdown || bold ? null : undefined; // another heading ends the section; a stray "Label:" line does not
|
|
39
|
+
}
|
|
40
|
+
const ITEM = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+?)\s*$/;
|
|
41
|
+
const TICKED = /`([^`\n]+)`/g;
|
|
42
|
+
const FILE_EXTENSIONS = new Set(['ts', 'tsx', 'mts', 'cts', 'js', 'mjs', 'cjs', 'jsx', 'json', 'jsonc', 'md', 'mdx', 'yml', 'yaml', 'sql', 'py', 'go', 'rs', 'java', 'rb', 'kt', 'kts',
|
|
43
|
+
'swift', 'svelte', 'vue', 'css', 'scss', 'sass', 'less', 'html', 'htm', 'toml', 'lock', 'sh', 'bash', 'zsh', 'sha256', 'txt', 'csv', 'xml', 'graphql', 'gql', 'proto',
|
|
44
|
+
'ini', 'cfg', 'conf', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'php', 'scala', 'ex', 'exs', 'dart', 'lua', 'tf', 'hcl', 'snap']);
|
|
45
|
+
/**
|
|
46
|
+
* A token that names a file or path: has a folder separator or a glob, or ends in a known file extension. Member
|
|
47
|
+
* access (`JSON.parse`, `order.customerId`, `res.status`) is a symbol, not a file. A bare `name.ext` is a file only when
|
|
48
|
+
* `exists` (the repository's file names) knows it, so `package.json` is a file and `res.json` is a member access. A URL
|
|
49
|
+
* or an app route (`https://host/checkout?x=1`, `/app/dashboard`) is never a file of the repository.
|
|
50
|
+
*/
|
|
51
|
+
function isPath(token, exists) {
|
|
52
|
+
if (/:\/\//.test(token) || token.startsWith('/') || !/^[\w.@/*?[\]{}!,+()$~-]+$/.test(token))
|
|
53
|
+
return false;
|
|
54
|
+
if (/[/*?[\]{}]/.test(token))
|
|
55
|
+
return true;
|
|
56
|
+
const extension = /\.([A-Za-z0-9]{1,8})$/.exec(token)?.[1];
|
|
57
|
+
return !!extension && FILE_EXTENSIONS.has(extension) && (!exists || exists(token));
|
|
58
|
+
}
|
|
59
|
+
/** A token that names a symbol: an identifier, optionally dotted, optionally called. */
|
|
60
|
+
function symbolOf(token) {
|
|
61
|
+
const m = /^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\(\))?$/.exec(token.trim());
|
|
62
|
+
return m && m[1].length >= 3 ? m[1] : undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The goal a pull request's description declares; empty when it declares none. `exists` tells a bare file name from a
|
|
66
|
+
* member access (isPath); without it, every bare name with a known file extension is a file.
|
|
67
|
+
*/
|
|
68
|
+
export function parseGoal(description, exists) {
|
|
69
|
+
const path = (token) => isPath(token, exists);
|
|
70
|
+
const goal = { doneWhen: [], scope: [], outOfScope: [], invariants: [] };
|
|
71
|
+
let section;
|
|
72
|
+
for (const line of description.replace(/\r\n/g, '\n').split('\n')) {
|
|
73
|
+
const heading = headingOf(line);
|
|
74
|
+
if (heading !== undefined) {
|
|
75
|
+
section = heading ?? undefined;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!section)
|
|
79
|
+
continue;
|
|
80
|
+
const item = ITEM.exec(line)?.[1];
|
|
81
|
+
if (!item)
|
|
82
|
+
continue;
|
|
83
|
+
const ticked = [...item.matchAll(TICKED)].map(m => m[1].trim());
|
|
84
|
+
if (section === 'doneWhen') {
|
|
85
|
+
goal.doneWhen.push({ text: item, paths: ticked.filter(path), symbols: ticked.filter(t => !path(t)).map(symbolOf).filter((s) => !!s) });
|
|
86
|
+
}
|
|
87
|
+
else if (section === 'invariants') {
|
|
88
|
+
goal.invariants.push(item);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// A scope entry is what it names in backticks, or the bullet itself when it is a bare path.
|
|
92
|
+
const paths = ticked.length ? ticked.filter(path) : path(item) && !/\s/.test(item) ? [item] : [];
|
|
93
|
+
goal[section].push(...paths.map(p => p.replace(/^\.\//, '')));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return goal;
|
|
97
|
+
}
|
|
98
|
+
/** Whether the description declares anything the deterministic checks can check. */
|
|
99
|
+
export function hasCheckableGoal(goal) {
|
|
100
|
+
return goal.scope.length > 0 || goal.outOfScope.length > 0 || goal.doneWhen.some(item => item.paths.length + item.symbols.length > 0);
|
|
101
|
+
}
|
|
102
|
+
/** Whether a file is covered by a declared path: a glob, a folder (with or without a trailing slash), or the file itself. */
|
|
103
|
+
function covers(pattern, file) {
|
|
104
|
+
if (/[*?[\]{}]/.test(pattern))
|
|
105
|
+
return micromatch.isMatch(file, pattern, { dot: true });
|
|
106
|
+
const folder = pattern.replace(/\/+$/, '');
|
|
107
|
+
return file === folder || file.startsWith(`${folder}/`);
|
|
108
|
+
}
|
|
109
|
+
/** Tests, snapshots, lockfiles, changelogs and release notes follow the code they belong to: never scope drift on their own. */
|
|
110
|
+
const EXEMPT = [/(^|\/)(__tests__|tests?|spec|__snapshots__)\//, /\.(test|spec)\.[A-Za-z0-9]+$/, /\.snap$/,
|
|
111
|
+
/(^|\/)(CHANGELOG|CHANGES|HISTORY|RELEASE_NOTES)(\.md)?$/i, /(^|\/)(releases|release-notes)\/[^/]+\.md$/, /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|poetry\.lock|go\.sum|Gemfile\.lock|composer\.lock|bun\.lockb)$/];
|
|
112
|
+
/**
|
|
113
|
+
* The goal's deterministic findings for a change. `changedLines` is the review's changed lines per file (generated files
|
|
114
|
+
* already left out); a deleted file has none and is read from the diff's headers, left out too when `isGenerated` says
|
|
115
|
+
* so. A named symbol must appear on an added or removed line.
|
|
116
|
+
*/
|
|
117
|
+
export function goalFailures(goal, changedLines, diff, isGenerated = () => false) {
|
|
118
|
+
const changed = {};
|
|
119
|
+
for (const [file, lines] of Object.entries(changedLines))
|
|
120
|
+
changed[file] = lines.size ? Math.min(...lines) : undefined;
|
|
121
|
+
for (const m of diff.matchAll(/^--- a\/(.+)\n\+\+\+ \/dev\/null$/gm))
|
|
122
|
+
if (!(m[1] in changed) && !isGenerated(m[1]))
|
|
123
|
+
changed[m[1]] = undefined;
|
|
124
|
+
const touched = diff.split('\n').filter(line => /^[+-]/.test(line) && !/^(\+\+\+|---) /.test(line)).map(line => line.slice(1)).join('\n');
|
|
125
|
+
const failures = [];
|
|
126
|
+
const files = Object.keys(changed).sort();
|
|
127
|
+
// A file a "Done when" item names is part of the goal, whatever Scope lists.
|
|
128
|
+
const inScope = [...goal.scope, ...goal.doneWhen.flatMap(item => item.paths)];
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
if (EXEMPT.some(pattern => pattern.test(file)))
|
|
131
|
+
continue;
|
|
132
|
+
const excluded = goal.outOfScope.find(pattern => covers(pattern, file));
|
|
133
|
+
const outside = !excluded && goal.scope.length > 0 && !inScope.some(pattern => covers(pattern, file));
|
|
134
|
+
if (!excluded && !outside)
|
|
135
|
+
continue;
|
|
136
|
+
failures.push({
|
|
137
|
+
id: 'goal-scope',
|
|
138
|
+
title: excluded ? `Changes ${file}, which the description declares out of scope (\`${excluded}\`)` : `Changes ${file}, outside the scope the description declares`,
|
|
139
|
+
details: excluded
|
|
140
|
+
? `The pull request's description lists \`${excluded}\` under "Out of scope", and this change edits ${file}.`
|
|
141
|
+
: `The pull request's description limits the change to ${goal.scope.map(s => `\`${s}\``).join(', ')}, and this change edits ${file}.`,
|
|
142
|
+
severity: 'high',
|
|
143
|
+
provenance: 'ai-drift',
|
|
144
|
+
files: [file],
|
|
145
|
+
...(changed[file] ? { line: changed[file] } : {}),
|
|
146
|
+
hint: 'Move this change to its own pull request, or add the path to the description\'s Scope if it belongs to this goal.',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
for (const item of goal.doneWhen) {
|
|
150
|
+
const missingPaths = item.paths.filter(p => !files.some(file => covers(p.replace(/^\.\//, ''), file) || file.endsWith(`/${p}`)));
|
|
151
|
+
const missingSymbols = item.symbols.filter(symbol => !new RegExp(`(^|[^\\w$])${symbol.replace(/[.$]/g, m => `\\${m}`)}([^\\w$]|$)`, 'm').test(touched));
|
|
152
|
+
// A file the change never touches is proof the item is not done; a symbol is not: an item can name one it keeps as is ("still the only check").
|
|
153
|
+
if (missingPaths.length)
|
|
154
|
+
failures.push({
|
|
155
|
+
id: 'goal-done-when',
|
|
156
|
+
title: `"Done when" names ${missingPaths.map(m => `\`${m}\``).join(', ')}, and the change never touches ${missingPaths.length === 1 ? 'it' : 'them'}`,
|
|
157
|
+
details: `The description's "Done when" says: ${item.text}`,
|
|
158
|
+
severity: 'high',
|
|
159
|
+
provenance: 'ai-drift',
|
|
160
|
+
files: [],
|
|
161
|
+
hint: 'Make the change the item asks for, or correct the item if the goal changed.',
|
|
162
|
+
});
|
|
163
|
+
if (missingSymbols.length)
|
|
164
|
+
failures.push({
|
|
165
|
+
id: 'goal-done-when',
|
|
166
|
+
title: `${missingSymbols.map(m => `\`${m}\``).join(', ')} named in "Done when", not changed by this pull request: check it's met`,
|
|
167
|
+
details: `The description's "Done when" says: ${item.text}`,
|
|
168
|
+
severity: 'medium',
|
|
169
|
+
provenance: 'ai-drift',
|
|
170
|
+
files: [],
|
|
171
|
+
advisory: true,
|
|
172
|
+
hint: 'If the item asks for a change, make it; if it states something the change keeps true, nothing to do.',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return failures;
|
|
176
|
+
}
|
|
177
|
+
/** The most declared items a judge is asked about: a goal is a few lines, and each item is a judge's turn. */
|
|
178
|
+
const MODEL_GOAL_MAX_ITEMS = 12;
|
|
179
|
+
/**
|
|
180
|
+
* What only a reader of the code can judge: the "Done when" items that name no file (prose, or a symbol) and the
|
|
181
|
+
* invariants. An item that names a file is proven or broken by goalFailures, without a model.
|
|
182
|
+
*/
|
|
183
|
+
export function modelGoalItems(goal) {
|
|
184
|
+
return [
|
|
185
|
+
...goal.doneWhen.filter(item => item.paths.length === 0).map(item => ({ kind: 'done', text: item.text })),
|
|
186
|
+
...goal.invariants.map(text => ({ kind: 'invariant', text })),
|
|
187
|
+
].slice(0, MODEL_GOAL_MAX_ITEMS);
|
|
188
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Config } from '../types/index.js';
|
|
2
|
+
import type { ReviewResult } from '../review/review.js';
|
|
3
|
+
import { type Exec } from '../review/reviewer/exec.js';
|
|
4
|
+
/** The open pull request's description for this branch, when the goal check is on; undefined otherwise or when it cannot be read. */
|
|
5
|
+
export declare function hookGoalDescription(cwd: string, config: Config, exec?: Exec): Promise<string | undefined>;
|
|
6
|
+
/** Records on the task's thread what the goal check did at a stop or a push; nothing when it had no description to check. */
|
|
7
|
+
export declare function recordGoal(cwd: string, moment: 'stop' | 'push', description: string | undefined, result: Pick<ReviewResult, 'goal' | 'findings'>): void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The goal at the stop hook and the push gate: the description of the branch's open pull request, read with one `gh`
|
|
3
|
+
* call, cached per branch and HEAD unless that commit was blocked by it (the author may have fixed the description, not
|
|
4
|
+
* the code), and skipped silently when there is none or gh cannot answer. Only the deterministic
|
|
5
|
+
* checks run there (goal/goal.ts): no model at a stop or a push. The same switch and floor as `rigour review --goal`.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { spawnSync } from 'child_process';
|
|
10
|
+
import { defaultExec } from '../review/reviewer/exec.js';
|
|
11
|
+
import { appendTaskEvent, threadsDir } from '../task/thread.js';
|
|
12
|
+
import { resolveSwitch } from '../switches.js';
|
|
13
|
+
/** A stop or a push waits at most this long for GitHub in all: the goal is worth a check, not a stall. */
|
|
14
|
+
const HOOK_GH_TIMEOUT_MS = 5_000;
|
|
15
|
+
/** The open pull request's description for this branch, when the goal check is on; undefined otherwise or when it cannot be read. */
|
|
16
|
+
export async function hookGoalDescription(cwd, config, exec = defaultExec) {
|
|
17
|
+
if (!resolveSwitch('goal', config).enabled)
|
|
18
|
+
return undefined;
|
|
19
|
+
const branch = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
20
|
+
const head = git(cwd, ['rev-parse', 'HEAD']);
|
|
21
|
+
const dir = threadsDir(cwd);
|
|
22
|
+
if (!branch || branch === 'HEAD' || !head || !dir)
|
|
23
|
+
return undefined;
|
|
24
|
+
const file = cacheFile(dir);
|
|
25
|
+
const cache = readCache(file);
|
|
26
|
+
if (cache[branch]?.head === head && !cache[branch].blocked)
|
|
27
|
+
return cache[branch].body ?? undefined;
|
|
28
|
+
const body = await openDescription(cwd, branch, config, exec);
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
31
|
+
fs.writeFileSync(file, JSON.stringify({ ...cache, [branch]: { head, body: body ?? null } }));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// a cache that cannot be written costs one more gh call next time, never the check
|
|
35
|
+
}
|
|
36
|
+
return body;
|
|
37
|
+
}
|
|
38
|
+
/** Records on the task's thread what the goal check did at a stop or a push; nothing when it had no description to check. */
|
|
39
|
+
export function recordGoal(cwd, moment, description, result) {
|
|
40
|
+
if (description === undefined)
|
|
41
|
+
return;
|
|
42
|
+
const blocks = result.findings.filter(f => f.id.startsWith('goal-')).length;
|
|
43
|
+
appendTaskEvent(cwd, { kind: 'goal', moment, declared: !!result.goal, blocks });
|
|
44
|
+
// A block's fix may be the description itself ("add the path to Scope"): never serve this commit's copy again.
|
|
45
|
+
const branch = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
46
|
+
const dir = threadsDir(cwd);
|
|
47
|
+
if (!blocks || !branch || !dir)
|
|
48
|
+
return;
|
|
49
|
+
const file = cacheFile(dir);
|
|
50
|
+
const cache = readCache(file);
|
|
51
|
+
if (!cache[branch])
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(file, JSON.stringify({ ...cache, [branch]: { ...cache[branch], blocked: true } }));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// unwritten, the next attempt serves the cached copy; never a failure of the stop or the push
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function cacheFile(threads) {
|
|
61
|
+
return path.join(path.dirname(threads), 'goal-cache.json');
|
|
62
|
+
}
|
|
63
|
+
async function openDescription(cwd, branch, config, exec) {
|
|
64
|
+
try {
|
|
65
|
+
// One budget for every call: the token, when an account is named, and the pull request.
|
|
66
|
+
const deadline = Date.now() + HOOK_GH_TIMEOUT_MS;
|
|
67
|
+
const account = config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT;
|
|
68
|
+
let env;
|
|
69
|
+
if (account && !process.env.GH_TOKEN) {
|
|
70
|
+
const token = await exec('gh', ['auth', 'token', '--user', account], { cwd, timeoutMs: HOOK_GH_TIMEOUT_MS });
|
|
71
|
+
if (token.exitCode !== 0 || !token.stdout.trim())
|
|
72
|
+
return undefined;
|
|
73
|
+
env = { GH_TOKEN: token.stdout.trim() };
|
|
74
|
+
}
|
|
75
|
+
const left = deadline - Date.now();
|
|
76
|
+
if (left <= 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
const view = await exec('gh', ['pr', 'view', branch, '--json', 'state,body'], { cwd, timeoutMs: left, ...(env ? { env } : {}) });
|
|
79
|
+
if (view.exitCode !== 0)
|
|
80
|
+
return undefined;
|
|
81
|
+
const pr = JSON.parse(view.stdout);
|
|
82
|
+
return pr?.state === 'OPEN' && typeof pr.body === 'string' ? pr.body : undefined;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function readCache(file) {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
91
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function git(cwd, args) {
|
|
98
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 5_000 });
|
|
99
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
100
|
+
}
|
|
@@ -20,6 +20,7 @@ import { diffFromGit } from '../review/git-diff.js';
|
|
|
20
20
|
import { branchBase } from '../gates/logic-drift-git-base.js';
|
|
21
21
|
import { branchFailures } from '../review/branch-checks.js';
|
|
22
22
|
import { describeLesson } from '../review-learning/team-lessons.js';
|
|
23
|
+
import { hookGoalDescription, recordGoal } from '../goal/hook.js';
|
|
23
24
|
export const STOP_MAX_ATTEMPTS = 3;
|
|
24
25
|
const MAX_LISTED = 8;
|
|
25
26
|
/** The branch since it left main; on main, what the session changed since its baseline (session-state.ts). */
|
|
@@ -36,7 +37,9 @@ function stopSource(cwd, sessionBaseline) {
|
|
|
36
37
|
export async function stopReview(cwd, config, attempt, sessionBaseline) {
|
|
37
38
|
const { source, against } = stopSource(cwd, sessionBaseline);
|
|
38
39
|
const diff = diffFromGit(cwd, source);
|
|
39
|
-
const
|
|
40
|
+
const goalDescription = await hookGoalDescription(cwd, config);
|
|
41
|
+
const result = await reviewChange({ cwd, config, diff, source, ...(goalDescription !== undefined ? { goalDescription } : {}) });
|
|
42
|
+
recordGoal(cwd, 'stop', goalDescription, result);
|
|
40
43
|
const branch = branchBase(cwd);
|
|
41
44
|
const whole = branch && !branch.onMain ? branchFailures(cwd, branch.base, branch.mainRef, config) : [];
|
|
42
45
|
// A check that could not run is never a pass; the attempt cap keeps a broken environment from looping forever.
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export { isControlFile, mergeBaseOf, readStateFile } from './review/trusted-stat
|
|
|
19
19
|
export { dismissFinding, dismissedKeys, findingKey, isProven, mustFix, quietSplit, DISMISSED_FILE } from './review/quiet.js';
|
|
20
20
|
export { loadLedger, ledgerProblems, runBacktest, backtestPassed, formatBacktest, LEDGER_PATH, type Ledger, type RoundResult } from './review/backtest.js';
|
|
21
21
|
export { scaffoldLedger } from './review/backtest-init.js';
|
|
22
|
+
export type { CiResult, FollowUp, PrOutcome } from './outcomes/outcome.js';
|
|
23
|
+
export { runOutcomes, type OutcomesRun } from './outcomes/run.js';
|
|
22
24
|
export { fixScope, type FixScope } from './review/fix-scope.js';
|
|
23
25
|
export { isGeneratedFile } from './review/generated-files.js';
|
|
24
26
|
export { costBucket, countUsage, doNotTrack, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
|
|
@@ -29,6 +31,9 @@ export { branchFailures, mergeConflicts, staleReferences } from './review/branch
|
|
|
29
31
|
export { runReviewer, reviewerBlocks, itemLine, defaultExec, githubToken, type ReviewerOptions, type ReviewerResult, type ReviewerOutcome, type OpenItem } from './review/reviewer.js';
|
|
30
32
|
export { startBackgroundReview, backgroundReview, reviewStatus, type ReviewStatus } from './review/reviewer/background.js';
|
|
31
33
|
export { dismissReviewerFinding, REVIEW_DISMISSALS, reviewerInputs } from './review/reviewer/context.js';
|
|
34
|
+
export { goalFailures, hasCheckableGoal, parseGoal, type DoneItem, type Goal } from './goal/goal.js';
|
|
35
|
+
export { resolveSwitch, saveUserSwitch, SWITCHES, type ResolvedSwitch, type SwitchName, type TeamSwitch } from './switches.js';
|
|
36
|
+
export { hookGoalDescription, recordGoal } from './goal/hook.js';
|
|
32
37
|
export { resolveReviewer, saveUserReviewer, type ResolvedReviewer, type RunChoice, type UserReviewerPatch } from './review/reviewer/settings.js';
|
|
33
38
|
export { reviewerAvailability } from './review/reviewer/adapters.js';
|
|
34
39
|
export { branchBase, type BranchBase } from './gates/logic-drift-git-base.js';
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export { isControlFile, mergeBaseOf, readStateFile } from './review/trusted-stat
|
|
|
19
19
|
export { dismissFinding, dismissedKeys, findingKey, isProven, mustFix, quietSplit, DISMISSED_FILE } from './review/quiet.js';
|
|
20
20
|
export { loadLedger, ledgerProblems, runBacktest, backtestPassed, formatBacktest, LEDGER_PATH } from './review/backtest.js';
|
|
21
21
|
export { scaffoldLedger } from './review/backtest-init.js';
|
|
22
|
+
export { runOutcomes } from './outcomes/run.js';
|
|
22
23
|
export { fixScope } from './review/fix-scope.js';
|
|
23
24
|
export { isGeneratedFile } from './review/generated-files.js';
|
|
24
25
|
export { costBucket, countUsage, doNotTrack, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
|
|
@@ -29,6 +30,9 @@ export { branchFailures, mergeConflicts, staleReferences } from './review/branch
|
|
|
29
30
|
export { runReviewer, reviewerBlocks, itemLine, defaultExec, githubToken } from './review/reviewer.js';
|
|
30
31
|
export { startBackgroundReview, backgroundReview, reviewStatus } from './review/reviewer/background.js';
|
|
31
32
|
export { dismissReviewerFinding, REVIEW_DISMISSALS, reviewerInputs } from './review/reviewer/context.js';
|
|
33
|
+
export { goalFailures, hasCheckableGoal, parseGoal } from './goal/goal.js';
|
|
34
|
+
export { resolveSwitch, saveUserSwitch, SWITCHES } from './switches.js';
|
|
35
|
+
export { hookGoalDescription, recordGoal } from './goal/hook.js';
|
|
32
36
|
export { resolveReviewer, saveUserReviewer } from './review/reviewer/settings.js';
|
|
33
37
|
export { reviewerAvailability } from './review/reviewer/adapters.js';
|
|
34
38
|
export { branchBase } from './gates/logic-drift-git-base.js';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type Exec } from '../review/reviewer/exec.js';
|
|
2
|
+
/**
|
|
3
|
+
* failure: a check that passed on the commit before the merge failed on the merge commit (the merge broke it; a check
|
|
4
|
+
* already failing on main says nothing about this pull request); success: none did; pending while one runs; none when
|
|
5
|
+
* there are no runs; unavailable when GitHub could not say.
|
|
6
|
+
*/
|
|
7
|
+
export type CiResult = 'success' | 'failure' | 'pending' | 'none' | 'unavailable';
|
|
8
|
+
export interface FollowUp {
|
|
9
|
+
sha: string;
|
|
10
|
+
at: string;
|
|
11
|
+
subject: string;
|
|
12
|
+
/** The pull request's files this commit touched. */
|
|
13
|
+
files: string[];
|
|
14
|
+
/** Its subject says it fixed something (the same words review-learning reads). */
|
|
15
|
+
fix: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface PrOutcome {
|
|
18
|
+
pr: number;
|
|
19
|
+
mergeSha: string;
|
|
20
|
+
mergedAt: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
/** Who wrote the pull request: independence between pull requests is judged by it. */
|
|
23
|
+
author: string;
|
|
24
|
+
/** What the merge changed on main: its first-parent diff. */
|
|
25
|
+
files: string[];
|
|
26
|
+
ci: CiResult;
|
|
27
|
+
followUps: FollowUp[];
|
|
28
|
+
/** The commit that reverted it, when one did. */
|
|
29
|
+
reverted?: {
|
|
30
|
+
sha: string;
|
|
31
|
+
subject: string;
|
|
32
|
+
};
|
|
33
|
+
/** The end of the window: later commits count up to here. */
|
|
34
|
+
windowEnd: string;
|
|
35
|
+
/** The window has closed and CI has an answer: the record never changes again. */
|
|
36
|
+
settled: boolean;
|
|
37
|
+
checkedAt: string;
|
|
38
|
+
}
|
|
39
|
+
export interface OutcomeOptions {
|
|
40
|
+
mainRef: string;
|
|
41
|
+
windowDays: number;
|
|
42
|
+
/** Only history before this time (a backtest); now when unset. */
|
|
43
|
+
until?: string;
|
|
44
|
+
/** The CI result for the merge commit against the commit before it on main (gh); `unavailable` when it cannot be read. */
|
|
45
|
+
ci: (sha: string, parent: string) => Promise<CiResult>;
|
|
46
|
+
/** How git is run (tests count the calls); `spawnSync` by default. */
|
|
47
|
+
git?: (args: string[]) => string;
|
|
48
|
+
}
|
|
49
|
+
type MergedRef = {
|
|
50
|
+
number: number;
|
|
51
|
+
mergeSha: string;
|
|
52
|
+
mergedAt: string;
|
|
53
|
+
branch: string;
|
|
54
|
+
author: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The CI result for a merge commit from all its check runs, every page (a big matrix runs more than 100: a failure on
|
|
58
|
+
* page two must not read as success). A run that failed is counted only when the same check did not fail on the commit
|
|
59
|
+
* before the merge, read only then: on a main branch where a check is often red, "any run failed" says nothing about the
|
|
60
|
+
* pull request. Any error is `unavailable`, never a throw.
|
|
61
|
+
*/
|
|
62
|
+
export declare function checkRunsCi(cwd: string, exec: Exec, env?: Record<string, string>): (sha: string, parent: string) => Promise<CiResult>;
|
|
63
|
+
interface OutcomeStore {
|
|
64
|
+
version: 1;
|
|
65
|
+
outcomes: Record<string, PrOutcome>;
|
|
66
|
+
}
|
|
67
|
+
export declare function readPrOutcomes(cwd: string): OutcomeStore;
|
|
68
|
+
/**
|
|
69
|
+
* Brings the store up to date for these merged pull requests: a settled record is kept as it is, anything else is read
|
|
70
|
+
* again. The branch's thread (when this machine has one) gets a `merge` event the first time and an `outcome` event when
|
|
71
|
+
* the record settles. Stops at `deadline` (ms since epoch) and says so: what was read is kept.
|
|
72
|
+
*/
|
|
73
|
+
export declare function updatePrOutcomes(cwd: string, prs: MergedRef[], options: OutcomeOptions & {
|
|
74
|
+
deadline?: number;
|
|
75
|
+
}): Promise<{
|
|
76
|
+
outcomes: PrOutcome[];
|
|
77
|
+
read: number;
|
|
78
|
+
stopped?: string;
|
|
79
|
+
}>;
|
|
80
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What happened after a pull request merged: the CI result on its merge commit, the later commits on main that touched
|
|
3
|
+
* its files within the window, and whether it was reverted. One record per merge commit, computed from git and one
|
|
4
|
+
* GitHub read, and kept in `.rigour/outcomes.json`. Once the window has closed and CI has an answer, the record is
|
|
5
|
+
* settled: it never changes and is never read from GitHub again.
|
|
6
|
+
*
|
|
7
|
+
* Follow-ups are read on main's first-parent history, by file, so a merge commit and a squash merge are read the same
|
|
8
|
+
* way. Nothing here changes a lesson: lessons read these records as evidence (review-learning).
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { FIX, revertsPr } from '../review-learning/outcomes.js';
|
|
14
|
+
import { GH_TIMEOUT_MS } from '../review/reviewer/exec.js';
|
|
15
|
+
import { appendBranchEvent } from '../task/thread.js';
|
|
16
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
/** An unsettled record read this recently is kept as it is: its window is open, and reading it again every run starves the older ones. */
|
|
18
|
+
const RECHECK_MS = 12 * 60 * 60 * 1000;
|
|
19
|
+
const OUTCOMES_FILE = path.join('.rigour', 'outcomes.json');
|
|
20
|
+
/** The outcome of one merged pull request as of now (or `until`), its follow-ups taken from main's log read once for the run. */
|
|
21
|
+
async function prOutcome(pr, options, run, log) {
|
|
22
|
+
const now = options.until ? Date.parse(options.until) : Date.now();
|
|
23
|
+
const windowEnd = new Date(Math.min(Date.parse(pr.mergedAt) + options.windowDays * DAY_MS, now)).toISOString();
|
|
24
|
+
const closed = Date.parse(pr.mergedAt) + options.windowDays * DAY_MS <= now;
|
|
25
|
+
const files = lines(run(['diff', '--name-only', `${pr.mergeSha}^1`, pr.mergeSha]));
|
|
26
|
+
const followUps = followUpsOf(log, pr, files, windowEnd);
|
|
27
|
+
const revert = followUps.find(f => revertsPr(f.subject, pr));
|
|
28
|
+
const ci = await options.ci(pr.mergeSha, `${pr.mergeSha}^1`);
|
|
29
|
+
return {
|
|
30
|
+
pr: pr.number, mergeSha: pr.mergeSha, mergedAt: pr.mergedAt, branch: pr.branch, author: pr.author, files, ci, followUps,
|
|
31
|
+
...(revert ? { reverted: { sha: revert.sha, subject: revert.subject } } : {}),
|
|
32
|
+
windowEnd,
|
|
33
|
+
settled: closed && (ci === 'success' || ci === 'failure' || ci === 'none'),
|
|
34
|
+
checkedAt: new Date(now).toISOString(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** The commits after the merge on main's first-parent history, up to the window's end, that touched any of the pull request's files. */
|
|
38
|
+
function followUpsOf(log, pr, files, windowEnd) {
|
|
39
|
+
const wanted = new Set(files);
|
|
40
|
+
const merged = log.findIndex(c => c.sha === pr.mergeSha);
|
|
41
|
+
// The merge itself is in the log (it is read from a day before the earliest merge); without it, time decides.
|
|
42
|
+
const after = merged >= 0 ? log.slice(merged + 1) : log.filter(c => c.at > pr.mergedAt);
|
|
43
|
+
return after.filter(c => c.at <= windowEnd).flatMap(c => {
|
|
44
|
+
const touched = c.files.filter(file => wanted.has(file));
|
|
45
|
+
return touched.length ? [{ sha: c.sha, at: c.at, subject: c.subject, files: touched, fix: FIX.test(c.subject) }] : [];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Main's first-parent history between two times, oldest first, with each commit's files: read once for every pull
|
|
50
|
+
* request a run brings up to date (their windows overlap). The files are matched in code, not passed to git: a large
|
|
51
|
+
* pull request's paths would pass the command-line limit (about 32 KB on Windows).
|
|
52
|
+
*/
|
|
53
|
+
function mainLog(run, mainRef, since, until) {
|
|
54
|
+
const out = run(['log', '--first-parent', '--name-only', '--format=%x00%H%x09%cI%x09%s', `--since=${since}`, `--until=${until}`, mainRef]);
|
|
55
|
+
return out.split('\0').filter(Boolean).flatMap(block => {
|
|
56
|
+
const [header, ...rest] = block.split('\n');
|
|
57
|
+
const [sha, at, ...subject] = header.split('\t');
|
|
58
|
+
return sha && at ? [{ sha, at, subject: subject.join('\t'), files: rest.map(line => line.trim()).filter(Boolean) }] : [];
|
|
59
|
+
}).reverse();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The CI result for a merge commit from all its check runs, every page (a big matrix runs more than 100: a failure on
|
|
63
|
+
* page two must not read as success). A run that failed is counted only when the same check did not fail on the commit
|
|
64
|
+
* before the merge, read only then: on a main branch where a check is often red, "any run failed" says nothing about the
|
|
65
|
+
* pull request. Any error is `unavailable`, never a throw.
|
|
66
|
+
*/
|
|
67
|
+
export function checkRunsCi(cwd, exec, env) {
|
|
68
|
+
const runs = async (sha) => {
|
|
69
|
+
try {
|
|
70
|
+
const read = await exec('gh', ['api', '--paginate', `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`, '--jq', '[.check_runs[] | {name, status, conclusion}]'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
|
|
71
|
+
if (read.exitCode !== 0)
|
|
72
|
+
return undefined;
|
|
73
|
+
// One array per page; unlike parseJsonArrays, output that does not parse is unavailable here, never an empty "none" that would settle.
|
|
74
|
+
return JSON.parse(`[${read.stdout.trim().replace(/\]\s*\[/g, '],[')}]`).flat();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return async (sha, parent) => {
|
|
81
|
+
const merge = await runs(sha);
|
|
82
|
+
if (!merge)
|
|
83
|
+
return 'unavailable';
|
|
84
|
+
const failed = merge.filter(failedRun);
|
|
85
|
+
if (failed.length === 0)
|
|
86
|
+
return ciFrom(merge);
|
|
87
|
+
const before = await runs(parent);
|
|
88
|
+
if (!before)
|
|
89
|
+
return 'unavailable';
|
|
90
|
+
const redBefore = new Set(before.filter(failedRun).map(r => r.name));
|
|
91
|
+
return failed.some(r => !redBefore.has(r.name)) ? 'failure' : ciFrom(merge.filter(r => !failedRun(r)));
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function failedRun(run) {
|
|
95
|
+
return run.conclusion === 'failure' || run.conclusion === 'timed_out';
|
|
96
|
+
}
|
|
97
|
+
/** With no new failure: a run still going keeps it pending; cancelled and skipped runs say nothing. */
|
|
98
|
+
function ciFrom(runs) {
|
|
99
|
+
if (!Array.isArray(runs) || runs.length === 0)
|
|
100
|
+
return 'none';
|
|
101
|
+
if (runs.some(r => r.status !== 'completed'))
|
|
102
|
+
return 'pending';
|
|
103
|
+
return runs.some(r => r.conclusion === 'success') ? 'success' : 'none';
|
|
104
|
+
}
|
|
105
|
+
export function readPrOutcomes(cwd) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(cwd, OUTCOMES_FILE), 'utf8'));
|
|
108
|
+
return parsed?.version === 1 && parsed.outcomes && typeof parsed.outcomes === 'object' ? parsed : { version: 1, outcomes: {} };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { version: 1, outcomes: {} };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Brings the store up to date for these merged pull requests: a settled record is kept as it is, anything else is read
|
|
116
|
+
* again. The branch's thread (when this machine has one) gets a `merge` event the first time and an `outcome` event when
|
|
117
|
+
* the record settles. Stops at `deadline` (ms since epoch) and says so: what was read is kept.
|
|
118
|
+
*/
|
|
119
|
+
export async function updatePrOutcomes(cwd, prs, options) {
|
|
120
|
+
const store = readPrOutcomes(cwd);
|
|
121
|
+
const result = [];
|
|
122
|
+
const run = options.git ?? ((args) => git(cwd, args));
|
|
123
|
+
let read = 0;
|
|
124
|
+
let stopped;
|
|
125
|
+
const now = options.until ? Date.parse(options.until) : Date.now();
|
|
126
|
+
const fresh = (outcome) => !!outcome && (outcome.settled || now - Date.parse(outcome.checkedAt) < RECHECK_MS);
|
|
127
|
+
// Oldest merge first: those are the ones whose windows close, so a run cut short by the deadline still moves records
|
|
128
|
+
// towards settled instead of reading the newest, still-open ones again and again.
|
|
129
|
+
const pending = prs.filter(pr => pr.mergeSha && !fresh(store.outcomes[pr.mergeSha])).sort((a, b) => (a.mergedAt < b.mergedAt ? -1 : 1));
|
|
130
|
+
// Main's log once, across every pull request to read: from a day before the earliest merge (so each merge commit is in
|
|
131
|
+
// it) to the latest window's end, bounded at both ends.
|
|
132
|
+
const log = pending.length ? mainLog(run, options.mainRef, new Date(Math.min(...pending.map(pr => Date.parse(pr.mergedAt))) - DAY_MS).toISOString(), new Date(Math.min(now, Math.max(...pending.map(pr => Date.parse(pr.mergedAt))) + options.windowDays * DAY_MS)).toISOString()) : [];
|
|
133
|
+
for (const pr of pending) {
|
|
134
|
+
if (options.deadline !== undefined && Date.now() > options.deadline) {
|
|
135
|
+
stopped = `read deadline reached after ${read} pull request(s); run again to read the rest`;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
const known = store.outcomes[pr.mergeSha];
|
|
139
|
+
const outcome = await prOutcome(pr, options, run, log);
|
|
140
|
+
read++;
|
|
141
|
+
if (!known)
|
|
142
|
+
appendBranchEvent(cwd, pr.branch, { kind: 'merge', pr: pr.number, merge_sha: pr.mergeSha, merged_at: pr.mergedAt });
|
|
143
|
+
if (outcome.settled)
|
|
144
|
+
appendBranchEvent(cwd, pr.branch, { kind: 'outcome', pr: pr.number, ci: outcome.ci, follow_ups: outcome.followUps.length, fixes: outcome.followUps.filter(f => f.fix).length, reverted: !!outcome.reverted });
|
|
145
|
+
store.outcomes[pr.mergeSha] = outcome;
|
|
146
|
+
}
|
|
147
|
+
// In the order asked for: every record known for these pull requests, read now or kept.
|
|
148
|
+
for (const pr of prs)
|
|
149
|
+
if (pr.mergeSha && store.outcomes[pr.mergeSha])
|
|
150
|
+
result.push(store.outcomes[pr.mergeSha]);
|
|
151
|
+
fs.mkdirSync(path.join(cwd, '.rigour'), { recursive: true });
|
|
152
|
+
fs.writeFileSync(path.join(cwd, OUTCOMES_FILE), JSON.stringify(store, null, 2) + '\n');
|
|
153
|
+
return { outcomes: result, read, ...(stopped ? { stopped } : {}) };
|
|
154
|
+
}
|
|
155
|
+
function git(cwd, args) {
|
|
156
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 60_000 });
|
|
157
|
+
return result.status === 0 ? result.stdout : '';
|
|
158
|
+
}
|
|
159
|
+
function lines(text) {
|
|
160
|
+
return text.split('\n').map(line => line.trim()).filter(Boolean);
|
|
161
|
+
}
|