@fullstackhouse/cerber 0.3.0 → 0.5.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 +27 -4
- package/dist/cli/index.js +87 -7
- package/dist/core/artifact.js +16 -1
- package/dist/core/autosend.js +26 -0
- package/dist/core/send.js +13 -0
- package/dist/core/state.js +26 -0
- package/dist/runner/review.js +2 -0
- package/dist/server/daemon.js +136 -0
- package/dist/server/index.js +34 -4
- package/package.json +4 -1
- package/web/dist/assets/{index-B73WZWQg.css → index-D00teMaV.css} +1 -1
- package/web/dist/assets/index-pyON6-n7.js +229 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CjzYwp5w.js +0 -163
package/README.md
CHANGED
|
@@ -58,10 +58,33 @@ Early. Current phase: review + read-only cockpit.
|
|
|
58
58
|
GitHub write in the codebase) + `cerber export` to markdown
|
|
59
59
|
3. ✅ `--awaiting-me` discovery, parallel runs (`--parallel`, default 3),
|
|
60
60
|
freshness skip (same head SHA → no re-run; `--force` overrides), queue stats
|
|
61
|
-
4. Daemon/VPS mode:
|
|
62
|
-
automatically
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
4. ✅ Daemon/VPS mode: `cerber serve --daemon` polls for PRs awaiting your
|
|
62
|
+
review and reviews them automatically (read-only); token auth for
|
|
63
|
+
non-localhost binds
|
|
64
|
+
5. ✅ Confidence calibration (`cerber stats`), shadow-mode auto-send (default
|
|
65
|
+
in daemon: logs what would be sent), opt-in `--auto-send` (approve-only,
|
|
66
|
+
`--auto-send-threshold`, default 90%)
|
|
67
|
+
|
|
68
|
+
## Running on a VPS
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
cerber serve --daemon --host 0.0.0.0 --token "$(openssl rand -hex 16)" \
|
|
72
|
+
--repo you/repo-a --repo you/repo-b --interval 10
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The daemon polls GitHub, reviews anything new (skipping PRs whose artifact
|
|
76
|
+
already matches the head SHA), and the cockpit is always warm — open it any
|
|
77
|
+
minute and see pending/ready/sent reviews. Auth: `?token=…` once in the
|
|
78
|
+
browser (sets a cookie) or `Authorization: Bearer …`. Binding a non-localhost
|
|
79
|
+
host without a token is refused. By default the daemon never sends reviews — Send stays a human click.
|
|
80
|
+
|
|
81
|
+
**Auto-send** (opt-in): add `--auto-send --auto-send-threshold 90` and the
|
|
82
|
+
daemon will submit APPROVE verdicts at/above the threshold as you. It never
|
|
83
|
+
auto-sends COMMENT or REQUEST_CHANGES. Before enabling, run the default
|
|
84
|
+
shadow mode for a while: every would-send decision lands in
|
|
85
|
+
`~/.cerber/autosend.ndjson`, and `cerber stats` shows how often the AI's
|
|
86
|
+
verdicts and comments survive your review — enable auto-send when its 90%
|
|
87
|
+
actually means 90%.
|
|
65
88
|
|
|
66
89
|
## Development
|
|
67
90
|
|
package/dist/cli/index.js
CHANGED
|
@@ -4,15 +4,16 @@ import readline from "node:readline/promises";
|
|
|
4
4
|
import { artifactId, artifactKey } from "../core/artifact.js";
|
|
5
5
|
import { toMarkdown } from "../core/export.js";
|
|
6
6
|
import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
7
|
-
import { buildReviewPayload, eventForRecommendation } from "../core/send.js";
|
|
8
|
-
import { cerberHome, listArtifacts, loadArtifact, updateArtifactByKey } from "../core/state.js";
|
|
7
|
+
import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
|
|
8
|
+
import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, updateArtifactByKey, } from "../core/state.js";
|
|
9
9
|
import { pool, reviewPr } from "../runner/review.js";
|
|
10
|
+
import { startDaemon } from "../server/daemon.js";
|
|
10
11
|
import { startServer } from "../server/index.js";
|
|
11
12
|
const program = new Command();
|
|
12
13
|
program
|
|
13
14
|
.name("cerber")
|
|
14
15
|
.description("AI code-review cockpit. Claude reviews PRs into local artifacts; nothing reaches GitHub until you explicitly send it.")
|
|
15
|
-
.version("0.
|
|
16
|
+
.version("0.5.0");
|
|
16
17
|
program
|
|
17
18
|
.command("review")
|
|
18
19
|
.description("Run an AI review of one or more PRs (local artifact only — no GitHub writes)")
|
|
@@ -154,18 +155,97 @@ program
|
|
|
154
155
|
await updateArtifactByKey(artifactKey(id), (a) => ({
|
|
155
156
|
...a,
|
|
156
157
|
status: "sent",
|
|
157
|
-
sent: { at: new Date().toISOString(), event, url },
|
|
158
|
+
sent: { at: new Date().toISOString(), event, url, auto: false },
|
|
159
|
+
calibration: computeCalibration(a, event),
|
|
158
160
|
}));
|
|
159
161
|
console.log(`✔ Review sent${url ? `: ${url}` : "."}`);
|
|
160
162
|
});
|
|
163
|
+
program
|
|
164
|
+
.command("stats")
|
|
165
|
+
.description("How well-calibrated the AI reviews are: verdict agreement and comment survival by confidence")
|
|
166
|
+
.action(async () => {
|
|
167
|
+
const artifacts = await listArtifacts();
|
|
168
|
+
const sent = artifacts.filter((a) => a.calibration);
|
|
169
|
+
if (sent.length === 0) {
|
|
170
|
+
console.log("No sent reviews with calibration data yet. Stats appear after you send reviews.");
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const buckets = [
|
|
174
|
+
[" <60%", (c) => c < 60],
|
|
175
|
+
["60-79%", (c) => c >= 60 && c < 80],
|
|
176
|
+
["80-89%", (c) => c >= 80 && c < 90],
|
|
177
|
+
[" 90%+", (c) => c >= 90],
|
|
178
|
+
];
|
|
179
|
+
console.log(`Calibration over ${sent.length} sent review(s):\n`);
|
|
180
|
+
console.log("confidence n verdict-agree ai-comments kept/edited/dropped");
|
|
181
|
+
for (const [label, match] of buckets) {
|
|
182
|
+
const group = sent.filter((a) => a.calibration.aiConfidence != null && match(a.calibration.aiConfidence));
|
|
183
|
+
if (group.length === 0)
|
|
184
|
+
continue;
|
|
185
|
+
const agree = group.filter((a) => a.calibration.aiRecommendation &&
|
|
186
|
+
eventForRecommendation(a.calibration.aiRecommendation) === a.calibration.sentEvent).length;
|
|
187
|
+
const totals = group.reduce((acc, a) => {
|
|
188
|
+
const c = a.calibration;
|
|
189
|
+
acc.total += c.aiCommentsTotal;
|
|
190
|
+
acc.dropped += c.aiCommentsDropped;
|
|
191
|
+
acc.edited += c.aiCommentsEdited;
|
|
192
|
+
return acc;
|
|
193
|
+
}, { total: 0, dropped: 0, edited: 0 });
|
|
194
|
+
const kept = totals.total - totals.dropped;
|
|
195
|
+
console.log(`${label} ${String(group.length).padEnd(3)} ${`${agree}/${group.length}`.padEnd(14)}` +
|
|
196
|
+
`${kept}/${totals.edited}/${totals.dropped} of ${totals.total}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const log = await readAutoSendLog();
|
|
200
|
+
if (log.length > 0) {
|
|
201
|
+
const wouldSend = log.filter((e) => e.decision.startsWith("would send")).length;
|
|
202
|
+
const autoSent = log.filter((e) => e.sent).length;
|
|
203
|
+
console.log(`\nAuto-send log (${cerberHome()}/autosend.ndjson): ${log.length} decision(s), ` +
|
|
204
|
+
`${wouldSend} shadow candidate(s), ${autoSent} auto-sent.`);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
161
207
|
program
|
|
162
208
|
.command("serve")
|
|
163
|
-
.description("Start the
|
|
209
|
+
.description("Start the review cockpit; --daemon keeps the queue warm by polling GitHub")
|
|
164
210
|
.option("-p, --port <port>", "port", "4820")
|
|
165
|
-
.option("-H, --host <host>", "host to bind", "127.0.0.1")
|
|
211
|
+
.option("-H, --host <host>", "host to bind (0.0.0.0 for VPS — requires --token)", "127.0.0.1")
|
|
212
|
+
.option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
|
|
213
|
+
.option("-d, --daemon", "poll for PRs awaiting your review and auto-review them (read-only)")
|
|
214
|
+
.option("-i, --interval <minutes>", "daemon poll interval", "5")
|
|
215
|
+
.option("-R, --repo <owner/repo>", "repo(s) the daemon watches (repeatable; default: all)", collect, [])
|
|
216
|
+
.option("-P, --parallel <n>", "daemon review concurrency", "3")
|
|
217
|
+
.option("-m, --model <model>", "Claude model override for daemon reviews")
|
|
218
|
+
.option("--auto-send", "ACTUALLY auto-send APPROVE verdicts at/above --auto-send-threshold (default: shadow mode, which only logs what would be sent)")
|
|
219
|
+
.option("--auto-send-threshold <pct>", "confidence needed to auto-send", "90")
|
|
220
|
+
.option("--insecure", "allow binding a non-localhost host without a token (NOT recommended)")
|
|
166
221
|
.action(async (opts) => {
|
|
167
|
-
|
|
222
|
+
const token = opts.token ?? process.env.CERBER_TOKEN;
|
|
223
|
+
const isLocal = ["127.0.0.1", "localhost", "::1"].includes(opts.host);
|
|
224
|
+
if (!isLocal && !token && !opts.insecure) {
|
|
225
|
+
console.error(`Refusing to bind ${opts.host} without auth: anyone reaching this port could send reviews as you.\n` +
|
|
226
|
+
`Pass --token <secret> (or CERBER_TOKEN), or --insecure if the network is trusted.`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
const threshold = Math.min(100, Math.max(50, Number(opts.autoSendThreshold) || 90));
|
|
230
|
+
if (opts.autoSend) {
|
|
231
|
+
console.log(`⚠ auto-send is ON: APPROVE verdicts with confidence ≥ ${threshold}% will be sent to GitHub as you, without a per-review confirmation.\n` +
|
|
232
|
+
` Every decision is logged to ${cerberHome()}/autosend.ndjson. COMMENT/REQUEST_CHANGES always wait for a human.`);
|
|
233
|
+
}
|
|
234
|
+
const daemon = opts.daemon
|
|
235
|
+
? startDaemon({
|
|
236
|
+
repos: opts.repo,
|
|
237
|
+
intervalMs: Math.max(1, Number(opts.interval) || 5) * 60_000,
|
|
238
|
+
parallel: Math.max(1, Number(opts.parallel) || 1),
|
|
239
|
+
model: opts.model,
|
|
240
|
+
autoSend: opts.autoSend ? "on" : "shadow",
|
|
241
|
+
autoSendThreshold: threshold,
|
|
242
|
+
})
|
|
243
|
+
: undefined;
|
|
244
|
+
await startServer({ port: Number(opts.port), host: opts.host, token, daemon });
|
|
168
245
|
});
|
|
246
|
+
function collect(value, previous) {
|
|
247
|
+
return [...previous, value];
|
|
248
|
+
}
|
|
169
249
|
program.parseAsync().catch((err) => {
|
|
170
250
|
console.error(err instanceof Error ? err.message : err);
|
|
171
251
|
process.exit(1);
|
package/dist/core/artifact.js
CHANGED
|
@@ -10,6 +10,8 @@ export const CommentSchema = z.object({
|
|
|
10
10
|
chapterId: z.string().nullable().default(null),
|
|
11
11
|
origin: z.enum(["ai", "user"]),
|
|
12
12
|
status: z.enum(["draft", "approved", "dropped"]).default("draft"),
|
|
13
|
+
/** Set when the user edits an AI comment's body — calibration signal. */
|
|
14
|
+
editedByUser: z.boolean().default(false),
|
|
13
15
|
});
|
|
14
16
|
/** A logical group of changes — the unit of the review walkthrough. */
|
|
15
17
|
export const ChapterSchema = z.object({
|
|
@@ -59,6 +61,18 @@ export const SentInfoSchema = z.object({
|
|
|
59
61
|
at: z.string(),
|
|
60
62
|
event: z.enum(["APPROVE", "COMMENT", "REQUEST_CHANGES"]),
|
|
61
63
|
url: z.string().nullable().default(null),
|
|
64
|
+
/** True when the daemon auto-sent this review (opt-in --auto-send). */
|
|
65
|
+
auto: z.boolean().default(false),
|
|
66
|
+
});
|
|
67
|
+
/** What actually happened vs what the AI proposed — recorded at send time. */
|
|
68
|
+
export const CalibrationSchema = z.object({
|
|
69
|
+
aiRecommendation: z.enum(["approve", "comment", "request_changes"]).nullable(),
|
|
70
|
+
aiConfidence: z.number().nullable(),
|
|
71
|
+
sentEvent: z.enum(["APPROVE", "COMMENT", "REQUEST_CHANGES"]),
|
|
72
|
+
aiCommentsTotal: z.number().int(),
|
|
73
|
+
aiCommentsDropped: z.number().int(),
|
|
74
|
+
aiCommentsEdited: z.number().int(),
|
|
75
|
+
userCommentsAdded: z.number().int(),
|
|
62
76
|
});
|
|
63
77
|
export const ArtifactSchema = z.object({
|
|
64
78
|
schemaVersion: z.literal(SCHEMA_VERSION),
|
|
@@ -76,8 +90,9 @@ export const ArtifactSchema = z.object({
|
|
|
76
90
|
comments: z.array(CommentSchema).default([]),
|
|
77
91
|
verdict: VerdictSchema.nullable().default(null),
|
|
78
92
|
run: RunInfoSchema.nullable().default(null),
|
|
79
|
-
/** Set once the
|
|
93
|
+
/** Set once the review was sent to GitHub (explicitly, or via opt-in auto-send). */
|
|
80
94
|
sent: SentInfoSchema.nullable().default(null),
|
|
95
|
+
calibration: CalibrationSchema.nullable().default(null),
|
|
81
96
|
});
|
|
82
97
|
/** What the AI must return. Subset of the artifact; cerber owns the rest. */
|
|
83
98
|
export const AiReviewSchema = z.object({
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-send policy — deliberately narrow:
|
|
3
|
+
* only APPROVE verdicts, at or above the confidence threshold, never a
|
|
4
|
+
* re-send. COMMENT and REQUEST_CHANGES always wait for a human.
|
|
5
|
+
*/
|
|
6
|
+
export function evaluateAutoSend(artifact, threshold) {
|
|
7
|
+
if (artifact.sent)
|
|
8
|
+
return { eligible: false, reason: "already sent" };
|
|
9
|
+
if (artifact.status !== "ready")
|
|
10
|
+
return { eligible: false, reason: `status is ${artifact.status}` };
|
|
11
|
+
if (!artifact.verdict)
|
|
12
|
+
return { eligible: false, reason: "no verdict" };
|
|
13
|
+
if (artifact.verdict.recommendation !== "approve") {
|
|
14
|
+
return {
|
|
15
|
+
eligible: false,
|
|
16
|
+
reason: `recommendation is ${artifact.verdict.recommendation} — only approve auto-sends`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (artifact.verdict.confidence < threshold) {
|
|
20
|
+
return {
|
|
21
|
+
eligible: false,
|
|
22
|
+
reason: `confidence ${artifact.verdict.confidence}% < threshold ${threshold}%`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return { eligible: true, reason: `approve at ${artifact.verdict.confidence}% ≥ ${threshold}%` };
|
|
26
|
+
}
|
package/dist/core/send.js
CHANGED
|
@@ -39,3 +39,16 @@ export function buildReviewPayload(artifact, event) {
|
|
|
39
39
|
parts.push("", "---", "_Reviewed with [cerber](https://github.com/fullstackhouse/cerber) 🐕 — drafted by AI, sent by a human._");
|
|
40
40
|
return { event, body: parts.join("\n").trim(), comments: inline, folded };
|
|
41
41
|
}
|
|
42
|
+
/** Snapshot of AI-proposal vs human-final, recorded at send time for `cerber stats`. */
|
|
43
|
+
export function computeCalibration(artifact, event) {
|
|
44
|
+
const ai = artifact.comments.filter((c) => c.origin === "ai");
|
|
45
|
+
return {
|
|
46
|
+
aiRecommendation: artifact.verdict?.recommendation ?? null,
|
|
47
|
+
aiConfidence: artifact.verdict?.confidence ?? null,
|
|
48
|
+
sentEvent: event,
|
|
49
|
+
aiCommentsTotal: ai.length,
|
|
50
|
+
aiCommentsDropped: ai.filter((c) => c.status === "dropped").length,
|
|
51
|
+
aiCommentsEdited: ai.filter((c) => c.editedByUser).length,
|
|
52
|
+
userCommentsAdded: artifact.comments.filter((c) => c.origin === "user").length,
|
|
53
|
+
};
|
|
54
|
+
}
|
package/dist/core/state.js
CHANGED
|
@@ -41,6 +41,32 @@ export async function loadArtifactByKey(key) {
|
|
|
41
41
|
throw err;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
/** Append to ~/.cerber/autosend.ndjson — the shadow/auto-send audit log. */
|
|
45
|
+
export async function appendAutoSendLog(entry) {
|
|
46
|
+
await fs.mkdir(cerberHome(), { recursive: true });
|
|
47
|
+
await fs.appendFile(path.join(cerberHome(), "autosend.ndjson"), JSON.stringify(entry) + "\n");
|
|
48
|
+
}
|
|
49
|
+
export async function readAutoSendLog() {
|
|
50
|
+
try {
|
|
51
|
+
const raw = await fs.readFile(path.join(cerberHome(), "autosend.ndjson"), "utf8");
|
|
52
|
+
return raw
|
|
53
|
+
.split("\n")
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.flatMap((line) => {
|
|
56
|
+
try {
|
|
57
|
+
return [JSON.parse(line)];
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err.code === "ENOENT")
|
|
66
|
+
return [];
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
44
70
|
/** Load by key, apply a mutation, bump updatedAt, save. Returns the new artifact. */
|
|
45
71
|
export async function updateArtifactByKey(key, mutate) {
|
|
46
72
|
const artifact = await loadArtifactByKey(key);
|
package/dist/runner/review.js
CHANGED
|
@@ -40,6 +40,7 @@ export async function reviewPr(ref, opts = {}) {
|
|
|
40
40
|
verdict: null,
|
|
41
41
|
run: { model: opts.model ?? null, startedAt: now(), finishedAt: null, costUsd: null, error: null },
|
|
42
42
|
sent: null,
|
|
43
|
+
calibration: null,
|
|
43
44
|
};
|
|
44
45
|
await saveArtifact(artifact);
|
|
45
46
|
const { prompt, truncated } = buildReviewPrompt(pr, diff);
|
|
@@ -70,6 +71,7 @@ export async function reviewPr(ref, opts = {}) {
|
|
|
70
71
|
chapterId: c.chapterId ?? null,
|
|
71
72
|
origin: "ai",
|
|
72
73
|
status: "draft",
|
|
74
|
+
editedByUser: false,
|
|
73
75
|
})),
|
|
74
76
|
verdict: review.ai.verdict,
|
|
75
77
|
run: {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { artifactKey } from "../core/artifact.js";
|
|
2
|
+
import { evaluateAutoSend } from "../core/autosend.js";
|
|
3
|
+
import { searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
4
|
+
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
5
|
+
import { appendAutoSendLog, updateArtifactByKey } from "../core/state.js";
|
|
6
|
+
import { pool, reviewPr } from "../runner/review.js";
|
|
7
|
+
/**
|
|
8
|
+
* Poll GitHub for PRs awaiting the user's review and keep their artifacts
|
|
9
|
+
* warm. Reviews are read-only; the daemon NEVER sends anything.
|
|
10
|
+
*/
|
|
11
|
+
export function startDaemon(opts) {
|
|
12
|
+
let timer = null;
|
|
13
|
+
let stopped = false;
|
|
14
|
+
const status = {
|
|
15
|
+
enabled: true,
|
|
16
|
+
polling: false,
|
|
17
|
+
repos: opts.repos,
|
|
18
|
+
intervalMs: opts.intervalMs,
|
|
19
|
+
polls: 0,
|
|
20
|
+
errors: 0,
|
|
21
|
+
lastPollAt: null,
|
|
22
|
+
nextPollAt: new Date(Date.now()).toISOString(),
|
|
23
|
+
lastSummary: null,
|
|
24
|
+
autoSend: opts.autoSend,
|
|
25
|
+
autoSendThreshold: opts.autoSendThreshold,
|
|
26
|
+
autoSent: 0,
|
|
27
|
+
autoSendCandidates: 0,
|
|
28
|
+
};
|
|
29
|
+
const log = (m) => console.log(`[daemon] ${m}`);
|
|
30
|
+
async function poll() {
|
|
31
|
+
if (status.polling)
|
|
32
|
+
return;
|
|
33
|
+
status.polling = true;
|
|
34
|
+
status.lastPollAt = new Date().toISOString();
|
|
35
|
+
try {
|
|
36
|
+
const filters = opts.repos.length > 0 ? opts.repos : [undefined];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const refs = (await Promise.all(filters.map((repo) => searchAwaitingMe(repo))))
|
|
39
|
+
.flat()
|
|
40
|
+
.filter((r) => {
|
|
41
|
+
const id = `${r.owner}/${r.repo}#${r.number}`;
|
|
42
|
+
if (seen.has(id))
|
|
43
|
+
return false;
|
|
44
|
+
seen.add(id);
|
|
45
|
+
return true;
|
|
46
|
+
});
|
|
47
|
+
let reviewed = 0;
|
|
48
|
+
let skipped = 0;
|
|
49
|
+
let failed = 0;
|
|
50
|
+
await pool(refs, opts.parallel, async (ref) => {
|
|
51
|
+
const label = `${ref.owner}/${ref.repo}#${ref.number}`;
|
|
52
|
+
try {
|
|
53
|
+
const result = await reviewPr(ref, {
|
|
54
|
+
model: opts.model,
|
|
55
|
+
onProgress: (m) => log(`[${label}] ${m}`),
|
|
56
|
+
});
|
|
57
|
+
if (result.skipped)
|
|
58
|
+
skipped++;
|
|
59
|
+
else {
|
|
60
|
+
reviewed++;
|
|
61
|
+
await handleAutoSend(result.artifact);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
failed++;
|
|
66
|
+
status.errors++;
|
|
67
|
+
log(`[${label}] failed: ${err instanceof Error ? err.message : err}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
status.lastSummary = `${refs.length} awaiting; ${reviewed} reviewed, ${skipped} up to date${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
71
|
+
log(status.lastSummary);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
status.errors++;
|
|
75
|
+
status.lastSummary = `poll failed: ${err instanceof Error ? err.message : err}`;
|
|
76
|
+
log(status.lastSummary);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
status.polls++;
|
|
80
|
+
status.polling = false;
|
|
81
|
+
if (!stopped) {
|
|
82
|
+
status.nextPollAt = new Date(Date.now() + opts.intervalMs).toISOString();
|
|
83
|
+
timer = setTimeout(poll, opts.intervalMs);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function handleAutoSend(artifact) {
|
|
88
|
+
const decision = evaluateAutoSend(artifact, opts.autoSendThreshold);
|
|
89
|
+
if (decision.eligible)
|
|
90
|
+
status.autoSendCandidates++;
|
|
91
|
+
const entryBase = {
|
|
92
|
+
at: new Date().toISOString(),
|
|
93
|
+
id: artifact.id,
|
|
94
|
+
recommendation: artifact.verdict?.recommendation ?? null,
|
|
95
|
+
confidence: artifact.verdict?.confidence ?? null,
|
|
96
|
+
mode: opts.autoSend,
|
|
97
|
+
};
|
|
98
|
+
if (!decision.eligible) {
|
|
99
|
+
await appendAutoSendLog({ ...entryBase, decision: decision.reason, sent: false });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (opts.autoSend === "shadow") {
|
|
103
|
+
log(`[${artifact.id}] WOULD auto-send: ${decision.reason} (shadow mode — nothing sent)`);
|
|
104
|
+
await appendAutoSendLog({ ...entryBase, decision: `would send: ${decision.reason}`, sent: false });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const payload = buildReviewPayload(artifact, "APPROVE");
|
|
109
|
+
const { url } = await submitReview({ owner: artifact.pr.owner, repo: artifact.pr.repo, number: artifact.pr.number }, { event: payload.event, body: payload.body, comments: payload.comments });
|
|
110
|
+
await updateArtifactByKey(artifactKey(artifact.id), (a) => ({
|
|
111
|
+
...a,
|
|
112
|
+
status: "sent",
|
|
113
|
+
sent: { at: new Date().toISOString(), event: "APPROVE", url, auto: true },
|
|
114
|
+
calibration: computeCalibration(a, "APPROVE"),
|
|
115
|
+
}));
|
|
116
|
+
status.autoSent++;
|
|
117
|
+
log(`[${artifact.id}] AUTO-SENT approve (${decision.reason})${url ? ` — ${url}` : ""}`);
|
|
118
|
+
await appendAutoSendLog({ ...entryBase, decision: `auto-sent: ${decision.reason}`, sent: true });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
status.errors++;
|
|
122
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
123
|
+
log(`[${artifact.id}] auto-send failed: ${message}`);
|
|
124
|
+
await appendAutoSendLog({ ...entryBase, decision: `auto-send failed: ${message}`, sent: false });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
void poll();
|
|
128
|
+
return {
|
|
129
|
+
status: () => ({ ...status }),
|
|
130
|
+
stop: () => {
|
|
131
|
+
stopped = true;
|
|
132
|
+
if (timer)
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { serve } from "@hono/node-server";
|
|
2
2
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
4
5
|
import { promises as fs } from "node:fs";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -8,11 +9,28 @@ import { randomUUID } from "node:crypto";
|
|
|
8
9
|
import { ArtifactStatusSchema, VerdictSchema, artifactKey } from "../core/artifact.js";
|
|
9
10
|
import { toMarkdown } from "../core/export.js";
|
|
10
11
|
import { submitReview } from "../core/gh.js";
|
|
11
|
-
import { buildReviewPayload } from "../core/send.js";
|
|
12
|
+
import { buildReviewPayload, computeCalibration } from "../core/send.js";
|
|
12
13
|
import { listArtifacts, loadArtifactByKey, updateArtifactByKey } from "../core/state.js";
|
|
13
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
export async function
|
|
15
|
+
export async function buildApp(opts) {
|
|
15
16
|
const app = new Hono();
|
|
17
|
+
if (opts.token) {
|
|
18
|
+
const token = opts.token;
|
|
19
|
+
app.use("*", async (c, next) => {
|
|
20
|
+
const query = c.req.query("token");
|
|
21
|
+
const cookie = getCookie(c, "cerber_token");
|
|
22
|
+
const header = c.req.header("authorization");
|
|
23
|
+
if (query === token || cookie === token || header === `Bearer ${token}`) {
|
|
24
|
+
if (query === token && cookie !== token) {
|
|
25
|
+
// First visit via ?token=… — set the cookie so the SPA's asset and API requests pass.
|
|
26
|
+
setCookie(c, "cerber_token", token, { httpOnly: true, sameSite: "Strict", path: "/" });
|
|
27
|
+
}
|
|
28
|
+
return next();
|
|
29
|
+
}
|
|
30
|
+
return c.text("unauthorized — pass ?token=… or Authorization: Bearer …\n", 401);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
app.get("/api/daemon", (c) => c.json(opts.daemon ? opts.daemon.status() : { enabled: false }));
|
|
16
34
|
app.get("/api/reviews", async (c) => {
|
|
17
35
|
const artifacts = await listArtifacts();
|
|
18
36
|
return c.json(artifacts.map((a) => ({
|
|
@@ -74,6 +92,9 @@ export async function startServer(opts) {
|
|
|
74
92
|
...(body.status !== undefined
|
|
75
93
|
? { status: body.status }
|
|
76
94
|
: {}),
|
|
95
|
+
...(body.body !== undefined && body.body !== cm.body && cm.origin === "ai"
|
|
96
|
+
? { editedByUser: true }
|
|
97
|
+
: {}),
|
|
77
98
|
}
|
|
78
99
|
: cm),
|
|
79
100
|
}));
|
|
@@ -95,6 +116,7 @@ export async function startServer(opts) {
|
|
|
95
116
|
chapterId: body.chapterId ?? null,
|
|
96
117
|
origin: "user",
|
|
97
118
|
status: "draft",
|
|
119
|
+
editedByUser: false,
|
|
98
120
|
},
|
|
99
121
|
],
|
|
100
122
|
}));
|
|
@@ -142,7 +164,8 @@ export async function startServer(opts) {
|
|
|
142
164
|
const updated = await updateArtifactByKey(c.req.param("key"), (a) => ({
|
|
143
165
|
...a,
|
|
144
166
|
status: "sent",
|
|
145
|
-
sent: { at: new Date().toISOString(), event: payload.event, url },
|
|
167
|
+
sent: { at: new Date().toISOString(), event: payload.event, url, auto: false },
|
|
168
|
+
calibration: computeCalibration(a, payload.event),
|
|
146
169
|
}));
|
|
147
170
|
return c.json(updated);
|
|
148
171
|
}
|
|
@@ -172,7 +195,14 @@ export async function startServer(opts) {
|
|
|
172
195
|
else {
|
|
173
196
|
app.get("/", (c) => c.text("cerber API is running, but the web cockpit is not built.\nRun `pnpm build` in the cerber repo, or use the API: GET /api/reviews\n"));
|
|
174
197
|
}
|
|
198
|
+
return app;
|
|
199
|
+
}
|
|
200
|
+
export async function startServer(opts) {
|
|
201
|
+
const app = await buildApp(opts);
|
|
175
202
|
serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
|
|
176
|
-
|
|
203
|
+
const tokenHint = opts.token ? `/?token=${opts.token}` : "";
|
|
204
|
+
console.log(`cerber cockpit: http://${opts.host}:${info.port}${tokenHint}`);
|
|
205
|
+
if (opts.daemon)
|
|
206
|
+
console.log("daemon: polling for PRs awaiting your review");
|
|
177
207
|
});
|
|
178
208
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
"repository": "github:fullstackhouse/cerber",
|
|
7
7
|
"type": "module",
|
|
8
|
+
"packageManager": "pnpm@10.33.2",
|
|
8
9
|
"bin": {
|
|
9
10
|
"cerber": "dist/cli/index.js"
|
|
10
11
|
},
|
|
@@ -36,6 +37,8 @@
|
|
|
36
37
|
"@types/react": "^19.0.4",
|
|
37
38
|
"@types/react-dom": "^19.0.2",
|
|
38
39
|
"@vitejs/plugin-react": "^4.3.4",
|
|
40
|
+
"dompurify": "^3.4.13",
|
|
41
|
+
"marked": "^18.0.10",
|
|
39
42
|
"react": "^19.0.0",
|
|
40
43
|
"react-dom": "^19.0.0",
|
|
41
44
|
"tsx": "^4.19.2",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
:host,:root{--d2h-bg-color:#fff;--d2h-border-color:#ddd;--d2h-dim-color:rgba(0,0,0,.3);--d2h-line-border-color:#eee;--d2h-file-header-bg-color:#f7f7f7;--d2h-file-header-border-color:#d8d8d8;--d2h-empty-placeholder-bg-color:#f1f1f1;--d2h-empty-placeholder-border-color:#e1e1e1;--d2h-selected-color:#c8e1ff;--d2h-ins-bg-color:#dfd;--d2h-ins-border-color:#b4e2b4;--d2h-ins-highlight-bg-color:#97f295;--d2h-ins-label-color:#399839;--d2h-del-bg-color:#fee8e9;--d2h-del-border-color:#e9aeae;--d2h-del-highlight-bg-color:#ffb6ba;--d2h-del-label-color:#c33;--d2h-change-del-color:#fdf2d0;--d2h-change-ins-color:#ded;--d2h-info-bg-color:#f8fafd;--d2h-info-border-color:#d5e4f2;--d2h-change-label-color:#d0b44c;--d2h-moved-label-color:#3572b0;--d2h-dark-color:#e6edf3;--d2h-dark-bg-color:#0d1117;--d2h-dark-border-color:#30363d;--d2h-dark-dim-color:#6e7681;--d2h-dark-line-border-color:#21262d;--d2h-dark-file-header-bg-color:#161b22;--d2h-dark-file-header-border-color:#30363d;--d2h-dark-empty-placeholder-bg-color:hsla(215,8%,47%,.1);--d2h-dark-empty-placeholder-border-color:#30363d;--d2h-dark-selected-color:rgba(56,139,253,.1);--d2h-dark-ins-bg-color:rgba(46,160,67,.15);--d2h-dark-ins-border-color:rgba(46,160,67,.4);--d2h-dark-ins-highlight-bg-color:rgba(46,160,67,.4);--d2h-dark-ins-label-color:#3fb950;--d2h-dark-del-bg-color:rgba(248,81,73,.1);--d2h-dark-del-border-color:rgba(248,81,73,.4);--d2h-dark-del-highlight-bg-color:rgba(248,81,73,.4);--d2h-dark-del-label-color:#f85149;--d2h-dark-change-del-color:rgba(210,153,34,.2);--d2h-dark-change-ins-color:rgba(46,160,67,.25);--d2h-dark-info-bg-color:rgba(56,139,253,.1);--d2h-dark-info-border-color:rgba(56,139,253,.4);--d2h-dark-change-label-color:#d29922;--d2h-dark-moved-label-color:#3572b0}.d2h-wrapper{text-align:left}.d2h-file-header{background-color:#f7f7f7;background-color:var(--d2h-file-header-bg-color);border-bottom:1px solid #d8d8d8;border-bottom:1px solid var(--d2h-file-header-border-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-family:Source Sans Pro,Helvetica Neue,Helvetica,Arial,sans-serif;height:35px;padding:5px 10px}.d2h-file-header.d2h-sticky-header{position:sticky;top:0;z-index:1}.d2h-file-stats{display:-webkit-box;display:-ms-flexbox;display:flex;font-size:14px;margin-left:auto}.d2h-lines-added{border:1px solid #b4e2b4;border:1px solid var(--d2h-ins-border-color);border-radius:5px 0 0 5px;color:#399839;color:var(--d2h-ins-label-color);padding:2px;text-align:right;vertical-align:middle}.d2h-lines-deleted{border:1px solid #e9aeae;border:1px solid var(--d2h-del-border-color);border-radius:0 5px 5px 0;color:#c33;color:var(--d2h-del-label-color);margin-left:1px;padding:2px;text-align:left;vertical-align:middle}.d2h-file-name-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;font-size:15px;width:100%}.d2h-file-name{overflow-x:hidden;text-overflow:ellipsis;white-space:nowrap}.d2h-file-wrapper{border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;margin-bottom:1em}.d2h-file-collapse{-webkit-box-pack:end;-ms-flex-pack:end;cursor:pointer;display:none;font-size:12px;justify-content:flex-end;-webkit-box-align:center;-ms-flex-align:center;align-items:center;border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;padding:4px 8px}.d2h-file-collapse.d2h-selected{background-color:#c8e1ff;background-color:var(--d2h-selected-color)}.d2h-file-collapse-input{margin:0 4px 0 0}.d2h-diff-table{border-collapse:collapse;font-family:Menlo,Consolas,monospace;font-size:13px;width:100%}.d2h-files-diff{display:-webkit-box;display:-ms-flexbox;display:flex;width:100%}.d2h-file-diff{overflow-y:hidden}.d2h-file-diff.d2h-d-none,.d2h-files-diff.d2h-d-none{display:none}.d2h-file-side-diff{display:inline-block;overflow-x:scroll;overflow-y:hidden;width:50%}.d2h-code-line{padding:0 8em;width:calc(100% - 16em)}.d2h-code-line,.d2h-code-side-line{display:inline-block;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;white-space:nowrap}.d2h-code-side-line{padding:0 4.5em;width:calc(100% - 9em)}.d2h-code-line-ctn{background:none;display:inline-block;padding:0;word-wrap:normal;-webkit-user-select:text;-moz-user-select:text;-ms-user-select:text;user-select:text;vertical-align:middle;white-space:pre;width:100%}.d2h-code-line del,.d2h-code-side-line del{background-color:#ffb6ba;background-color:var(--d2h-del-highlight-bg-color)}.d2h-code-line del,.d2h-code-line ins,.d2h-code-side-line del,.d2h-code-side-line ins{border-radius:.2em;display:inline-block;margin-top:-1px;-webkit-text-decoration:none;text-decoration:none}.d2h-code-line ins,.d2h-code-side-line ins{background-color:#97f295;background-color:var(--d2h-ins-highlight-bg-color);text-align:left}.d2h-code-line-prefix{background:none;display:inline;padding:0;word-wrap:normal;white-space:pre}.line-num1{float:left}.line-num1,.line-num2{-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;padding:0 .5em;text-overflow:ellipsis;width:3.5em}.line-num2{float:right}.d2h-code-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#0000004d;color:var(--d2h-dim-color);cursor:pointer;display:inline-block;position:absolute;text-align:right;width:7.5em}.d2h-code-linenumber:after{content:""}.d2h-code-side-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#0000004d;color:var(--d2h-dim-color);cursor:pointer;display:inline-block;overflow:hidden;padding:0 .5em;position:absolute;text-align:right;text-overflow:ellipsis;width:4em}.d2h-code-side-linenumber:after{content:""}.d2h-code-side-emptyplaceholder,.d2h-emptyplaceholder{background-color:#f1f1f1;background-color:var(--d2h-empty-placeholder-bg-color);border-color:#e1e1e1;border-color:var(--d2h-empty-placeholder-border-color)}.d2h-code-line-prefix,.d2h-code-linenumber,.d2h-code-side-linenumber,.d2h-emptyplaceholder{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.d2h-code-linenumber,.d2h-code-side-linenumber{direction:rtl}.d2h-del{background-color:#fee8e9;background-color:var(--d2h-del-bg-color);border-color:#e9aeae;border-color:var(--d2h-del-border-color)}.d2h-ins{background-color:#dfd;background-color:var(--d2h-ins-bg-color);border-color:#b4e2b4;border-color:var(--d2h-ins-border-color)}.d2h-info{background-color:#f8fafd;background-color:var(--d2h-info-bg-color);border-color:#d5e4f2;border-color:var(--d2h-info-border-color);color:#0000004d;color:var(--d2h-dim-color)}.d2h-file-diff .d2h-del.d2h-change{background-color:#fdf2d0;background-color:var(--d2h-change-del-color)}.d2h-file-diff .d2h-ins.d2h-change{background-color:#ded;background-color:var(--d2h-change-ins-color)}.d2h-file-list-wrapper{margin-bottom:10px}.d2h-file-list-wrapper a{-webkit-text-decoration:none;text-decoration:none}.d2h-file-list-wrapper a,.d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-file-list-header{text-align:left}.d2h-file-list-title{font-weight:700}.d2h-file-list-line{display:-webkit-box;display:-ms-flexbox;display:flex;text-align:left}.d2h-file-list{display:block;list-style:none;margin:0;padding:0}.d2h-file-list>li{border-bottom:1px solid #ddd;border-bottom:1px solid var(--d2h-border-color);margin:0;padding:5px 10px}.d2h-file-list>li:last-child{border-bottom:none}.d2h-file-switch{cursor:pointer;display:none;font-size:10px}.d2h-icon{fill:currentColor;margin-right:10px;vertical-align:middle}.d2h-deleted{color:#c33;color:var(--d2h-del-label-color)}.d2h-added{color:#399839;color:var(--d2h-ins-label-color)}.d2h-changed{color:#d0b44c;color:var(--d2h-change-label-color)}.d2h-moved{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-tag{background-color:#fff;background-color:var(--d2h-bg-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-size:10px;margin-left:5px;padding:0 2px}.d2h-deleted-tag{border:1px solid #c33;border:1px solid var(--d2h-del-label-color)}.d2h-added-tag{border:1px solid #399839;border:1px solid var(--d2h-ins-label-color)}.d2h-changed-tag{border:1px solid #d0b44c;border:1px solid var(--d2h-change-label-color)}.d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-moved-label-color)}.d2h-dark-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-dark-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-dark-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-code-line del,.d2h-dark-color-scheme .d2h-code-side-line del{background-color:#f8514966;background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-dark-color-scheme .d2h-code-line ins,.d2h-dark-color-scheme .d2h-code-side-line ins{background-color:#2ea04366;background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-dark-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:#6e76811a;background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-dark-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-del{background-color:#f851491a;background-color:var(--d2h-dark-del-bg-color);border-color:#f8514966;border-color:var(--d2h-dark-del-border-color)}.d2h-dark-color-scheme .d2h-ins{background-color:#2ea04326;background-color:var(--d2h-dark-ins-bg-color);border-color:#2ea04366;border-color:var(--d2h-dark-ins-border-color)}.d2h-dark-color-scheme .d2h-info{background-color:#388bfd1a;background-color:var(--d2h-dark-info-bg-color);border-color:#388bfd66;border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:#d2992233;background-color:var(--d2h-dark-change-del-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:#2ea04340;background-color:var(--d2h-dark-change-ins-color)}.d2h-dark-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected{background-color:#388bfd1a;background-color:var(--d2h-dark-selected-color)}.d2h-dark-color-scheme .d2h-file-list-wrapper a,.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}@media(prefers-color-scheme:dark){.d2h-auto-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-auto-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-auto-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-code-line del,.d2h-auto-color-scheme .d2h-code-side-line del{background-color:#f8514966;background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-auto-color-scheme .d2h-code-line ins,.d2h-auto-color-scheme .d2h-code-side-line ins{background-color:#2ea04366;background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-auto-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:#6e76811a;background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-auto-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-del{background-color:#f851491a;background-color:var(--d2h-dark-del-bg-color);border-color:#f8514966;border-color:var(--d2h-dark-del-border-color)}.d2h-auto-color-scheme .d2h-ins{background-color:#2ea04326;background-color:var(--d2h-dark-ins-bg-color);border-color:#2ea04366;border-color:var(--d2h-dark-ins-border-color)}.d2h-auto-color-scheme .d2h-info{background-color:#388bfd1a;background-color:var(--d2h-dark-info-bg-color);border-color:#388bfd66;border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:#d2992233;background-color:var(--d2h-dark-change-del-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:#2ea04340;background-color:var(--d2h-dark-change-ins-color)}.d2h-auto-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected{background-color:#388bfd1a;background-color:var(--d2h-dark-selected-color)}.d2h-auto-color-scheme .d2h-file-list-wrapper a,.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}}:root{--bg: #ffffff;--fg: #1f2328;--muted: #656d76;--border: #d0d7de;--green: #1a7f37;--red: #cf222e;--yellow: #9a6700;--accent: #0969da}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:var(--fg);background:var(--bg)}.app{max-width:1100px;margin:0 auto;padding:0 16px 64px}.topbar{display:flex;align-items:baseline;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand{font-size:20px;font-weight:700;text-decoration:none;color:var(--fg)}.tagline{color:var(--muted);font-size:13px}a{color:var(--accent)}.muted{color:var(--muted);font-size:.92em}.error{color:var(--red);background:#ffebe9;border:1px solid rgba(207,34,46,.3);padding:8px 12px;border-radius:6px}.empty{text-align:center;color:var(--muted);margin-top:64px}.empty pre{display:inline-block;background:#f6f8fa;padding:8px 16px;border-radius:6px}.queue-stats{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}.queue{width:100%;border-collapse:collapse;font-size:14px}.queue th{text-align:left;color:var(--muted);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--border)}.queue td{padding:8px;border-bottom:1px solid var(--border);vertical-align:top}.pr-link{font-weight:600;text-decoration:none}.status{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}.status-ready{background:#dafbe1;border-color:#1a7f3766}.status-running{background:#ddf4ff;border-color:#0969da66}.status-failed{background:#ffebe9;border-color:#cf222e66}.badge{font-size:12px;padding:2px 8px;border-radius:999px;white-space:nowrap}.badge-green{background:#dafbe1;color:var(--green)}.badge-yellow{background:#fff8c5;color:var(--yellow)}.badge-red{background:#ffebe9;color:var(--red)}.badge-muted{color:var(--muted)}.add{color:var(--green)}.del{color:var(--red)}.back{display:inline-block;margin:8px 0;text-decoration:none}.detail h1{font-size:22px;margin:4px 0}.verdict{border:1px solid var(--border);border-left-width:4px;border-radius:6px;padding:8px 12px;margin:12px 0}.verdict-approve{border-left-color:var(--green)}.verdict-comment{border-left-color:var(--yellow)}.verdict-request_changes{border-left-color:var(--red)}.verdict p{margin:4px 0 0;color:var(--muted);font-size:14px}.summary-body{white-space:pre-wrap;line-height:1.55}.chapter{border:1px solid var(--border);border-radius:6px;margin:12px 0;padding:4px 16px 12px}.chapter-title{cursor:pointer;-webkit-user-select:none;user-select:none;font-size:16px}.chapter-explanation{white-space:pre-wrap;line-height:1.5}.comment{border:1px solid var(--border);border-left:4px solid var(--yellow);border-radius:6px;padding:8px 12px;margin:8px 0;font-size:14px}.comment-loc{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;color:var(--muted);margin-bottom:4px}.comment-body{white-space:pre-wrap;line-height:1.5}.diff{margin-top:8px;font-size:12px}button{font:inherit;font-size:12px;padding:2px 10px;border:1px solid var(--border);border-radius:6px;background:#f6f8fa;cursor:pointer}button:hover{background:#eef1f4}.comment-actions{margin-left:8px;display:inline-flex;gap:4px}.comment-dropped{opacity:.5}.comment-approved{border-left-color:var(--green)}.comment-edit,.add-comment textarea{width:100%;font:inherit;font-size:14px;padding:6px 8px;border:1px solid var(--border);border-radius:6px}.add-comment{border:1px dashed var(--border);border-radius:6px;padding:8px 12px;margin:8px 0;display:flex;flex-direction:column;gap:6px}.add-comment-row{display:flex;gap:8px;align-items:center}.add-comment select,.add-comment input{font:inherit;font-size:13px;padding:4px 6px;border:1px solid var(--border);border-radius:6px}.add-comment-btn{margin:8px 0}.verdict-override{margin-left:12px;font-size:13px;padding:2px 6px;border:1px solid var(--border);border-radius:6px}.send-panel{border:2px solid var(--accent);border-radius:8px;padding:12px 16px;margin:24px 0}.send-panel.sent{border-color:var(--green);background:#dafbe1}.send-panel h2{margin:0 0 4px}.send-controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.send-controls select{font:inherit;font-size:14px;padding:4px 8px;border:1px solid var(--border);border-radius:6px}.send-btn{background:var(--accent);color:#fff;border-color:var(--accent);font-size:14px;padding:6px 14px}.send-btn:hover{background:#0757b3}.send-btn-confirm{background:var(--red);border-color:var(--red)}.send-btn-confirm:hover{background:#a91e28}.confirm-group{display:inline-flex;gap:8px;align-items:center}.body-preview{background:#f6f8fa;border:1px solid var(--border);border-radius:6px;padding:12px;white-space:pre-wrap;font-size:13px;max-height:400px;overflow:auto}.export-link{font-size:13px}.post-actions{margin-top:16px}.diff .d2h-file-header{font-family:ui-monospace,SFMono-Regular,monospace}
|
|
1
|
+
:host,:root{--d2h-bg-color:#fff;--d2h-border-color:#ddd;--d2h-dim-color:rgba(0,0,0,.3);--d2h-line-border-color:#eee;--d2h-file-header-bg-color:#f7f7f7;--d2h-file-header-border-color:#d8d8d8;--d2h-empty-placeholder-bg-color:#f1f1f1;--d2h-empty-placeholder-border-color:#e1e1e1;--d2h-selected-color:#c8e1ff;--d2h-ins-bg-color:#dfd;--d2h-ins-border-color:#b4e2b4;--d2h-ins-highlight-bg-color:#97f295;--d2h-ins-label-color:#399839;--d2h-del-bg-color:#fee8e9;--d2h-del-border-color:#e9aeae;--d2h-del-highlight-bg-color:#ffb6ba;--d2h-del-label-color:#c33;--d2h-change-del-color:#fdf2d0;--d2h-change-ins-color:#ded;--d2h-info-bg-color:#f8fafd;--d2h-info-border-color:#d5e4f2;--d2h-change-label-color:#d0b44c;--d2h-moved-label-color:#3572b0;--d2h-dark-color:#e6edf3;--d2h-dark-bg-color:#0d1117;--d2h-dark-border-color:#30363d;--d2h-dark-dim-color:#6e7681;--d2h-dark-line-border-color:#21262d;--d2h-dark-file-header-bg-color:#161b22;--d2h-dark-file-header-border-color:#30363d;--d2h-dark-empty-placeholder-bg-color:hsla(215,8%,47%,.1);--d2h-dark-empty-placeholder-border-color:#30363d;--d2h-dark-selected-color:rgba(56,139,253,.1);--d2h-dark-ins-bg-color:rgba(46,160,67,.15);--d2h-dark-ins-border-color:rgba(46,160,67,.4);--d2h-dark-ins-highlight-bg-color:rgba(46,160,67,.4);--d2h-dark-ins-label-color:#3fb950;--d2h-dark-del-bg-color:rgba(248,81,73,.1);--d2h-dark-del-border-color:rgba(248,81,73,.4);--d2h-dark-del-highlight-bg-color:rgba(248,81,73,.4);--d2h-dark-del-label-color:#f85149;--d2h-dark-change-del-color:rgba(210,153,34,.2);--d2h-dark-change-ins-color:rgba(46,160,67,.25);--d2h-dark-info-bg-color:rgba(56,139,253,.1);--d2h-dark-info-border-color:rgba(56,139,253,.4);--d2h-dark-change-label-color:#d29922;--d2h-dark-moved-label-color:#3572b0}.d2h-wrapper{text-align:left}.d2h-file-header{background-color:#f7f7f7;background-color:var(--d2h-file-header-bg-color);border-bottom:1px solid #d8d8d8;border-bottom:1px solid var(--d2h-file-header-border-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-family:Source Sans Pro,Helvetica Neue,Helvetica,Arial,sans-serif;height:35px;padding:5px 10px}.d2h-file-header.d2h-sticky-header{position:sticky;top:0;z-index:1}.d2h-file-stats{display:-webkit-box;display:-ms-flexbox;display:flex;font-size:14px;margin-left:auto}.d2h-lines-added{border:1px solid #b4e2b4;border:1px solid var(--d2h-ins-border-color);border-radius:5px 0 0 5px;color:#399839;color:var(--d2h-ins-label-color);padding:2px;text-align:right;vertical-align:middle}.d2h-lines-deleted{border:1px solid #e9aeae;border:1px solid var(--d2h-del-border-color);border-radius:0 5px 5px 0;color:#c33;color:var(--d2h-del-label-color);margin-left:1px;padding:2px;text-align:left;vertical-align:middle}.d2h-file-name-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;font-size:15px;width:100%}.d2h-file-name{overflow-x:hidden;text-overflow:ellipsis;white-space:nowrap}.d2h-file-wrapper{border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;margin-bottom:1em}.d2h-file-collapse{-webkit-box-pack:end;-ms-flex-pack:end;cursor:pointer;display:none;font-size:12px;justify-content:flex-end;-webkit-box-align:center;-ms-flex-align:center;align-items:center;border:1px solid #ddd;border:1px solid var(--d2h-border-color);border-radius:3px;padding:4px 8px}.d2h-file-collapse.d2h-selected{background-color:#c8e1ff;background-color:var(--d2h-selected-color)}.d2h-file-collapse-input{margin:0 4px 0 0}.d2h-diff-table{border-collapse:collapse;font-family:Menlo,Consolas,monospace;font-size:13px;width:100%}.d2h-files-diff{display:-webkit-box;display:-ms-flexbox;display:flex;width:100%}.d2h-file-diff{overflow-y:hidden}.d2h-file-diff.d2h-d-none,.d2h-files-diff.d2h-d-none{display:none}.d2h-file-side-diff{display:inline-block;overflow-x:scroll;overflow-y:hidden;width:50%}.d2h-code-line{padding:0 8em;width:calc(100% - 16em)}.d2h-code-line,.d2h-code-side-line{display:inline-block;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;white-space:nowrap}.d2h-code-side-line{padding:0 4.5em;width:calc(100% - 9em)}.d2h-code-line-ctn{background:none;display:inline-block;padding:0;word-wrap:normal;-webkit-user-select:text;-moz-user-select:text;-ms-user-select:text;user-select:text;vertical-align:middle;white-space:pre;width:100%}.d2h-code-line del,.d2h-code-side-line del{background-color:#ffb6ba;background-color:var(--d2h-del-highlight-bg-color)}.d2h-code-line del,.d2h-code-line ins,.d2h-code-side-line del,.d2h-code-side-line ins{border-radius:.2em;display:inline-block;margin-top:-1px;-webkit-text-decoration:none;text-decoration:none}.d2h-code-line ins,.d2h-code-side-line ins{background-color:#97f295;background-color:var(--d2h-ins-highlight-bg-color);text-align:left}.d2h-code-line-prefix{background:none;display:inline;padding:0;word-wrap:normal;white-space:pre}.line-num1{float:left}.line-num1,.line-num2{-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;padding:0 .5em;text-overflow:ellipsis;width:3.5em}.line-num2{float:right}.d2h-code-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#0000004d;color:var(--d2h-dim-color);cursor:pointer;display:inline-block;position:absolute;text-align:right;width:7.5em}.d2h-code-linenumber:after{content:""}.d2h-code-side-linenumber{background-color:#fff;background-color:var(--d2h-bg-color);border:solid #eee;border:solid var(--d2h-line-border-color);border-width:0 1px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#0000004d;color:var(--d2h-dim-color);cursor:pointer;display:inline-block;overflow:hidden;padding:0 .5em;position:absolute;text-align:right;text-overflow:ellipsis;width:4em}.d2h-code-side-linenumber:after{content:""}.d2h-code-side-emptyplaceholder,.d2h-emptyplaceholder{background-color:#f1f1f1;background-color:var(--d2h-empty-placeholder-bg-color);border-color:#e1e1e1;border-color:var(--d2h-empty-placeholder-border-color)}.d2h-code-line-prefix,.d2h-code-linenumber,.d2h-code-side-linenumber,.d2h-emptyplaceholder{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.d2h-code-linenumber,.d2h-code-side-linenumber{direction:rtl}.d2h-del{background-color:#fee8e9;background-color:var(--d2h-del-bg-color);border-color:#e9aeae;border-color:var(--d2h-del-border-color)}.d2h-ins{background-color:#dfd;background-color:var(--d2h-ins-bg-color);border-color:#b4e2b4;border-color:var(--d2h-ins-border-color)}.d2h-info{background-color:#f8fafd;background-color:var(--d2h-info-bg-color);border-color:#d5e4f2;border-color:var(--d2h-info-border-color);color:#0000004d;color:var(--d2h-dim-color)}.d2h-file-diff .d2h-del.d2h-change{background-color:#fdf2d0;background-color:var(--d2h-change-del-color)}.d2h-file-diff .d2h-ins.d2h-change{background-color:#ded;background-color:var(--d2h-change-ins-color)}.d2h-file-list-wrapper{margin-bottom:10px}.d2h-file-list-wrapper a{-webkit-text-decoration:none;text-decoration:none}.d2h-file-list-wrapper a,.d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-file-list-header{text-align:left}.d2h-file-list-title{font-weight:700}.d2h-file-list-line{display:-webkit-box;display:-ms-flexbox;display:flex;text-align:left}.d2h-file-list{display:block;list-style:none;margin:0;padding:0}.d2h-file-list>li{border-bottom:1px solid #ddd;border-bottom:1px solid var(--d2h-border-color);margin:0;padding:5px 10px}.d2h-file-list>li:last-child{border-bottom:none}.d2h-file-switch{cursor:pointer;display:none;font-size:10px}.d2h-icon{fill:currentColor;margin-right:10px;vertical-align:middle}.d2h-deleted{color:#c33;color:var(--d2h-del-label-color)}.d2h-added{color:#399839;color:var(--d2h-ins-label-color)}.d2h-changed{color:#d0b44c;color:var(--d2h-change-label-color)}.d2h-moved{color:#3572b0;color:var(--d2h-moved-label-color)}.d2h-tag{background-color:#fff;background-color:var(--d2h-bg-color);display:-webkit-box;display:-ms-flexbox;display:flex;font-size:10px;margin-left:5px;padding:0 2px}.d2h-deleted-tag{border:1px solid #c33;border:1px solid var(--d2h-del-label-color)}.d2h-added-tag{border:1px solid #399839;border:1px solid var(--d2h-ins-label-color)}.d2h-changed-tag{border:1px solid #d0b44c;border:1px solid var(--d2h-change-label-color)}.d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-moved-label-color)}.d2h-dark-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-dark-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-dark-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-code-line del,.d2h-dark-color-scheme .d2h-code-side-line del{background-color:#f8514966;background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-dark-color-scheme .d2h-code-line ins,.d2h-dark-color-scheme .d2h-code-side-line ins{background-color:#2ea04366;background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-dark-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:#6e76811a;background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-dark-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-del{background-color:#f851491a;background-color:var(--d2h-dark-del-bg-color);border-color:#f8514966;border-color:var(--d2h-dark-del-border-color)}.d2h-dark-color-scheme .d2h-ins{background-color:#2ea04326;background-color:var(--d2h-dark-ins-bg-color);border-color:#2ea04366;border-color:var(--d2h-dark-ins-border-color)}.d2h-dark-color-scheme .d2h-info{background-color:#388bfd1a;background-color:var(--d2h-dark-info-bg-color);border-color:#388bfd66;border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:#d2992233;background-color:var(--d2h-dark-change-del-color)}.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:#2ea04340;background-color:var(--d2h-dark-change-ins-color)}.d2h-dark-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-dark-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected{background-color:#388bfd1a;background-color:var(--d2h-dark-selected-color)}.d2h-dark-color-scheme .d2h-file-list-wrapper a,.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-dark-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-dark-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-dark-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-dark-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}@media(prefers-color-scheme:dark){.d2h-auto-color-scheme{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);color:#e6edf3;color:var(--d2h-dark-color)}.d2h-auto-color-scheme .d2h-file-header{background-color:#161b22;background-color:var(--d2h-dark-file-header-bg-color);border-bottom:#30363d;border-bottom:var(--d2h-dark-file-header-border-color)}.d2h-auto-color-scheme .d2h-lines-added{border:1px solid rgba(46,160,67,.4);border:1px solid var(--d2h-dark-ins-border-color);color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-lines-deleted{border:1px solid rgba(248,81,73,.4);border:1px solid var(--d2h-dark-del-border-color);color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-code-line del,.d2h-auto-color-scheme .d2h-code-side-line del{background-color:#f8514966;background-color:var(--d2h-dark-del-highlight-bg-color)}.d2h-auto-color-scheme .d2h-code-line ins,.d2h-auto-color-scheme .d2h-code-side-line ins{background-color:#2ea04366;background-color:var(--d2h-dark-ins-highlight-bg-color)}.d2h-auto-color-scheme .d2h-diff-tbody{border-color:#30363d;border-color:var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-code-side-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder{background-color:#6e76811a;background-color:var(--d2h-dark-empty-placeholder-bg-color);border-color:#30363d;border-color:var(--d2h-dark-empty-placeholder-border-color)}.d2h-auto-color-scheme .d2h-code-linenumber{background-color:#0d1117;background-color:var(--d2h-dark-bg-color);border-color:#21262d;border-color:var(--d2h-dark-line-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-del{background-color:#f851491a;background-color:var(--d2h-dark-del-bg-color);border-color:#f8514966;border-color:var(--d2h-dark-del-border-color)}.d2h-auto-color-scheme .d2h-ins{background-color:#2ea04326;background-color:var(--d2h-dark-ins-bg-color);border-color:#2ea04366;border-color:var(--d2h-dark-ins-border-color)}.d2h-auto-color-scheme .d2h-info{background-color:#388bfd1a;background-color:var(--d2h-dark-info-bg-color);border-color:#388bfd66;border-color:var(--d2h-dark-info-border-color);color:#6e7681;color:var(--d2h-dark-dim-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change{background-color:#d2992233;background-color:var(--d2h-dark-change-del-color)}.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change{background-color:#2ea04340;background-color:var(--d2h-dark-change-ins-color)}.d2h-auto-color-scheme .d2h-file-wrapper{border:1px solid #30363d;border:1px solid var(--d2h-dark-border-color)}.d2h-auto-color-scheme .d2h-file-collapse{border:1px solid #0d1117;border:1px solid var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected{background-color:#388bfd1a;background-color:var(--d2h-dark-selected-color)}.d2h-auto-color-scheme .d2h-file-list-wrapper a,.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-file-list>li{border-bottom:1px solid #0d1117;border-bottom:1px solid var(--d2h-dark-bg-color)}.d2h-dark-color-scheme .d2h-deleted{color:#f85149;color:var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added{color:#3fb950;color:var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed{color:#d29922;color:var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved{color:#3572b0;color:var(--d2h-dark-moved-label-color)}.d2h-auto-color-scheme .d2h-tag{background-color:#0d1117;background-color:var(--d2h-dark-bg-color)}.d2h-auto-color-scheme .d2h-deleted-tag{border:1px solid #f85149;border:1px solid var(--d2h-dark-del-label-color)}.d2h-auto-color-scheme .d2h-added-tag{border:1px solid #3fb950;border:1px solid var(--d2h-dark-ins-label-color)}.d2h-auto-color-scheme .d2h-changed-tag{border:1px solid #d29922;border:1px solid var(--d2h-dark-change-label-color)}.d2h-auto-color-scheme .d2h-moved-tag{border:1px solid #3572b0;border:1px solid var(--d2h-dark-moved-label-color)}}:root{--bg: #ffffff;--fg: #1f2328;--muted: #656d76;--border: #d0d7de;--green: #1a7f37;--red: #cf222e;--yellow: #9a6700;--accent: #0969da}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;color:var(--fg);background:var(--bg)}.app{max-width:1100px;margin:0 auto;padding:0 16px 64px}.topbar{display:flex;align-items:baseline;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px}.brand{font-size:20px;font-weight:700;text-decoration:none;color:var(--fg)}.tagline{color:var(--muted);font-size:13px}a{color:var(--accent)}.muted{color:var(--muted);font-size:.92em}.error{color:var(--red);background:#ffebe9;border:1px solid rgba(207,34,46,.3);padding:8px 12px;border-radius:6px}.empty{text-align:center;color:var(--muted);margin-top:64px}.empty pre{display:inline-block;background:#f6f8fa;padding:8px 16px;border-radius:6px}.md{line-height:1.55}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:8px 0}.md code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:.9em;background:#f0f2f5;padding:1px 5px;border-radius:4px}.md pre{background:#f6f8fa;border:1px solid var(--border);border-radius:6px;padding:10px 12px;overflow-x:auto}.md pre code{background:none;padding:0;font-size:12px}.md ul,.md ol{margin:8px 0;padding-left:24px}.md blockquote{margin:8px 0;padding-left:12px;border-left:3px solid var(--border);color:var(--muted)}.md h1,.md h2,.md h3{font-size:1.05em;margin:12px 0 4px}.daemon-strip{background:#ddf4ff;border:1px solid rgba(9,105,218,.3);border-radius:6px;padding:6px 12px;margin-bottom:12px;font-size:13px;display:flex;gap:12px;flex-wrap:wrap}.queue-stats{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}.queue{width:100%;border-collapse:collapse;font-size:14px}.queue th{text-align:left;color:var(--muted);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--border)}.queue td{padding:8px;border-bottom:1px solid var(--border);vertical-align:top}.pr-link{font-weight:600;text-decoration:none}.status{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}.status-ready{background:#dafbe1;border-color:#1a7f3766}.status-running{background:#ddf4ff;border-color:#0969da66}.status-failed{background:#ffebe9;border-color:#cf222e66}.badge{font-size:12px;padding:2px 8px;border-radius:999px;white-space:nowrap}.badge-green{background:#dafbe1;color:var(--green)}.badge-yellow{background:#fff8c5;color:var(--yellow)}.badge-red{background:#ffebe9;color:var(--red)}.badge-muted{color:var(--muted)}.add{color:var(--green)}.del{color:var(--red)}.back{display:inline-block;margin:8px 0;text-decoration:none}.detail h1{font-size:22px;margin:4px 0}.verdict{border:1px solid var(--border);border-left-width:4px;border-radius:6px;padding:8px 12px;margin:12px 0}.verdict-approve{border-left-color:var(--green)}.verdict-comment{border-left-color:var(--yellow)}.verdict-request_changes{border-left-color:var(--red)}.verdict p{margin:4px 0 0;color:var(--muted);font-size:14px}.summary-body{white-space:pre-wrap;line-height:1.55}.chapter{border:1px solid var(--border);border-radius:6px;margin:12px 0;padding:4px 16px 12px}.chapter-title{cursor:pointer;-webkit-user-select:none;user-select:none;font-size:16px}.chapter-explanation{white-space:pre-wrap;line-height:1.5}.comment{border:1px solid var(--border);border-left:4px solid var(--yellow);border-radius:6px;padding:8px 12px;margin:8px 0;font-size:14px}.comment-loc{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;color:var(--muted);margin-bottom:4px}.comment-body{white-space:pre-wrap;line-height:1.5}.diff{margin-top:8px;font-size:12px}button{font:inherit;font-size:12px;padding:2px 10px;border:1px solid var(--border);border-radius:6px;background:#f6f8fa;cursor:pointer}button:hover{background:#eef1f4}.comment-actions{margin-left:8px;display:inline-flex;gap:4px}.comment-dropped{opacity:.5}.comment-approved{border-left-color:var(--green)}.comment-edit,.add-comment textarea{width:100%;font:inherit;font-size:14px;padding:6px 8px;border:1px solid var(--border);border-radius:6px}.add-comment{border:1px dashed var(--border);border-radius:6px;padding:8px 12px;margin:8px 0;display:flex;flex-direction:column;gap:6px}.add-comment-row{display:flex;gap:8px;align-items:center}.add-comment select,.add-comment input{font:inherit;font-size:13px;padding:4px 6px;border:1px solid var(--border);border-radius:6px}.add-comment-btn{margin:8px 0}.verdict-override{margin-left:12px;font-size:13px;padding:2px 6px;border:1px solid var(--border);border-radius:6px}.send-panel{border:2px solid var(--accent);border-radius:8px;padding:12px 16px;margin:24px 0}.send-panel.sent{border-color:var(--green);background:#dafbe1}.send-panel h2{margin:0 0 4px}.send-controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.send-controls select{font:inherit;font-size:14px;padding:4px 8px;border:1px solid var(--border);border-radius:6px}.send-btn{background:var(--accent);color:#fff;border-color:var(--accent);font-size:14px;padding:6px 14px}.send-btn:hover{background:#0757b3}.send-btn-confirm{background:var(--red);border-color:var(--red)}.send-btn-confirm:hover{background:#a91e28}.confirm-group{display:inline-flex;gap:8px;align-items:center}.body-preview{background:#f6f8fa;border:1px solid var(--border);border-radius:6px;padding:12px;white-space:pre-wrap;font-size:13px;max-height:400px;overflow:auto}.export-link{font-size:13px}.post-actions{margin-top:16px}.diff .d2h-file-header{font-family:ui-monospace,SFMono-Regular,monospace}
|