@fullstackhouse/cerber 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -2
- package/dist/core/artifact.js +18 -1
- package/dist/core/autosend.js +9 -0
- package/dist/core/export.js +5 -1
- package/dist/core/gh.js +52 -0
- package/dist/core/revise.js +10 -2
- package/dist/core/send.js +10 -2
- package/dist/runner/prompt.js +13 -3
- package/dist/runner/review.js +1 -0
- package/dist/server/daemon.js +37 -2
- package/dist/server/index.js +5 -1
- package/package.json +1 -1
- package/web/dist/assets/index-akC55Qya.js +236 -0
- package/web/dist/assets/{index-DYCn2NmI.css → index-s4kXKj8C.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BtEGRrsq.js +0 -236
package/README.md
CHANGED
|
@@ -41,6 +41,24 @@ for reviews you sent, one for the ones you marked reviewed or skipped, one for
|
|
|
41
41
|
merged and closed PRs — and PRs in archived repos are never picked up at all,
|
|
42
42
|
since the repo is read-only and a review could never be sent.
|
|
43
43
|
|
|
44
|
+
Settling a review is your decision, not GitHub's: skip a PR, or mark one
|
|
45
|
+
reviewed without sending, and GitHub still holds its review request open,
|
|
46
|
+
because nothing cerber does here reaches it. Those rows stay marked where they
|
|
47
|
+
sit, counted on the drawer they went into, and named in place of the empty
|
|
48
|
+
inbox — cerber never tells you nothing awaits you while its own poll says
|
|
49
|
+
otherwise. It is a report, not a nag: what you decided here stands, and the
|
|
50
|
+
mark clears itself the moment GitHub hears from you.
|
|
51
|
+
|
|
52
|
+
An open request doesn't mean the ball is yours, though. You may have argued the
|
|
53
|
+
whole thing out in the PR conversation and never pressed GitHub's review
|
|
54
|
+
button, which leaves the request open with nothing blocked on you. So cerber
|
|
55
|
+
reads the conversation and says whose move it is: `you haven't replied` (they
|
|
56
|
+
are waiting), `waiting on them` (you had the last word), or `they replied last`
|
|
57
|
+
(it is back with you). Bots don't count as an answer, or a chatty CI would put
|
|
58
|
+
every PR back on you. If the conversation can't be read it says `unanswered on
|
|
59
|
+
GitHub` and claims nothing further — guessing "you never replied" at someone
|
|
60
|
+
who did is the one mistake worth designing against.
|
|
61
|
+
|
|
44
62
|
The cockpit is a background tab most of the day, so it tells you when the queue
|
|
45
63
|
grows: a desktop notification naming the PR that arrived — click it to open the
|
|
46
64
|
review — folded into one popup when several land at once. It stays quiet while
|
|
@@ -60,9 +78,11 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
|
|
|
60
78
|
- **Chapters** — the changed files grouped into a logical walkthrough, each
|
|
61
79
|
with a title, an explanation, and its slice of the diff
|
|
62
80
|
- **Draft comments** — inline, anchored to file/line, only things worth a
|
|
63
|
-
human's time
|
|
81
|
+
human's time. Findings are graded **blocker / minor / nit** — the words mean
|
|
82
|
+
what they mean everywhere, and only a blocker stands between the PR and
|
|
83
|
+
approval; a question or note carries no grade at all
|
|
64
84
|
- **Verdict** — approve / comment / request changes, with a 0–100 confidence
|
|
65
|
-
score and reasoning
|
|
85
|
+
score and reasoning that names the worst finding still standing
|
|
66
86
|
- **Run metadata** — model, whether it read the source or only the diff, and
|
|
67
87
|
the token spend as an API-rate equivalent
|
|
68
88
|
|
package/dist/core/artifact.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const SCHEMA_VERSION = 1;
|
|
3
|
+
/**
|
|
4
|
+
* How hard a finding presses on the merge. The words carry their own
|
|
5
|
+
* semantics: a blocker is the only tier that stops approval; minor is
|
|
6
|
+
* "should fix, author's call"; nit is taste. Absent (null) means the comment
|
|
7
|
+
* is not a finding at all — a question, a note, praise — not "unknown".
|
|
8
|
+
*/
|
|
9
|
+
export const SeveritySchema = z.enum(["blocker", "minor", "nit"]);
|
|
3
10
|
/** A draft inline comment. Never sent to GitHub unless the user explicitly sends the review. */
|
|
4
11
|
export const CommentSchema = z.object({
|
|
5
12
|
id: z.string(),
|
|
@@ -8,6 +15,8 @@ export const CommentSchema = z.object({
|
|
|
8
15
|
line: z.number().int().positive().nullable(),
|
|
9
16
|
body: z.string(),
|
|
10
17
|
chapterId: z.string().nullable().default(null),
|
|
18
|
+
/** Merge impact of this finding; null for remarks that are not findings. */
|
|
19
|
+
severity: SeveritySchema.nullable().default(null),
|
|
11
20
|
origin: z.enum(["ai", "user"]),
|
|
12
21
|
status: z.enum(["draft", "approved", "dropped"]).default("draft"),
|
|
13
22
|
/** Set when the user edits an AI comment's body — calibration signal. */
|
|
@@ -111,7 +120,13 @@ export const RevisionSchema = z.discriminatedUnion("kind", [
|
|
|
111
120
|
z.object({ kind: z.literal("summary"), body: z.string() }),
|
|
112
121
|
z.object({ kind: z.literal("verdict"), verdict: VerdictSchema }),
|
|
113
122
|
z.object({ kind: z.literal("chapter"), chapterId: z.string(), title: z.string().optional(), explanation: z.string().optional() }),
|
|
114
|
-
z.object({
|
|
123
|
+
z.object({
|
|
124
|
+
kind: z.literal("comment-edit"),
|
|
125
|
+
commentId: z.string(),
|
|
126
|
+
body: z.string(),
|
|
127
|
+
/** Re-grade while editing; omitted = grade unchanged, null = not a finding. */
|
|
128
|
+
severity: SeveritySchema.nullable().optional(),
|
|
129
|
+
}),
|
|
115
130
|
z.object({ kind: z.literal("comment-drop"), commentId: z.string() }),
|
|
116
131
|
z.object({
|
|
117
132
|
kind: z.literal("comment-add"),
|
|
@@ -119,6 +134,7 @@ export const RevisionSchema = z.discriminatedUnion("kind", [
|
|
|
119
134
|
line: z.number().int().positive().nullable(),
|
|
120
135
|
body: z.string(),
|
|
121
136
|
chapterId: z.string().nullable().default(null),
|
|
137
|
+
severity: SeveritySchema.nullable().default(null),
|
|
122
138
|
}),
|
|
123
139
|
]);
|
|
124
140
|
/** A revision the run declined to make, and why — shown in the transcript. */
|
|
@@ -217,6 +233,7 @@ export const AiReviewSchema = z.object({
|
|
|
217
233
|
line: z.number().int().positive().nullable(),
|
|
218
234
|
body: z.string(),
|
|
219
235
|
chapterId: z.string().nullable().optional(),
|
|
236
|
+
severity: SeveritySchema.nullable().optional(),
|
|
220
237
|
})),
|
|
221
238
|
verdict: VerdictSchema,
|
|
222
239
|
});
|
package/dist/core/autosend.js
CHANGED
|
@@ -16,6 +16,15 @@ export function evaluateAutoSend(artifact, threshold) {
|
|
|
16
16
|
reason: `recommendation is ${artifact.verdict.recommendation} — only approve auto-sends`,
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
// An approve verdict standing next to a live blocker finding contradicts
|
|
20
|
+
// itself; a contradiction is a human's to resolve, never auto-sent.
|
|
21
|
+
const blockers = artifact.comments.filter((c) => c.status !== "dropped" && c.severity === "blocker");
|
|
22
|
+
if (blockers.length > 0) {
|
|
23
|
+
return {
|
|
24
|
+
eligible: false,
|
|
25
|
+
reason: `verdict says approve but ${blockers.length} blocker finding(s) still stand — a human resolves that`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
19
28
|
if (artifact.verdict.confidence < threshold) {
|
|
20
29
|
return {
|
|
21
30
|
eligible: false,
|
package/dist/core/export.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** Same convention as Send: the grade travels in the text, ungraded stays bare. */
|
|
2
|
+
function grade(c) {
|
|
3
|
+
return c.severity ? `**${c.severity}:** ` : "";
|
|
4
|
+
}
|
|
1
5
|
/** Render an artifact as a standalone markdown review document. */
|
|
2
6
|
export function toMarkdown(artifact) {
|
|
3
7
|
const lines = [];
|
|
@@ -29,7 +33,7 @@ export function toMarkdown(artifact) {
|
|
|
29
33
|
lines.push("");
|
|
30
34
|
const comments = artifact.comments.filter((c) => c.chapterId === ch.id && c.status !== "dropped");
|
|
31
35
|
for (const c of comments) {
|
|
32
|
-
lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${c.body.replace(/\n/g, "\n> ")}`);
|
|
36
|
+
lines.push(`> **${c.path}${c.line != null ? `:${c.line}` : ""}** — ${grade(c)}${c.body.replace(/\n/g, "\n> ")}`);
|
|
33
37
|
lines.push("");
|
|
34
38
|
}
|
|
35
39
|
}
|
package/dist/core/gh.js
CHANGED
|
@@ -114,6 +114,58 @@ export function mapSearchResults(raw) {
|
|
|
114
114
|
];
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
|
+
/** GitHub types bot accounts, and names them `something[bot]` besides. */
|
|
118
|
+
export const isBot = (c) => c.bot || c.author.endsWith("[bot]");
|
|
119
|
+
/** The PR's conversation comments, oldest first. Not the inline review ones —
|
|
120
|
+
* those come attached to a review, which would have cleared the request. */
|
|
121
|
+
export async function fetchConversation(ref) {
|
|
122
|
+
const out = await gh([
|
|
123
|
+
"api",
|
|
124
|
+
"--paginate",
|
|
125
|
+
`repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments`,
|
|
126
|
+
"--jq",
|
|
127
|
+
'.[] | {author: .user.login, at: .created_at, bot: (.user.type == "Bot")}',
|
|
128
|
+
]);
|
|
129
|
+
// --jq streams one object per line rather than a JSON array.
|
|
130
|
+
return out
|
|
131
|
+
.split("\n")
|
|
132
|
+
.filter((line) => line.trim() !== "")
|
|
133
|
+
.map((line) => JSON.parse(line));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Whose move it is, from the conversation: you have not spoken at all, you
|
|
137
|
+
* spoke last (so it is on them), or a person answered after you (back to you).
|
|
138
|
+
*
|
|
139
|
+
* Bots are ignored outright. A repo whose CI posts on every push would
|
|
140
|
+
* otherwise report "they replied last" on everything, which is the fastest way
|
|
141
|
+
* to make a signal worthless.
|
|
142
|
+
*/
|
|
143
|
+
export function classifyReply(comments, login) {
|
|
144
|
+
const human = comments.filter((c) => !isBot(c));
|
|
145
|
+
const mine = human.filter((c) => c.author === login);
|
|
146
|
+
if (mine.length === 0)
|
|
147
|
+
return "none";
|
|
148
|
+
const lastMine = mine[mine.length - 1].at;
|
|
149
|
+
// A comment of your own posted at the same instant can't be someone else's
|
|
150
|
+
// answer to it, so only strictly-later comments hand the move back.
|
|
151
|
+
return human.some((c) => c.author !== login && c.at > lastMine) ? "them" : "you";
|
|
152
|
+
}
|
|
153
|
+
/** The login `@me` resolves to. Asked once — it cannot change mid-process. */
|
|
154
|
+
let cachedLogin = null;
|
|
155
|
+
export function currentLogin() {
|
|
156
|
+
cachedLogin ??= gh(["api", "user", "--jq", ".login"]).then((out) => out.trim(), (err) => {
|
|
157
|
+
// A login we could not fetch is not an answer worth keeping. gh may be
|
|
158
|
+
// offline or mid-re-auth; caching the rejection would leave every later
|
|
159
|
+
// poll reporting "unknown" until the process restarts.
|
|
160
|
+
cachedLogin = null;
|
|
161
|
+
throw err;
|
|
162
|
+
});
|
|
163
|
+
return cachedLogin;
|
|
164
|
+
}
|
|
165
|
+
/** Test seam: forget the cached login so the next call asks again. */
|
|
166
|
+
export function resetLoginCache() {
|
|
167
|
+
cachedLogin = null;
|
|
168
|
+
}
|
|
117
169
|
export function searchAwaitingArgs(repoFilter, limit = 50) {
|
|
118
170
|
const args = [
|
|
119
171
|
"search",
|
package/dist/core/revise.js
CHANGED
|
@@ -39,7 +39,7 @@ export function mergeConcurrentEdits(before, after, current) {
|
|
|
39
39
|
// Absent from `before` means the user added it while the turn ran.
|
|
40
40
|
if (!then)
|
|
41
41
|
return true;
|
|
42
|
-
return then.body !== now.body || then.status !== now.status;
|
|
42
|
+
return then.body !== now.body || then.status !== now.status || then.severity !== now.severity;
|
|
43
43
|
};
|
|
44
44
|
const merged = current.comments.map((now) => userTouched(now.id, now) ? now : fromTurn.get(now.id) ?? now);
|
|
45
45
|
// Comments the turn itself added are not in `current` yet. Only those: a
|
|
@@ -115,7 +115,14 @@ export function applyRevisions(artifact, revisions, opts = {}) {
|
|
|
115
115
|
}
|
|
116
116
|
next = {
|
|
117
117
|
...next,
|
|
118
|
-
comments: next.comments.map((c) => c.id === revision.commentId
|
|
118
|
+
comments: next.comments.map((c) => c.id === revision.commentId
|
|
119
|
+
? {
|
|
120
|
+
...c,
|
|
121
|
+
body: revision.body,
|
|
122
|
+
// Omitted = the turn is not re-grading; null = "not a finding".
|
|
123
|
+
severity: revision.severity === undefined ? c.severity : revision.severity,
|
|
124
|
+
}
|
|
125
|
+
: c),
|
|
119
126
|
};
|
|
120
127
|
applied.push(revision);
|
|
121
128
|
break;
|
|
@@ -149,6 +156,7 @@ export function applyRevisions(artifact, revisions, opts = {}) {
|
|
|
149
156
|
line: revision.line,
|
|
150
157
|
body: revision.body,
|
|
151
158
|
chapterId: revision.chapterId,
|
|
159
|
+
severity: revision.severity,
|
|
152
160
|
origin: "ai",
|
|
153
161
|
status: "draft",
|
|
154
162
|
editedByUser: false,
|
package/dist/core/send.js
CHANGED
|
@@ -2,6 +2,14 @@ import { newSideLines } from "./diff.js";
|
|
|
2
2
|
export function eventForRecommendation(rec) {
|
|
3
3
|
return { approve: "APPROVE", comment: "COMMENT", request_changes: "REQUEST_CHANGES" }[rec];
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* The grade travels in the text: to a reader used to review convention, an
|
|
7
|
+
* unprefixed comment says "please change this", so leaving a nit bare would
|
|
8
|
+
* invert its meaning. Ungraded remarks (questions, notes) stay bare.
|
|
9
|
+
*/
|
|
10
|
+
function withGrade(c) {
|
|
11
|
+
return c.severity ? `**${c.severity}:** ${c.body}` : c.body;
|
|
12
|
+
}
|
|
5
13
|
/**
|
|
6
14
|
* Build the GitHub review payload from an artifact.
|
|
7
15
|
* Includes every comment that is not dropped. Comments whose line is not part
|
|
@@ -17,7 +25,7 @@ export function buildReviewPayload(artifact, event) {
|
|
|
17
25
|
// holds different code — posting there would point the author at something
|
|
18
26
|
// the comment was never about.
|
|
19
27
|
if (!c.drifted && c.line != null && anchorable.get(c.path)?.has(c.line)) {
|
|
20
|
-
inline.push({ path: c.path, line: c.line, side: "RIGHT", body: c
|
|
28
|
+
inline.push({ path: c.path, line: c.line, side: "RIGHT", body: withGrade(c) });
|
|
21
29
|
}
|
|
22
30
|
else {
|
|
23
31
|
folded.push(c);
|
|
@@ -39,7 +47,7 @@ export function buildReviewPayload(artifact, event) {
|
|
|
39
47
|
// A drifted comment's line no longer exists, so name it as "was at" —
|
|
40
48
|
// pointing a reader at a line number that moved is worse than no number.
|
|
41
49
|
const at = c.line != null ? `:${c.drifted ? `~${c.line}` : c.line}` : "";
|
|
42
|
-
parts.push(`- \`${c.path}${at}\` — ${c
|
|
50
|
+
parts.push(`- \`${c.path}${at}\` — ${withGrade(c)}`);
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
|
package/dist/runner/prompt.js
CHANGED
|
@@ -49,6 +49,7 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
|
|
|
49
49
|
"path": "path/to/file.ts",
|
|
50
50
|
"line": 42,
|
|
51
51
|
"body": "markdown — the review comment. First sentence states the problem plainly; then the evidence or fix. Concrete, actionable, kind.",
|
|
52
|
+
"severity": "blocker" | "minor" | "nit" | null,
|
|
52
53
|
"chapterId": "short-kebab-id"
|
|
53
54
|
}
|
|
54
55
|
],
|
|
@@ -70,6 +71,13 @@ Rules:
|
|
|
70
71
|
- Group ALL changed files into chapters that tell the story of the PR (schema first, then logic, then tests, etc). Every changed file must appear in exactly one chapter.
|
|
71
72
|
- "line" is the line number in the NEW version of the file (the "+" side of the diff). Use null for file-level comments. Only reference lines that appear in the diff.
|
|
72
73
|
- Only write comments that are worth a human reading: real bugs, risky patterns, missing tests, unclear naming. No nitpicks a linter would catch. An empty comments array is a perfectly good review of a clean PR.
|
|
74
|
+
- "severity" is the finding's merge impact. The words mean what they mean everywhere:
|
|
75
|
+
- "blocker" — you would not approve with this in: data loss, a security hole, wrong behavior on a path people actually hit, a broken build. The only tier that blocks.
|
|
76
|
+
- "minor" — should be fixed, but the author's call; you would approve and not chase it.
|
|
77
|
+
- "nit" — taste: naming, wording, formatting.
|
|
78
|
+
- null — not a finding at all: a question, a note, praise.
|
|
79
|
+
- Grade severity as if the finding is true. If you do not believe it enough to grade it, do not write it — or ask it as a question with severity null. Never lower a grade to hedge doubt; doubt belongs in the verdict's "confidence", nowhere else.
|
|
80
|
+
- The verdict must follow from the worst finding still standing: any blocker → "request_changes"; none → "approve" (or "comment" when you genuinely take no position). Name the deciding finding in "reasoning". Never request changes without a blocker in the list, and never approve past one.
|
|
73
81
|
${confidenceRule}
|
|
74
82
|
- Never invent files or lines not present in the diff.
|
|
75
83
|
|
|
@@ -122,7 +130,8 @@ function renderReview(artifact) {
|
|
|
122
130
|
.map((c) => {
|
|
123
131
|
const mine = c.origin === "user" || c.editedByUser ? " [THE USER'S OWN WRITING]" : "";
|
|
124
132
|
const dropped = c.status === "dropped" ? " [dropped]" : "";
|
|
125
|
-
|
|
133
|
+
const graded = c.severity ? ` [${c.severity}]` : "";
|
|
134
|
+
return `- ${c.id} — ${c.path}:${c.line ?? "file-level"}${graded}${mine}${dropped}\n ${c.body}`;
|
|
126
135
|
})
|
|
127
136
|
.join("\n");
|
|
128
137
|
const verdict = artifact.verdict
|
|
@@ -192,9 +201,9 @@ Respond with ONLY a JSON object (no markdown fences, no prose before or after) m
|
|
|
192
201
|
{ "kind": "summary", "body": "the rewritten summary" },
|
|
193
202
|
{ "kind": "verdict", "verdict": { "recommendation": "approve" | "comment" | "request_changes", "confidence": 0-100, "reasoning": "..." } },
|
|
194
203
|
{ "kind": "chapter", "chapterId": "...", "title": "optional new title", "explanation": "optional new explanation" },
|
|
195
|
-
{ "kind": "comment-edit", "commentId": "...", "body": "the rewritten comment" },
|
|
204
|
+
{ "kind": "comment-edit", "commentId": "...", "body": "the rewritten comment", "severity": "blocker" | "minor" | "nit" | null (optional — omit to leave the grade as it is) },
|
|
196
205
|
{ "kind": "comment-drop", "commentId": "..." },
|
|
197
|
-
{ "kind": "comment-add", "path": "path/to/file.ts", "line": 42, "body": "...", "chapterId": "..." }
|
|
206
|
+
{ "kind": "comment-add", "path": "path/to/file.ts", "line": 42, "body": "...", "severity": "blocker" | "minor" | "nit" | null, "chapterId": "..." }
|
|
198
207
|
]
|
|
199
208
|
}
|
|
200
209
|
|
|
@@ -205,6 +214,7 @@ Rules:
|
|
|
205
214
|
- Comments marked [THE USER'S OWN WRITING] are theirs. Do not edit or drop them. If one of them is what needs changing, say so in your reply and let them do it.
|
|
206
215
|
- Every "commentId" and "chapterId" must be one listed below. Never invent one.
|
|
207
216
|
- "line" is a line in the NEW version of the file and must appear in the diff. Use null for a file-level comment.
|
|
217
|
+
- "severity" is the finding's merge impact — "blocker" (would not approve with this in; the only tier that blocks), "minor" (should fix, author's call), "nit" (taste), null (not a finding: a question, a note). Grade as if the finding is true; hedge in the reply, never in the grade. A verdict revision must still follow from the worst finding left standing.
|
|
208
218
|
- Your reply is prose to a person, not a changelog. The cockpit already shows what you changed — do not list your own edits back to them.
|
|
209
219
|
|
|
210
220
|
Writing style:
|
package/dist/runner/review.js
CHANGED
package/dist/server/daemon.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SCHEMA_VERSION, artifactId, artifactKey } from "../core/artifact.js";
|
|
2
2
|
import { evaluateAutoSend } from "../core/autosend.js";
|
|
3
3
|
import { loadConfig } from "../core/config.js";
|
|
4
|
-
import { fetchPrInfo, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
4
|
+
import { classifyReply, currentLogin, fetchConversation, fetchPrInfo, searchAwaitingMe, submitReview, } from "../core/gh.js";
|
|
5
5
|
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
6
6
|
import { appendAutoSendLog, deleteArtifact, listArtifacts, loadArtifact, saveArtifact, updateArtifactByKey, } from "../core/state.js";
|
|
7
7
|
import { ReviewInProgressError } from "../runner/inflight.js";
|
|
@@ -78,6 +78,7 @@ export function startDaemon(opts) {
|
|
|
78
78
|
lastPollAt: null,
|
|
79
79
|
nextPollAt: new Date(Date.now()).toISOString(),
|
|
80
80
|
lastSummary: null,
|
|
81
|
+
awaiting: [],
|
|
81
82
|
lastPollError: null,
|
|
82
83
|
autoSend: opts.autoSend,
|
|
83
84
|
autoSendThreshold: opts.autoSendThreshold,
|
|
@@ -170,6 +171,34 @@ export function startDaemon(opts) {
|
|
|
170
171
|
}
|
|
171
172
|
return { discovered };
|
|
172
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Whose move it is on each awaiting PR, one conversation read apiece.
|
|
176
|
+
*
|
|
177
|
+
* GitHub's `commenter:` search qualifier would answer this in a single call,
|
|
178
|
+
* but it demonstrably disagrees with the REST API — it misses PRs whose only
|
|
179
|
+
* comment is the newest thing on them — and a tag that says "you never
|
|
180
|
+
* replied" to someone who did is the exact bug this whole change fixes. So
|
|
181
|
+
* the conversation is read per PR, bounded by the review concurrency. A read
|
|
182
|
+
* that fails yields `unknown`, which claims nothing.
|
|
183
|
+
*/
|
|
184
|
+
async function classifyAll(refs) {
|
|
185
|
+
let login;
|
|
186
|
+
try {
|
|
187
|
+
login = await currentLogin();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Without knowing who you are, no comment can be attributed to you.
|
|
191
|
+
return refs.map((ref) => ({ id: artifactId(ref), reply: "unknown" }));
|
|
192
|
+
}
|
|
193
|
+
return pool(refs, opts.parallel, async (ref) => {
|
|
194
|
+
try {
|
|
195
|
+
return { id: artifactId(ref), reply: classifyReply(await fetchConversation(ref), login) };
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return { id: artifactId(ref), reply: "unknown" };
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
173
202
|
async function reviewAll(refs) {
|
|
174
203
|
let reviewed = 0;
|
|
175
204
|
let skipped = 0;
|
|
@@ -222,13 +251,19 @@ export function startDaemon(opts) {
|
|
|
222
251
|
// Recomputed too, so a trust rule added mid-run shows up in the strip.
|
|
223
252
|
status.trustedRuns = autoReview && opts.trust !== false && config.trust.length > 0;
|
|
224
253
|
if (!knobs.poll) {
|
|
225
|
-
// Deliberately off is not an outage — don't leave a stale error up
|
|
254
|
+
// Deliberately off is not an outage — don't leave a stale error up, or
|
|
255
|
+
// a list of PRs we are no longer checking are still awaiting you.
|
|
226
256
|
status.lastPollError = null;
|
|
257
|
+
status.awaiting = [];
|
|
227
258
|
status.lastSummary = "polling off (settings) — the queue only shows what you review by hand";
|
|
228
259
|
return;
|
|
229
260
|
}
|
|
230
261
|
const refs = await discover();
|
|
231
262
|
status.lastPollError = null;
|
|
263
|
+
// The same refs behind lastSummary's count, so the number the strip shows
|
|
264
|
+
// and the rows the cockpit can name always come from one answer.
|
|
265
|
+
// A failed poll leaves the last good list up; lastPollError says so.
|
|
266
|
+
status.awaiting = await classifyAll(refs);
|
|
232
267
|
const { discovered } = await syncQueue(refs);
|
|
233
268
|
if (autoReview) {
|
|
234
269
|
const { reviewed, skipped, failed } = await reviewAll(refs);
|
package/dist/server/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { promises as fs } from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
-
import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
|
|
9
|
+
import { ArtifactStatusSchema, SeveritySchema, VerdictSchema, artifactKey } from "../core/artifact.js";
|
|
10
10
|
import { toMarkdown } from "../core/export.js";
|
|
11
11
|
import { fetchPrDiff, fetchPrInfo, submitReview } from "../core/gh.js";
|
|
12
12
|
import { z } from "zod";
|
|
@@ -194,6 +194,9 @@ export async function buildApp(opts) {
|
|
|
194
194
|
? {
|
|
195
195
|
...cm,
|
|
196
196
|
...(body.body !== undefined ? { body: String(body.body) } : {}),
|
|
197
|
+
...(body.severity !== undefined
|
|
198
|
+
? { severity: SeveritySchema.nullable().parse(body.severity) }
|
|
199
|
+
: {}),
|
|
197
200
|
...(body.status !== undefined
|
|
198
201
|
? { status: body.status }
|
|
199
202
|
: {}),
|
|
@@ -219,6 +222,7 @@ export async function buildApp(opts) {
|
|
|
219
222
|
line: body.line != null ? Number(body.line) : null,
|
|
220
223
|
body: String(body.body ?? ""),
|
|
221
224
|
chapterId: body.chapterId ?? null,
|
|
225
|
+
severity: SeveritySchema.nullable().parse(body.severity ?? null),
|
|
222
226
|
origin: "user",
|
|
223
227
|
status: "draft",
|
|
224
228
|
editedByUser: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Full Stack House (https://fullstack.house)",
|