co-maintainer 0.5.0 → 0.5.1-beta.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/LICENSE +21 -21
- package/README.md +5 -3
- package/dist/package.json +1 -1
- package/dist/src/ai/anthropic.d.ts +55 -0
- package/dist/src/ai/anthropic.js +262 -0
- package/dist/src/ai/batch.d.ts +2 -2
- package/dist/src/ai/batch.js +1 -1
- package/dist/src/ai/estimate.d.ts +4 -0
- package/dist/src/ai/estimate.js +17 -2
- package/dist/src/ai/mermaid_loop.js +7 -4
- package/dist/src/ai/openai.d.ts +45 -0
- package/dist/src/ai/openai.js +206 -0
- package/dist/src/ai/opencode.d.ts +10 -0
- package/dist/src/ai/opencode.js +76 -0
- package/dist/src/ai/openrouter.d.ts +23 -7
- package/dist/src/ai/openrouter.js +39 -23
- package/dist/src/ai/pricing.js +1 -1
- package/dist/src/ai/provider.d.ts +9 -0
- package/dist/src/ai/provider.js +42 -3
- package/dist/src/ai/verify.d.ts +9 -1
- package/dist/src/ai/verify.js +81 -3
- package/dist/src/cli/args.d.ts +1 -2
- package/dist/src/cli/args.js +38 -26
- package/dist/src/cli/commands/config.d.ts +1 -1
- package/dist/src/cli/commands/config.js +10 -4
- package/dist/src/cli/commands/probe.js +3 -11
- package/dist/src/cli/commands/registry.d.ts +1 -9
- package/dist/src/cli/commands/registry.js +75 -10
- package/dist/src/cli/commands/review.js +18 -13
- package/dist/src/cli/commands/rollback.d.ts +1 -0
- package/dist/src/cli/commands/rollback.js +42 -0
- package/dist/src/cli/commands/serve.js +2 -3
- package/dist/src/cli/commands/set.js +71 -7
- package/dist/src/cli/commands/view.js +10 -9
- package/dist/src/cli/error.d.ts +4 -4
- package/dist/src/cli/error.js +4 -4
- package/dist/src/cli/main.d.ts +4 -4
- package/dist/src/cli/main.js +13 -8
- package/dist/src/cli/prompt.js +1 -1
- package/dist/src/cli/review_args.d.ts +9 -3
- package/dist/src/cli/review_args.js +28 -5
- package/dist/src/cli/review_github.d.ts +31 -0
- package/dist/src/cli/review_github.js +128 -0
- package/dist/src/cli/review_result.d.ts +27 -16
- package/dist/src/cli/review_result.js +7 -7
- package/dist/src/config.d.ts +28 -6
- package/dist/src/config.js +15 -1
- package/dist/src/git/default_branch.d.ts +1 -1
- package/dist/src/git/default_branch.js +1 -1
- package/dist/src/github/app.js +2 -2
- package/dist/src/github/app_manifest.d.ts +1 -7
- package/dist/src/github/app_manifest.js +2 -7
- package/dist/src/github/client.d.ts +4 -1
- package/dist/src/github/client.js +14 -2
- package/dist/src/github/gh.js +10 -7
- package/dist/src/github/pat.js +4 -4
- package/dist/src/github/permissions.d.ts +36 -0
- package/dist/src/github/permissions.js +334 -0
- package/dist/src/knowledge/facts.js +1 -1
- package/dist/src/knowledge/guide.d.ts +3 -3
- package/dist/src/knowledge/guide.js +5 -5
- package/dist/src/knowledge/probe.js +1 -2
- package/dist/src/knowledge/sections.d.ts +3 -3
- package/dist/src/knowledge/sections.js +3 -3
- package/dist/src/knowledge/skill.d.ts +1 -1
- package/dist/src/knowledge/skill.js +4 -4
- package/dist/src/knowledge/synthesis.d.ts +1 -1
- package/dist/src/knowledge/synthesis.js +5 -5
- package/dist/src/knowledge/types.d.ts +1 -1
- package/dist/src/local/codegraph_exclude.d.ts +1 -1
- package/dist/src/local/codegraph_exclude.js +1 -1
- package/dist/src/local/codegraph_prepare.js +1 -1
- package/dist/src/local/git_ops.d.ts +1 -1
- package/dist/src/local/git_ops.js +1 -1
- package/dist/src/local/git_parse.d.ts +2 -2
- package/dist/src/local/git_parse.js +2 -2
- package/dist/src/local/git_revision.d.ts +1 -1
- package/dist/src/local/git_revision.js +1 -1
- package/dist/src/local/git_untracked.d.ts +1 -1
- package/dist/src/local/git_untracked.js +1 -1
- package/dist/src/local/review_local.js +17 -14
- package/dist/src/local/review_lock.d.ts +1 -1
- package/dist/src/local/review_lock.js +1 -1
- package/dist/src/pr/checkout.js +2 -2
- package/dist/src/pr/findings.js +1 -1
- package/dist/src/pr/findings_json.d.ts +2 -2
- package/dist/src/pr/findings_json.js +1 -1
- package/dist/src/pr/github_revision.js +1 -1
- package/dist/src/pr/review_copy.d.ts +2 -2
- package/dist/src/pr/review_copy.js +2 -2
- package/dist/src/pr/reviewer.d.ts +6 -0
- package/dist/src/pr/reviewer.js +49 -39
- package/dist/src/remote/client.d.ts +4 -0
- package/dist/src/remote/client.js +84 -23
- package/dist/src/remote/fixtures.js +1 -1
- package/dist/src/remote/http.d.ts +10 -1
- package/dist/src/remote/http.js +15 -3
- package/dist/src/remote/schema.d.ts +3 -3
- package/dist/src/remote/schema.js +3 -3
- package/dist/src/remote/server/body.d.ts +1 -1
- package/dist/src/remote/server/body.js +1 -1
- package/dist/src/remote/server/byok_keys.d.ts +15 -0
- package/dist/src/remote/server/byok_keys.js +40 -0
- package/dist/src/remote/server/guides.js +2 -2
- package/dist/src/remote/server/routes.js +8 -1
- package/dist/src/remote/validate.js +15 -1
- package/dist/src/review/blocking.d.ts +2 -2
- package/dist/src/review/blocking.js +2 -2
- package/dist/src/review/carry_over.d.ts +2 -2
- package/dist/src/review/carry_over.js +2 -2
- package/dist/src/review/guides.js +1 -1
- package/dist/src/server/api/remote_tokens.js +2 -0
- package/dist/src/server/api/repos.js +51 -0
- package/dist/src/server/api/settings.js +10 -0
- package/dist/src/server/errors.d.ts +1 -1
- package/dist/src/server/http.js +1 -1
- package/dist/src/server/pages/activity.d.ts +2 -0
- package/dist/src/server/pages/activity.js +18 -4
- package/dist/src/server/pages/add_repo.js +61 -1
- package/dist/src/server/pages/analytics.js +18 -8
- package/dist/src/server/pages/cost.d.ts +21 -0
- package/dist/src/server/pages/cost.js +72 -0
- package/dist/src/server/pages/pr.js +5 -4
- package/dist/src/server/pages/repo.js +3 -2
- package/dist/src/server/pages/repo_prs.js +3 -2
- package/dist/src/server/pages/repo_remote.js +3 -2
- package/dist/src/server/pages/repo_settings.js +326 -12
- package/dist/src/server/pages/router.js +3 -2
- package/dist/src/server/pages/settings.js +50 -6
- package/dist/src/server/pages/styles.d.ts +1 -1
- package/dist/src/server/pages/styles.js +1 -1
- package/dist/src/services/credentials.js +8 -11
- package/dist/src/services/dashboard.d.ts +22 -9
- package/dist/src/services/dashboard.js +16 -2
- package/dist/src/services/probe.js +1 -1
- package/dist/src/services/remote_review.js +142 -35
- package/dist/src/services/remote_review_abort.d.ts +5 -1
- package/dist/src/services/remote_review_abort.js +9 -1
- package/dist/src/services/review.d.ts +1 -1
- package/dist/src/services/review.js +19 -5
- package/dist/src/services/review_policy.d.ts +116 -0
- package/dist/src/services/review_policy.js +489 -0
- package/dist/src/services/setup.d.ts +4 -3
- package/dist/src/services/setup.js +18 -16
- package/dist/src/services/setup_checklist.js +2 -2
- package/dist/src/services/webhook.d.ts +23 -0
- package/dist/src/services/webhook.js +141 -34
- package/dist/src/store/app_db.d.ts +5 -0
- package/dist/src/store/app_db.js +69 -13
- package/dist/src/store/backup.d.ts +31 -0
- package/dist/src/store/backup.js +158 -0
- package/dist/src/store/migrations.d.ts +1 -1
- package/dist/src/store/migrations.js +21 -1
- package/dist/src/store/remote_review_inputs.d.ts +3 -0
- package/dist/src/store/remote_review_inputs.js +3 -3
- package/dist/src/store/remote_tokens.d.ts +1 -1
- package/dist/src/store/remote_tokens.js +1 -1
- package/dist/src/store/repos.d.ts +2 -2
- package/dist/src/store/repos.js +1 -1
- package/dist/src/store/reviews.d.ts +21 -13
- package/dist/src/store/reviews.js +60 -15
- package/dist/src/store/rows.d.ts +10 -0
- package/dist/src/tools/codegraph.d.ts +4 -4
- package/dist/src/tools/codegraph.js +5 -5
- package/dist/src/tools/codegraph_exec.d.ts +1 -1
- package/dist/src/tools/codegraph_exec.js +1 -1
- package/dist/src/types.d.ts +12 -6
- package/dist/src/util/cost.d.ts +52 -0
- package/dist/src/util/cost.js +70 -0
- package/dist/src/util/log.d.ts +1 -1
- package/dist/src/util/log.js +2 -2
- package/dist/src/util/redact.js +2 -0
- package/dist/src/util/run_summary.d.ts +7 -2
- package/dist/src/util/run_summary.js +28 -2
- package/dist/src/util/time.d.ts +1 -1
- package/dist/src/util/time.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/** The review policy engine.
|
|
2
|
+
*
|
|
3
|
+
* A policy is an ordered list of rules. The first rule whose conditions all
|
|
4
|
+
* hold decides what happens to a pull request, and when none holds the policy's
|
|
5
|
+
* default action does. This file is pure: no database, no GitHub, no clock, so
|
|
6
|
+
* every branch of it is a table test. `webhook.ts` gathers the facts and acts on
|
|
7
|
+
* the answer. */
|
|
8
|
+
export const ASSOCIATIONS = [
|
|
9
|
+
"OWNER",
|
|
10
|
+
"MEMBER",
|
|
11
|
+
"COLLABORATOR",
|
|
12
|
+
"CONTRIBUTOR",
|
|
13
|
+
"FIRST_TIME_CONTRIBUTOR",
|
|
14
|
+
"FIRST_TIMER",
|
|
15
|
+
"NONE",
|
|
16
|
+
];
|
|
17
|
+
export const ACTIONS = ["review", "on-request", "skip"];
|
|
18
|
+
const TRUSTED = ["OWNER", "MEMBER", "COLLABORATOR"];
|
|
19
|
+
export const DEFAULT_POLICY_SETTINGS = {
|
|
20
|
+
requestLabel: "co-maintainer:review",
|
|
21
|
+
requestCommand: "/co-maintainer review",
|
|
22
|
+
requesters: TRUSTED,
|
|
23
|
+
approvalScope: "head",
|
|
24
|
+
};
|
|
25
|
+
const SKIP_DRAFTS = {
|
|
26
|
+
name: "draft",
|
|
27
|
+
when: { draft: true },
|
|
28
|
+
action: "skip",
|
|
29
|
+
};
|
|
30
|
+
const SKIP_BOTS = {
|
|
31
|
+
name: "bot author",
|
|
32
|
+
when: { bot: true },
|
|
33
|
+
action: "skip",
|
|
34
|
+
};
|
|
35
|
+
export const TEMPLATES = {
|
|
36
|
+
everyone: { rules: [SKIP_DRAFTS, SKIP_BOTS], default: "review" },
|
|
37
|
+
"trusted-auto": {
|
|
38
|
+
rules: [
|
|
39
|
+
SKIP_DRAFTS,
|
|
40
|
+
SKIP_BOTS,
|
|
41
|
+
{
|
|
42
|
+
name: "trusted author",
|
|
43
|
+
when: { association: TRUSTED },
|
|
44
|
+
action: "review",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
default: "on-request",
|
|
48
|
+
},
|
|
49
|
+
"on-request-only": { rules: [], default: "on-request" },
|
|
50
|
+
};
|
|
51
|
+
export const TEMPLATE_NAMES = Object.keys(TEMPLATES);
|
|
52
|
+
export function withDefaults(input) {
|
|
53
|
+
return {
|
|
54
|
+
rules: [],
|
|
55
|
+
default: "review",
|
|
56
|
+
...DEFAULT_POLICY_SETTINGS,
|
|
57
|
+
...input,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** The policy the three old per repository switches amount to, so a repository
|
|
61
|
+
* nobody has given a policy keeps behaving exactly as before. Switched off
|
|
62
|
+
* means everything is skipped, and the drafts and bots switches only matter
|
|
63
|
+
* while it is on. */
|
|
64
|
+
export function legacyPolicy(settings) {
|
|
65
|
+
if (settings.auto_review !== 1) {
|
|
66
|
+
return withDefaults({
|
|
67
|
+
rules: [],
|
|
68
|
+
default: "skip",
|
|
69
|
+
defaultReason: "auto-review-off",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const rules = [];
|
|
73
|
+
if (settings.skip_drafts === 1) {
|
|
74
|
+
rules.push({ ...SKIP_DRAFTS, reason: "draft" });
|
|
75
|
+
}
|
|
76
|
+
if (settings.skip_bots === 1) {
|
|
77
|
+
rules.push({ ...SKIP_BOTS, reason: "bot-author" });
|
|
78
|
+
}
|
|
79
|
+
return withDefaults({ rules, default: "review" });
|
|
80
|
+
}
|
|
81
|
+
function whenHolds(when, facts) {
|
|
82
|
+
if (when.association) {
|
|
83
|
+
if (facts.association === undefined ||
|
|
84
|
+
!when.association.includes(facts.association)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (when.fork !== undefined && facts.fork !== when.fork)
|
|
89
|
+
return false;
|
|
90
|
+
if (when.draft !== undefined && facts.draft !== when.draft)
|
|
91
|
+
return false;
|
|
92
|
+
if (when.bot !== undefined && facts.bot !== when.bot)
|
|
93
|
+
return false;
|
|
94
|
+
if (when.labels) {
|
|
95
|
+
const have = new Set((facts.labels ?? []).map((l) => l.toLowerCase()));
|
|
96
|
+
if (!when.labels.some((label) => have.has(label.toLowerCase()))) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (when.targetBranch) {
|
|
101
|
+
if (facts.targetBranch === undefined ||
|
|
102
|
+
!when.targetBranch.includes(facts.targetBranch)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (when.changedLines) {
|
|
107
|
+
const lines = facts.changedLines;
|
|
108
|
+
if (lines === undefined)
|
|
109
|
+
return false;
|
|
110
|
+
const { min, max } = when.changedLines;
|
|
111
|
+
if (min !== undefined && lines < min)
|
|
112
|
+
return false;
|
|
113
|
+
if (max !== undefined && lines > max)
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
const OUTCOME_TEXT = {
|
|
119
|
+
skip: "skipped by policy.",
|
|
120
|
+
"on-request": "waiting for a maintainer request.",
|
|
121
|
+
review: "reviewing.",
|
|
122
|
+
};
|
|
123
|
+
export function evaluatePolicy(policy, facts) {
|
|
124
|
+
for (let index = 0; index < policy.rules.length; index++) {
|
|
125
|
+
const rule = policy.rules[index];
|
|
126
|
+
if (!whenHolds(rule.when, facts))
|
|
127
|
+
continue;
|
|
128
|
+
const label = `Rule ${index + 1}${rule.name ? ` (${rule.name})` : ""}`;
|
|
129
|
+
return {
|
|
130
|
+
action: rule.action,
|
|
131
|
+
rule: index + 1,
|
|
132
|
+
reason: rule.reason ?? `${label}: ${OUTCOME_TEXT[rule.action]}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
action: policy.default,
|
|
137
|
+
rule: null,
|
|
138
|
+
reason: policy.defaultReason ?? `Default rule: ${OUTCOME_TEXT[policy.default]}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Whether a pull request that a policy leaves waiting for a request may be
|
|
142
|
+
* reviewed without a new one. Only the `pull-request` scope carries an earlier
|
|
143
|
+
* request forward, and only once something was actually reviewed or queued. */
|
|
144
|
+
export function carriesEarlierRequest(policy, hasEarlierReview) {
|
|
145
|
+
return policy.approvalScope === "pull-request" && hasEarlierReview;
|
|
146
|
+
}
|
|
147
|
+
/** Whether `body` starts with the request command as a whole word. */
|
|
148
|
+
export function isRequestCommand(body, command) {
|
|
149
|
+
const text = body.trim().toLowerCase();
|
|
150
|
+
const wanted = command.trim().toLowerCase();
|
|
151
|
+
if (!wanted || !text.startsWith(wanted))
|
|
152
|
+
return false;
|
|
153
|
+
const next = text.charAt(wanted.length);
|
|
154
|
+
return next === "" || /\s/.test(next);
|
|
155
|
+
}
|
|
156
|
+
function isRecord(value) {
|
|
157
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
158
|
+
}
|
|
159
|
+
function stringList(value, path) {
|
|
160
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
161
|
+
return { ok: false, problem: `${path} must be a list of strings` };
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, value: value };
|
|
164
|
+
}
|
|
165
|
+
function associations(value, path) {
|
|
166
|
+
const list = stringList(value, path);
|
|
167
|
+
if (!list.ok)
|
|
168
|
+
return list;
|
|
169
|
+
const bad = list.value.find((item) => !ASSOCIATIONS.includes(item));
|
|
170
|
+
if (bad !== undefined) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
problem: `${path} has "${bad}", expected one of ${ASSOCIATIONS.join(", ")}`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, value: list.value };
|
|
177
|
+
}
|
|
178
|
+
function unknownKey(value, allowed, path) {
|
|
179
|
+
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
180
|
+
return extra === undefined ? undefined : `${path}: unknown field "${extra}"`;
|
|
181
|
+
}
|
|
182
|
+
function parseWhen(value, path) {
|
|
183
|
+
if (!isRecord(value))
|
|
184
|
+
return { ok: false, problem: `${path} must be an object` };
|
|
185
|
+
const extra = unknownKey(value, [
|
|
186
|
+
"association",
|
|
187
|
+
"fork",
|
|
188
|
+
"draft",
|
|
189
|
+
"bot",
|
|
190
|
+
"labels",
|
|
191
|
+
"targetBranch",
|
|
192
|
+
"changedLines",
|
|
193
|
+
], path);
|
|
194
|
+
if (extra)
|
|
195
|
+
return { ok: false, problem: extra };
|
|
196
|
+
const when = {};
|
|
197
|
+
if (value.association !== undefined) {
|
|
198
|
+
const parsed = associations(value.association, `${path}.association`);
|
|
199
|
+
if (!parsed.ok)
|
|
200
|
+
return parsed;
|
|
201
|
+
when.association = parsed.value;
|
|
202
|
+
}
|
|
203
|
+
for (const key of ["fork", "draft", "bot"]) {
|
|
204
|
+
if (value[key] === undefined)
|
|
205
|
+
continue;
|
|
206
|
+
if (typeof value[key] !== "boolean") {
|
|
207
|
+
return { ok: false, problem: `${path}.${key} must be true or false` };
|
|
208
|
+
}
|
|
209
|
+
when[key] = value[key];
|
|
210
|
+
}
|
|
211
|
+
for (const key of ["labels", "targetBranch"]) {
|
|
212
|
+
if (value[key] === undefined)
|
|
213
|
+
continue;
|
|
214
|
+
const parsed = stringList(value[key], `${path}.${key}`);
|
|
215
|
+
if (!parsed.ok)
|
|
216
|
+
return parsed;
|
|
217
|
+
when[key] = parsed.value;
|
|
218
|
+
}
|
|
219
|
+
if (value.changedLines !== undefined) {
|
|
220
|
+
const lines = value.changedLines;
|
|
221
|
+
if (!isRecord(lines)) {
|
|
222
|
+
return { ok: false, problem: `${path}.changedLines must be an object` };
|
|
223
|
+
}
|
|
224
|
+
const bad = unknownKey(lines, ["min", "max"], `${path}.changedLines`);
|
|
225
|
+
if (bad)
|
|
226
|
+
return { ok: false, problem: bad };
|
|
227
|
+
for (const key of ["min", "max"]) {
|
|
228
|
+
const bound = lines[key];
|
|
229
|
+
if (bound !== undefined &&
|
|
230
|
+
(!Number.isInteger(bound) || Number(bound) < 0)) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
problem: `${path}.changedLines.${key} must be a whole number of 0 or more`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
when.changedLines = lines;
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, value: when };
|
|
240
|
+
}
|
|
241
|
+
function parseAction(value, path) {
|
|
242
|
+
if (!ACTIONS.includes(value)) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
problem: `${path} must be one of ${ACTIONS.join(", ")}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return { ok: true, value: value };
|
|
249
|
+
}
|
|
250
|
+
/** Turns stored or user supplied policy data into a policy, or says what is
|
|
251
|
+
* wrong with it. A string names a template, an object is a full policy where
|
|
252
|
+
* every setting but the rules can be left out. */
|
|
253
|
+
export function parsePolicy(value) {
|
|
254
|
+
if (typeof value === "string") {
|
|
255
|
+
const template = TEMPLATES[value];
|
|
256
|
+
if (!template) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
problem: `unknown template "${value}", expected one of ${TEMPLATE_NAMES.join(", ")} or a policy object`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, value: withDefaults(template) };
|
|
263
|
+
}
|
|
264
|
+
if (!isRecord(value)) {
|
|
265
|
+
return { ok: false, problem: "a policy is a template name or an object" };
|
|
266
|
+
}
|
|
267
|
+
const extra = unknownKey(value, [
|
|
268
|
+
"rules",
|
|
269
|
+
"default",
|
|
270
|
+
"requestLabel",
|
|
271
|
+
"requestCommand",
|
|
272
|
+
"requesters",
|
|
273
|
+
"approvalScope",
|
|
274
|
+
"maxRounds",
|
|
275
|
+
], "policy");
|
|
276
|
+
if (extra)
|
|
277
|
+
return { ok: false, problem: extra };
|
|
278
|
+
const input = {};
|
|
279
|
+
if (value.rules !== undefined) {
|
|
280
|
+
if (!Array.isArray(value.rules)) {
|
|
281
|
+
return { ok: false, problem: "rules must be a list" };
|
|
282
|
+
}
|
|
283
|
+
const rules = [];
|
|
284
|
+
for (let index = 0; index < value.rules.length; index++) {
|
|
285
|
+
const path = `rules[${index}]`;
|
|
286
|
+
const rule = value.rules[index];
|
|
287
|
+
if (!isRecord(rule)) {
|
|
288
|
+
return { ok: false, problem: `${path} must be an object` };
|
|
289
|
+
}
|
|
290
|
+
const bad = unknownKey(rule, ["name", "when", "action"], path);
|
|
291
|
+
if (bad)
|
|
292
|
+
return { ok: false, problem: bad };
|
|
293
|
+
const when = parseWhen(rule.when ?? {}, `${path}.when`);
|
|
294
|
+
if (!when.ok)
|
|
295
|
+
return when;
|
|
296
|
+
const action = parseAction(rule.action, `${path}.action`);
|
|
297
|
+
if (!action.ok)
|
|
298
|
+
return action;
|
|
299
|
+
if (rule.name !== undefined && typeof rule.name !== "string") {
|
|
300
|
+
return { ok: false, problem: `${path}.name must be text` };
|
|
301
|
+
}
|
|
302
|
+
rules.push({ name: rule.name, when: when.value, action: action.value });
|
|
303
|
+
}
|
|
304
|
+
input.rules = rules;
|
|
305
|
+
}
|
|
306
|
+
if (value.default !== undefined) {
|
|
307
|
+
const action = parseAction(value.default, "default");
|
|
308
|
+
if (!action.ok)
|
|
309
|
+
return action;
|
|
310
|
+
input.default = action.value;
|
|
311
|
+
}
|
|
312
|
+
for (const key of ["requestLabel", "requestCommand"]) {
|
|
313
|
+
if (value[key] === undefined)
|
|
314
|
+
continue;
|
|
315
|
+
if (typeof value[key] !== "string" || !value[key].trim()) {
|
|
316
|
+
return { ok: false, problem: `${key} must be non-empty text` };
|
|
317
|
+
}
|
|
318
|
+
input[key] = value[key].trim();
|
|
319
|
+
}
|
|
320
|
+
if (value.requesters !== undefined) {
|
|
321
|
+
const parsed = associations(value.requesters, "requesters");
|
|
322
|
+
if (!parsed.ok)
|
|
323
|
+
return parsed;
|
|
324
|
+
input.requesters = parsed.value;
|
|
325
|
+
}
|
|
326
|
+
if (value.approvalScope !== undefined) {
|
|
327
|
+
if (value.approvalScope !== "head" &&
|
|
328
|
+
value.approvalScope !== "pull-request") {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
problem: "approvalScope must be head or pull-request",
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
input.approvalScope = value.approvalScope;
|
|
335
|
+
}
|
|
336
|
+
if (value.maxRounds !== undefined) {
|
|
337
|
+
if (!Number.isInteger(value.maxRounds) || Number(value.maxRounds) < 1) {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
problem: "maxRounds must be a whole number of 1 or more",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
input.maxRounds = Number(value.maxRounds);
|
|
344
|
+
}
|
|
345
|
+
return { ok: true, value: withDefaults(input) };
|
|
346
|
+
}
|
|
347
|
+
/** What the dashboard shows next to each template, one plain sentence each. */
|
|
348
|
+
export const TEMPLATE_INFO = [
|
|
349
|
+
{
|
|
350
|
+
name: "everyone",
|
|
351
|
+
label: "Everyone",
|
|
352
|
+
description: "Every pull request is reviewed automatically, except drafts and pull requests by bots.",
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: "trusted-auto",
|
|
356
|
+
label: "Trusted authors, automatic",
|
|
357
|
+
description: "Owners, members and collaborators are reviewed automatically. Everyone else waits until a maintainer asks.",
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "on-request-only",
|
|
361
|
+
label: "Only when requested",
|
|
362
|
+
description: "Nothing is reviewed until a maintainer asks, with a label or a comment.",
|
|
363
|
+
},
|
|
364
|
+
];
|
|
365
|
+
const ASSOCIATION_WORDS = {
|
|
366
|
+
OWNER: "owners",
|
|
367
|
+
MEMBER: "members",
|
|
368
|
+
COLLABORATOR: "collaborators",
|
|
369
|
+
CONTRIBUTOR: "earlier contributors",
|
|
370
|
+
FIRST_TIME_CONTRIBUTOR: "first time contributors",
|
|
371
|
+
FIRST_TIMER: "first timers",
|
|
372
|
+
NONE: "people with no relation to the repository",
|
|
373
|
+
};
|
|
374
|
+
function joinWords(words, joiner) {
|
|
375
|
+
if (words.length <= 1)
|
|
376
|
+
return words.join("");
|
|
377
|
+
return `${words.slice(0, -1).join(", ")} ${joiner} ${words.at(-1)}`;
|
|
378
|
+
}
|
|
379
|
+
function describeWhen(when) {
|
|
380
|
+
const empty = Object.keys(when).length === 0;
|
|
381
|
+
if (empty)
|
|
382
|
+
return "All pull requests";
|
|
383
|
+
let subject = "Pull requests";
|
|
384
|
+
if (when.draft === true && when.bot === true) {
|
|
385
|
+
subject = "Draft pull requests by bots";
|
|
386
|
+
}
|
|
387
|
+
else if (when.draft === true) {
|
|
388
|
+
subject = "Draft pull requests";
|
|
389
|
+
}
|
|
390
|
+
else if (when.bot === true) {
|
|
391
|
+
subject = "Pull requests by bots";
|
|
392
|
+
}
|
|
393
|
+
const extras = [];
|
|
394
|
+
if (when.draft === false)
|
|
395
|
+
extras.push("that are ready for review");
|
|
396
|
+
if (when.bot === false)
|
|
397
|
+
extras.push("not by bots");
|
|
398
|
+
if (when.association) {
|
|
399
|
+
extras.push(`by ${joinWords(when.association.map((a) => ASSOCIATION_WORDS[a]), "or")}`);
|
|
400
|
+
}
|
|
401
|
+
if (when.fork === true)
|
|
402
|
+
extras.push("opened from a fork");
|
|
403
|
+
if (when.fork === false)
|
|
404
|
+
extras.push("opened from the repository itself");
|
|
405
|
+
if (when.labels)
|
|
406
|
+
extras.push(`labeled ${joinWords(when.labels, "or")}`);
|
|
407
|
+
if (when.targetBranch) {
|
|
408
|
+
extras.push(`into ${joinWords(when.targetBranch, "or")}`);
|
|
409
|
+
}
|
|
410
|
+
const lines = when.changedLines;
|
|
411
|
+
if (lines) {
|
|
412
|
+
if (lines.min !== undefined && lines.max !== undefined) {
|
|
413
|
+
extras.push(`changing ${lines.min} to ${lines.max} lines`);
|
|
414
|
+
}
|
|
415
|
+
else if (lines.min !== undefined) {
|
|
416
|
+
extras.push(`changing at least ${lines.min} lines`);
|
|
417
|
+
}
|
|
418
|
+
else if (lines.max !== undefined) {
|
|
419
|
+
extras.push(`changing at most ${lines.max} lines`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return [subject, ...extras].join(" ");
|
|
423
|
+
}
|
|
424
|
+
const PLURAL_ACTION = {
|
|
425
|
+
review: "are reviewed automatically.",
|
|
426
|
+
"on-request": "wait for a maintainer request.",
|
|
427
|
+
skip: "are not reviewed.",
|
|
428
|
+
};
|
|
429
|
+
const SINGULAR_ACTION = {
|
|
430
|
+
review: "is reviewed automatically.",
|
|
431
|
+
"on-request": "waits for a maintainer request.",
|
|
432
|
+
skip: "is not reviewed.",
|
|
433
|
+
};
|
|
434
|
+
/** The policy as short sentences a maintainer can read before saving it. */
|
|
435
|
+
export function describePolicy(policy) {
|
|
436
|
+
const lines = policy.rules.map((rule) => `${describeWhen(rule.when)} ${PLURAL_ACTION[rule.action]}`);
|
|
437
|
+
lines.push(policy.rules.length === 0
|
|
438
|
+
? `Every pull request ${SINGULAR_ACTION[policy.default]}`
|
|
439
|
+
: `Any other pull request ${SINGULAR_ACTION[policy.default]}`);
|
|
440
|
+
const waits = policy.default === "on-request" ||
|
|
441
|
+
policy.rules.some((rule) => rule.action === "on-request");
|
|
442
|
+
if (waits) {
|
|
443
|
+
const who = joinWords(policy.requesters.map((a) => ASSOCIATION_WORDS[a]), "and");
|
|
444
|
+
const scope = policy.approvalScope === "head"
|
|
445
|
+
? "A request covers the commit it was made on."
|
|
446
|
+
: "A request covers every later push to the pull request.";
|
|
447
|
+
lines.push(`${who.charAt(0).toUpperCase()}${who.slice(1)} can ask by adding the label "${policy.requestLabel}" or by commenting "${policy.requestCommand}". ${scope}`);
|
|
448
|
+
}
|
|
449
|
+
if (policy.maxRounds !== undefined) {
|
|
450
|
+
lines.push(`A pull request gets at most ${policy.maxRounds} ${policy.maxRounds === 1 ? "review" : "reviews"} from the webhook.`);
|
|
451
|
+
}
|
|
452
|
+
return lines;
|
|
453
|
+
}
|
|
454
|
+
function canonical(value) {
|
|
455
|
+
if (Array.isArray(value))
|
|
456
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
457
|
+
if (value !== null && typeof value === "object") {
|
|
458
|
+
const entries = Object.entries(value)
|
|
459
|
+
.filter(([, item]) => item !== undefined)
|
|
460
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
461
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
462
|
+
}
|
|
463
|
+
return JSON.stringify(value);
|
|
464
|
+
}
|
|
465
|
+
/** What to store for a policy: the template's name when the policy is exactly
|
|
466
|
+
* that template with the default settings, the whole policy otherwise. */
|
|
467
|
+
export function storedPolicyValue(policy) {
|
|
468
|
+
const wanted = canonical(policy);
|
|
469
|
+
for (const name of TEMPLATE_NAMES) {
|
|
470
|
+
if (canonical(withDefaults(TEMPLATES[name])) === wanted)
|
|
471
|
+
return name;
|
|
472
|
+
}
|
|
473
|
+
return policy;
|
|
474
|
+
}
|
|
475
|
+
/** What a form or API caller sent, as the value to store. `null` clears the
|
|
476
|
+
* choice, and `"legacy"` (the simple switches) is only for one repository. */
|
|
477
|
+
export function policyToStore(value, options) {
|
|
478
|
+
if (value === null)
|
|
479
|
+
return { ok: true, value: null };
|
|
480
|
+
if (value === "legacy") {
|
|
481
|
+
return options.allowLegacy
|
|
482
|
+
? { ok: true, value: '"legacy"' }
|
|
483
|
+
: { ok: false, problem: "the simple switches only exist per repository" };
|
|
484
|
+
}
|
|
485
|
+
const parsed = parsePolicy(value);
|
|
486
|
+
if (!parsed.ok)
|
|
487
|
+
return parsed;
|
|
488
|
+
return { ok: true, value: JSON.stringify(storedPolicyValue(parsed.value)) };
|
|
489
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { AiResponse, GitHubClient, Options } from "../types.ts";
|
|
2
2
|
import type { LogFn } from "./jobs.ts";
|
|
3
3
|
import type { JobRow } from "../store/rows.ts";
|
|
4
|
-
|
|
4
|
+
import { type CostTally } from "../util/cost.ts";
|
|
5
|
+
export type AiMetrics = CostTally & {
|
|
5
6
|
calls: number;
|
|
6
7
|
tokensIn: number;
|
|
7
8
|
tokensOut: number;
|
|
8
|
-
cost: number;
|
|
9
|
-
costKnown: boolean;
|
|
10
9
|
};
|
|
11
10
|
export declare function emptyAiMetrics(): AiMetrics;
|
|
11
|
+
/** One AI response into the run totals. */
|
|
12
|
+
export declare function addAiMetrics(metrics: AiMetrics, response: AiResponse): void;
|
|
12
13
|
export declare function recordAiCost(repo: string, job: string, response: AiResponse): Promise<void>;
|
|
13
14
|
export declare function clientFor(options: Options): GitHubClient;
|
|
14
15
|
export declare function runInitOrRemake(options: Options): Promise<void>;
|
|
@@ -20,8 +20,17 @@ import { closeAppDb, isAppDbOpen, openAppDb } from "../store/app_db.js";
|
|
|
20
20
|
import { getRepo, markKnowledgeBuilt } from "../store/repos.js";
|
|
21
21
|
import { nowIso } from "../util/time.js";
|
|
22
22
|
import { mkdir, readTextFile, remove } from "../util/runtime.js";
|
|
23
|
+
import { addResponseCost, emptyTally } from "../util/cost.js";
|
|
24
|
+
import { costLabel } from "../util/run_summary.js";
|
|
23
25
|
export function emptyAiMetrics() {
|
|
24
|
-
return { calls: 0, tokensIn: 0, tokensOut: 0,
|
|
26
|
+
return { calls: 0, tokensIn: 0, tokensOut: 0, ...emptyTally() };
|
|
27
|
+
}
|
|
28
|
+
/** One AI response into the run totals. */
|
|
29
|
+
export function addAiMetrics(metrics, response) {
|
|
30
|
+
metrics.calls++;
|
|
31
|
+
metrics.tokensIn += response.tokensIn;
|
|
32
|
+
metrics.tokensOut += response.tokensOut;
|
|
33
|
+
addResponseCost(metrics, response);
|
|
25
34
|
}
|
|
26
35
|
export async function recordAiCost(repo, job, response) {
|
|
27
36
|
await cacheSet("cost", `${repo}:${crypto.randomUUID()}`, JSON.stringify({
|
|
@@ -31,7 +40,8 @@ export async function recordAiCost(repo, job, response) {
|
|
|
31
40
|
model: response.model,
|
|
32
41
|
tokensIn: response.tokensIn,
|
|
33
42
|
tokensOut: response.tokensOut,
|
|
34
|
-
usd: response.
|
|
43
|
+
usd: response.cost ?? null,
|
|
44
|
+
status: response.cost === undefined ? "unknown" : "known",
|
|
35
45
|
}));
|
|
36
46
|
}
|
|
37
47
|
export function clientFor(options) {
|
|
@@ -76,7 +86,7 @@ async function skillPath(repo) {
|
|
|
76
86
|
async function writeReviewDocuments(repo, documents, signalCount) {
|
|
77
87
|
const directory = `${reposDir()}/${repo}`;
|
|
78
88
|
if (!documents) {
|
|
79
|
-
//
|
|
89
|
+
// This used to delete the files without a word. The rule is
|
|
80
90
|
// "says why the third file is missing": keep it to one line.
|
|
81
91
|
log("write", `PR_REVIEW_GUIDE.md skipped: found ${signalCount} review signals, needs at least 3`);
|
|
82
92
|
await Promise.all([
|
|
@@ -98,7 +108,7 @@ async function writeReviewDocuments(repo, documents, signalCount) {
|
|
|
98
108
|
* of the assembled skill into their own file, so `review` can check a pull
|
|
99
109
|
* request against how this repository's code actually looks, not just the
|
|
100
110
|
* review-bar checklist mined from past PR comments. The skill links to this
|
|
101
|
-
* file rather than repeating it
|
|
111
|
+
* file rather than repeating it, so its body is built from
|
|
102
112
|
* the facts instead of being scraped back out of the skill. */
|
|
103
113
|
async function writeCodebaseDocument(repo, facts, overrides = {}) {
|
|
104
114
|
const path = `${reposDir()}/${repo}/CODEBASE.md`;
|
|
@@ -188,15 +198,7 @@ async function initOrRemake(options) {
|
|
|
188
198
|
if (lowAi) {
|
|
189
199
|
log("ai", "starting extract_unit jobs");
|
|
190
200
|
const usage = async (job, response) => {
|
|
191
|
-
aiMetrics
|
|
192
|
-
aiMetrics.tokensIn += response.tokensIn;
|
|
193
|
-
aiMetrics.tokensOut += response.tokensOut;
|
|
194
|
-
if (response.provider === "hetzner")
|
|
195
|
-
aiMetrics.cost += 0;
|
|
196
|
-
else if (response.cost === undefined)
|
|
197
|
-
aiMetrics.costKnown = false;
|
|
198
|
-
else
|
|
199
|
-
aiMetrics.cost += response.cost;
|
|
201
|
+
addAiMetrics(aiMetrics, response);
|
|
200
202
|
await recordAiCost(options.repo, job, response);
|
|
201
203
|
};
|
|
202
204
|
const enriched = await timed("extract_unit AI", options.logTime, () => enrichFactsWithReport(lowAi, options.repo, facts, source, options, usage));
|
|
@@ -289,8 +291,8 @@ async function initOrRemake(options) {
|
|
|
289
291
|
: "already current"}`);
|
|
290
292
|
log("done", `${facts.length} facts · ${source.pullRequests.length} pull requests · ${source.commits.length} commits`);
|
|
291
293
|
if (skipped.length) {
|
|
292
|
-
//
|
|
293
|
-
// hit" forever. Say it in the final line instead, in
|
|
294
|
+
// A skipped unit used to vanish into a log line and recur as a "cache
|
|
295
|
+
// hit" forever. Say it in the final line instead, in this shape:
|
|
294
296
|
// `1 unit skipped (PR #3: output was not JSON)`.
|
|
295
297
|
const parts = skipped.map((unit) => unit.reason);
|
|
296
298
|
const line = skipped.length === 1
|
|
@@ -300,7 +302,7 @@ async function initOrRemake(options) {
|
|
|
300
302
|
}
|
|
301
303
|
printRunSummary(summaryFromMetrics(aiMetrics, performance.now() - operationStarted));
|
|
302
304
|
if (options.logTime) {
|
|
303
|
-
log("time", `AI total · calls=${aiMetrics.calls} · input=${aiMetrics.tokensIn} tokens · output=${aiMetrics.tokensOut} tokens · cost=${aiMetrics
|
|
305
|
+
log("time", `AI total · calls=${aiMetrics.calls} · input=${aiMetrics.tokensIn} tokens · output=${aiMetrics.tokensOut} tokens · cost=${costLabel(aiMetrics)}`);
|
|
304
306
|
log("time", `total init/sync · ${((performance.now() - operationStarted) / 1000).toFixed(2)}s`);
|
|
305
307
|
}
|
|
306
308
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/** The "Finish setup" checklist shown on the home page
|
|
1
|
+
/** The "Finish setup" checklist shown on the home page.
|
|
2
2
|
*
|
|
3
3
|
* Each item is derived from state that already exists, so the card reflects
|
|
4
4
|
* what serve can actually do rather than a parallel bookkeeping of it. The
|
|
5
|
-
* webhook item reuses
|
|
5
|
+
* webhook item reuses the existing reachability check. */
|
|
6
6
|
import { readConfig, resolveAppPrivateKey } from "../config.js";
|
|
7
7
|
import { countReviews } from "../store/reviews.js";
|
|
8
8
|
import { listRemoteTokens } from "../store/remote_tokens.js";
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
+
import type { RepoRow } from "../store/rows.ts";
|
|
2
|
+
import { type ReviewPolicy } from "./review_policy.ts";
|
|
1
3
|
export declare const REVIEW_DEBOUNCE_MS = 20000;
|
|
4
|
+
/** The policy that governs a repository. A repository pinned to `"legacy"`, and
|
|
5
|
+
* one with nothing stored and no server default, follow the three old
|
|
6
|
+
* switches. A stored value that no longer parses does too, so a bad edit
|
|
7
|
+
* degrades to today's behavior rather than to a silent stop. */
|
|
8
|
+
export declare function policySource(row: RepoRow): {
|
|
9
|
+
policy: ReviewPolicy;
|
|
10
|
+
/** True when the three old switches are what decides. */
|
|
11
|
+
legacy: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function policyForRepo(row: RepoRow): ReviewPolicy;
|
|
2
14
|
export type WebhookResult = {
|
|
3
15
|
outcome: "enqueued" | "skipped" | "ignored" | "error";
|
|
4
16
|
reason?: string;
|
|
@@ -18,5 +30,16 @@ export declare function maybeEnqueueReview(input: {
|
|
|
18
30
|
trigger: string;
|
|
19
31
|
headSha?: string;
|
|
20
32
|
maxDiffLines?: number;
|
|
33
|
+
/** What the pull request payload said about its author and shape. Left out
|
|
34
|
+
* when GitHub did not send it, and a rule that asks about it then skips. */
|
|
35
|
+
association?: string;
|
|
36
|
+
fork?: boolean;
|
|
37
|
+
labels?: string[];
|
|
38
|
+
targetBranch?: string;
|
|
39
|
+
/** Set when a maintainer asked for this review by label or comment. The
|
|
40
|
+
* reaction endpoint is where the App acknowledges it once the job starts. */
|
|
41
|
+
request?: {
|
|
42
|
+
reaction: string;
|
|
43
|
+
};
|
|
21
44
|
}): WebhookResult;
|
|
22
45
|
export declare function dispatchGithubEvent(event: string, payload: Record<string, unknown>, deliveryId: string, maxDiffLines?: number): WebhookResult;
|