reviewstage 1.0.0-rc.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/bin/reviewstage.js +49 -0
- package/flock.py +87 -0
- package/main.js +281 -0
- package/package.json +44 -0
- package/pages/phone.html +79 -0
- package/pages/preparing.html +66 -0
- package/preload.cjs +15 -0
- package/server/bin/bootstrap.sh +318 -0
- package/server/bin/demo-fixture.py +188 -0
- package/server/bin/doctor.sh +310 -0
- package/server/bin/entrypoint.sh +191 -0
- package/server/bin/lib-common.sh +308 -0
- package/server/bin/lib-limits.sh +18 -0
- package/server/bin/lib-settings.sh +37 -0
- package/server/bin/notify.sh +335 -0
- package/server/bin/poller-loop.sh +68 -0
- package/server/bin/pr-watch.sh +356 -0
- package/server/bin/profile-repo.sh +141 -0
- package/server/bin/rs_agree.py +163 -0
- package/server/bin/rs_assets.py +10 -0
- package/server/bin/rs_device_flow.py +225 -0
- package/server/bin/rs_devices.py +220 -0
- package/server/bin/rs_diff.py +260 -0
- package/server/bin/rs_learn.py +754 -0
- package/server/bin/rs_md.py +107 -0
- package/server/bin/rs_paths.py +285 -0
- package/server/bin/rs_personal.py +276 -0
- package/server/bin/rs_profile.py +1091 -0
- package/server/bin/rs_push.py +417 -0
- package/server/bin/rs_queue.py +791 -0
- package/server/bin/rs_review_body.py +133 -0
- package/server/bin/rs_rollup.py +382 -0
- package/server/bin/rs_settings.py +265 -0
- package/server/bin/rs_stack.py +76 -0
- package/server/bin/rs_state.py +184 -0
- package/server/bin/rs_webhook.py +250 -0
- package/server/bin/run-qa.sh +295 -0
- package/server/bin/run-review.sh +402 -0
- package/server/bin/server.py +6271 -0
- package/server/bin/static/app-c45f0338184b.css +2 -0
- package/server/bin/static/app-c45f0338184b.js +237 -0
- package/server/bin/static/assets.json +5 -0
- package/server/bin/static/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
- package/server/bin/static/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
- package/server/bin/static/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
- package/server/bin/static/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
- package/server/bin/static/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
- package/server/bin/static/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
- package/server/bin/static/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
- package/server/bin/static/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
- package/server/bin/static/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
- package/server/bin/static/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
- package/server/bin/static/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
- package/server/bin/static/icons/apple-touch-icon.png +0 -0
- package/server/bin/static/icons/icon-192.png +0 -0
- package/server/bin/static/icons/icon-512.png +0 -0
- package/server/bin/static/icons/maskable-192.png +0 -0
- package/server/bin/static/icons/maskable-512.png +0 -0
- package/server/bin/static/manifest.webmanifest +20 -0
- package/server/bin/static/offline.html +28 -0
- package/server/bin/static/sw.js +126 -0
- package/server/skills/examples/deep-depth-domain-example.md +68 -0
- package/server/skills/examples/team-default-domain-example.md +68 -0
- package/server/skills/global-review.md +58 -0
- package/server/skills/pr-qa-guide/SKILL.md +187 -0
- package/server/skills/pr-qa-guide/template.html +489 -0
- package/server/skills/pr-review/SKILL.md +129 -0
- package/server/skills/repo-profile/SKILL.md +67 -0
- package/server.js +140 -0
- package/tools.js +147 -0
- package/tools.json +30 -0
- package/tunnel.js +157 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>ReviewStage</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { --paper:#0B0C10; --panel:#12141A; --ink:#ECEEF3; --graphite:#9AA0B4; --hairline:#22262F; --blue:#7A83FF; --red:#F0718A; --green:#3DD98F; --light: rgba(255,225,176,.17); }
|
|
8
|
+
html, body { height: 100%; margin: 0; background: var(--paper); color: var(--ink); font: 14px/1.5 "Geist Variable", system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-app-region: drag; }
|
|
9
|
+
body { display: grid; place-items: center; }
|
|
10
|
+
.card { position: relative; width: min(440px, calc(100vw - 32px)); padding: 32px 28px 24px; border-radius: 12px; background: var(--panel); box-shadow: 0 24px 64px -24px rgba(0,0,0,.8); -webkit-app-region: no-drag; }
|
|
11
|
+
.card::before { content: ""; position: absolute; left: 50%; top: -46%; width: 120%; height: 112%; transform: translateX(-50%); border-radius: 50%; background: radial-gradient(60% 55% at 50% 0%, var(--light), transparent 70%); pointer-events: none; animation: breathe 8s ease-in-out infinite alternate; }
|
|
12
|
+
@keyframes breathe { from { opacity: .7 } to { opacity: 1 } }
|
|
13
|
+
@media (prefers-reduced-motion: reduce) { .card::before { animation: none } }
|
|
14
|
+
h1 { margin: 12px 0 2px; font-size: 24px; font-weight: 600; letter-spacing: -0.02em; text-align: center; }
|
|
15
|
+
.sub { margin: 0 0 20px; color: var(--graphite); text-align: center; }
|
|
16
|
+
.mark { display: block; width: 40px; height: 40px; margin: 0 auto; }
|
|
17
|
+
.step { display: flex; align-items: center; gap: 10px; padding: 8px 0; color: var(--graphite); }
|
|
18
|
+
.step[data-state="doing"] { color: var(--ink) }
|
|
19
|
+
.step[data-state="done"] .dot { background: var(--green) }
|
|
20
|
+
.step[data-state="doing"] .dot { background: var(--blue); animation: pulse 1.2s ease-in-out infinite }
|
|
21
|
+
.step[data-state="failed"] .dot { background: var(--red) }
|
|
22
|
+
.dot { width: 8px; height: 8px; border-radius: 999px; background: var(--hairline); flex: none }
|
|
23
|
+
@keyframes pulse { 50% { opacity: .35 } }
|
|
24
|
+
.bar { height: 3px; margin-top: 6px; background: var(--hairline); border-radius: 999px; overflow: hidden }
|
|
25
|
+
.bar i { display: block; height: 100%; width: 0; background: var(--blue); transition: width .2s }
|
|
26
|
+
.err { margin-top: 14px; padding: 10px 12px; border-radius: 8px; background: rgba(240,113,138,.12); color: var(--ink); font-size: 13px; white-space: pre-wrap; display: none }
|
|
27
|
+
.err code { font: 12px/1.5 "Geist Mono Variable", ui-monospace, Menlo, monospace }
|
|
28
|
+
.acts { display: none; margin-top: 14px; gap: 8px; justify-content: flex-end }
|
|
29
|
+
button { font: inherit; font-weight: 500; border: 0; border-radius: 8px; padding: 8px 14px; cursor: pointer; background: var(--blue); color: var(--paper) }
|
|
30
|
+
button.ghost { background: transparent; color: var(--graphite) }
|
|
31
|
+
.fine { margin-top: 18px; text-align: center; color: var(--graphite); font-size: 12px }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<div class="card">
|
|
36
|
+
<svg class="mark" viewBox="0 0 48 48" aria-hidden="true"><path d="M8 10h12v28H8z" fill="#ECEEF3"/><path d="M22 14h6v24h-6z" fill="#3947FF"/><path d="M30 10h10v28H30z" fill="#ECEEF3" opacity=".85"/></svg>
|
|
37
|
+
<h1>ReviewStage</h1>
|
|
38
|
+
<p class="sub">Getting ready on this machine.</p>
|
|
39
|
+
<div id="steps">
|
|
40
|
+
<div class="step" data-id="tools"><i class="dot"></i><span>Review tools</span></div>
|
|
41
|
+
<div class="bar" id="bar" hidden><i id="fill"></i></div>
|
|
42
|
+
<div class="step" data-id="claude"><i class="dot"></i><span>Claude Code</span></div>
|
|
43
|
+
<div class="step" data-id="server"><i class="dot"></i><span>Starting the server</span></div>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="err" id="err"></div>
|
|
46
|
+
<div class="acts" id="acts">
|
|
47
|
+
<button class="ghost" id="quit">Quit</button>
|
|
48
|
+
<button id="retry">Try again</button>
|
|
49
|
+
</div>
|
|
50
|
+
<p class="fine">Nothing posts to GitHub until you click.</p>
|
|
51
|
+
</div>
|
|
52
|
+
<script>
|
|
53
|
+
const $ = (s) => document.querySelector(s);
|
|
54
|
+
const set = (id, state, label) => { const el = $(`.step[data-id="${id}"]`); el.dataset.state = state; if (label) el.querySelector("span").textContent = label; };
|
|
55
|
+
window.reviewstage?.onProgress((p) => {
|
|
56
|
+
if (p.step) set(p.step, p.state, p.label);
|
|
57
|
+
if (typeof p.fraction === "number") { $("#bar").hidden = false; $("#fill").style.width = Math.round(p.fraction * 100) + "%"; }
|
|
58
|
+
if (p.fraction === 1) $("#bar").hidden = true;
|
|
59
|
+
if (p.error) { $("#err").style.display = "block"; $("#err").innerHTML = p.error; $("#acts").style.display = "flex"; }
|
|
60
|
+
if (p.clearError) { $("#err").style.display = "none"; $("#acts").style.display = "none"; }
|
|
61
|
+
});
|
|
62
|
+
$("#retry").onclick = () => window.reviewstage?.retry();
|
|
63
|
+
$("#quit").onclick = () => window.reviewstage?.quit();
|
|
64
|
+
</script>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
package/preload.cjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The only bridge between the pages this app owns (preparing, phone) and the main process.
|
|
2
|
+
// The dashboard itself is a plain web page from the local server and gets no preload.
|
|
3
|
+
const { contextBridge, ipcRenderer } = require("electron");
|
|
4
|
+
|
|
5
|
+
contextBridge.exposeInMainWorld("reviewstage", {
|
|
6
|
+
onProgress: (fn) => ipcRenderer.on("progress", (_e, p) => fn(p)),
|
|
7
|
+
retry: () => ipcRenderer.send("retry"),
|
|
8
|
+
quit: () => ipcRenderer.send("quit"),
|
|
9
|
+
phone: {
|
|
10
|
+
enable: () => ipcRenderer.invoke("phone:enable"),
|
|
11
|
+
disable: () => ipcRenderer.invoke("phone:disable"),
|
|
12
|
+
status: () => ipcRenderer.invoke("phone:status"),
|
|
13
|
+
onData: (fn) => ipcRenderer.on("phone", (_e, p) => fn(p)),
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# bootstrap.sh — install ReviewStage on a Linux server you control. Idempotent; safe to re-run.
|
|
3
|
+
#
|
|
4
|
+
# git clone <this repo> ~/reviewstage
|
|
5
|
+
# ~/reviewstage/bin/bootstrap.sh
|
|
6
|
+
#
|
|
7
|
+
# On first run it writes ~/.reviewstage/.env, prompting for the per-install values (the repo
|
|
8
|
+
# to review, your GitHub login, the public URL of the dashboard), and tells you to paste your
|
|
9
|
+
# secrets. Then you re-run it and it finishes. See docs/SETUP.md.
|
|
10
|
+
#
|
|
11
|
+
# Everything lives under ~/.reviewstage, in $HOME — deliberately outside any directory a
|
|
12
|
+
# deploy tool or sync job of yours might rsync over or delete. The only files written outside
|
|
13
|
+
# $HOME are the systemd unit and, when you opt in with SETUP_APACHE=1, an Apache vhost that is
|
|
14
|
+
# config-tested before it is enabled.
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
SRC="$(cd "$(dirname "$0")" && pwd)"
|
|
18
|
+
ROOT="$HOME/.reviewstage"
|
|
19
|
+
BIN="$ROOT/bin"
|
|
20
|
+
|
|
21
|
+
# The account that owns the install. It must be the same account Claude Code is signed in as,
|
|
22
|
+
# because `claude` reads its credentials from $HOME. Defaults to whoever is running this; set
|
|
23
|
+
# RS_USER to guard against running it from the wrong shell by accident.
|
|
24
|
+
RS_USER="${RS_USER:-$(id -un)}"
|
|
25
|
+
[ "$(id -un)" = "$RS_USER" ] \
|
|
26
|
+
|| { echo "Run as $RS_USER (sudo su - $RS_USER), not $(id -un)."; exit 1; }
|
|
27
|
+
|
|
28
|
+
echo "==> directories"
|
|
29
|
+
mkdir -p "$BIN" "$ROOT/wt" "$ROOT/state" "$ROOT/repos"
|
|
30
|
+
chmod 700 "$ROOT"
|
|
31
|
+
touch "$ROOT/seen" "$ROOT/used-nonces"
|
|
32
|
+
# Teammates' encrypted tokens + Slack IDs, written by the dashboard on sign-in.
|
|
33
|
+
[ -f "$ROOT/users.json" ] || echo '{}' > "$ROOT/users.json"
|
|
34
|
+
chmod 600 "$ROOT/users.json"
|
|
35
|
+
|
|
36
|
+
echo "==> .env"
|
|
37
|
+
# Ensure every documented key EXISTS rather than only writing the file when it is absent.
|
|
38
|
+
# A hand-made .env (secrets pasted in before the first bootstrap) previously ended up with no
|
|
39
|
+
# DRY_RUN line at all, so the code fell back to its "1" default and the dashboard stayed in
|
|
40
|
+
# dry run with no visible reason — sed found nothing to flip. Existing values are never
|
|
41
|
+
# overwritten, so flipping DRY_RUN to 0 survives a re-run.
|
|
42
|
+
touch "$ROOT/.env"; chmod 600 "$ROOT/.env"
|
|
43
|
+
ensure_key() { grep -q "^$1=" "$ROOT/.env" || printf '%s=%s\n' "$1" "$2" >> "$ROOT/.env"; }
|
|
44
|
+
# The per-install values. Prompt when we have a terminal; otherwise write them empty and let
|
|
45
|
+
# the checks below (and require_env later) fail loudly, which beats guessing.
|
|
46
|
+
ask() {
|
|
47
|
+
local key="$1" prompt="$2" default="${3:-}" val=""
|
|
48
|
+
grep -q "^$key=.\+" "$ROOT/.env" && return 0
|
|
49
|
+
if [ -t 0 ]; then read -rp " $prompt${default:+ [$default]}: " val; fi
|
|
50
|
+
printf '%s=%s\n' "$key" "${val:-$default}" >> "$ROOT/.env"
|
|
51
|
+
}
|
|
52
|
+
ensure_key GITHUB_PAT ""
|
|
53
|
+
ensure_key SLACK_WEBHOOK ""
|
|
54
|
+
# Optional: a Slack bot token + channel enable threaded replies (review-ready threads under the
|
|
55
|
+
# review-request card). Without them, Slack falls back to the webhook (a fresh message each time).
|
|
56
|
+
ensure_key SLACK_BOT_TOKEN ""
|
|
57
|
+
ensure_key SLACK_CHANNEL ""
|
|
58
|
+
# The repositories to review. No default — every install names its own. REPO (single) is still
|
|
59
|
+
# honoured as an alias, so an existing .env is not re-prompted.
|
|
60
|
+
grep -q '^REPO=.\+' "$ROOT/.env" || ask REPOS "GitHub repositories to review (owner/name, comma-separated)"
|
|
61
|
+
grep -Eq '^REPOS?=.+' "$ROOT/.env" \
|
|
62
|
+
|| { echo " !! REPOS is empty in $ROOT/.env — set it to owner/name[,owner/name…], then re-run"; exit 1; }
|
|
63
|
+
# Optional: accept any repo under this org where a signed-in user gets a review request.
|
|
64
|
+
ensure_key REPO_ALLOW_ORG ""
|
|
65
|
+
ensure_key RS_SIGNATURE_GRACE_DAYS 7
|
|
66
|
+
# Your GitHub login. The PAT must belong to this account — everything the dashboard posts is
|
|
67
|
+
# attributed to it, which is the whole point of the design.
|
|
68
|
+
ask REVIEWER "Your GitHub login"
|
|
69
|
+
# Where browsers reach the dashboard: the hostname your reverse proxy forwards to the server.
|
|
70
|
+
# Older installs may have RS_ENV + RS_DOMAIN instead; lib-common.sh still derives the
|
|
71
|
+
# URL from that pair, so only prompt when neither form is present.
|
|
72
|
+
if ! grep -q '^RS_ENV=.\+' "$ROOT/.env" || ! grep -q '^RS_DOMAIN=.\+' "$ROOT/.env"; then
|
|
73
|
+
ask PUBLIC_URL "Public URL of this dashboard (e.g. https://reviews.example.com)"
|
|
74
|
+
grep -q '^PUBLIC_URL=.\+' "$ROOT/.env" \
|
|
75
|
+
|| { echo " !! PUBLIC_URL is empty in $ROOT/.env — set it, then re-run"; exit 1; }
|
|
76
|
+
fi
|
|
77
|
+
# GitHub login. Empty = token sign-in only. See docs/SETUP.md "GitHub login".
|
|
78
|
+
ensure_key GH_CLIENT_ID ""
|
|
79
|
+
ensure_key GH_CLIENT_SECRET ""
|
|
80
|
+
ensure_key GH_OAUTH_SCOPES ""
|
|
81
|
+
# Device flow: "Sign in with GitHub" with no app registration (shared public client ID).
|
|
82
|
+
ensure_key GH_DEVICE_FLOW 1
|
|
83
|
+
ensure_key GH_DEVICE_CLIENT_ID ""
|
|
84
|
+
ensure_key DRY_RUN 1
|
|
85
|
+
ensure_key SKIP_BOT_PRS 0
|
|
86
|
+
ensure_key RS_MAX_PR_AGE_DAYS 45
|
|
87
|
+
ensure_key RISK_PATHS ""
|
|
88
|
+
ensure_key RS_SECRET "$(openssl rand -hex 32)"
|
|
89
|
+
grep -q '^GITHUB_PAT=.\+' "$ROOT/.env" \
|
|
90
|
+
|| echo " !! GITHUB_PAT is empty in $ROOT/.env — add it, then re-run"
|
|
91
|
+
chmod 600 "$ROOT/.env"
|
|
92
|
+
|
|
93
|
+
# Everything below reads the values just written. PUBLIC_URL may be derived from the legacy
|
|
94
|
+
# pair, so let lib-common.sh do that rather than repeating the logic here.
|
|
95
|
+
# shellcheck disable=SC1091
|
|
96
|
+
. "$SRC/lib-common.sh"
|
|
97
|
+
PORT="${RS_PORT:-8899}"
|
|
98
|
+
PUBLIC_HOST="${PUBLIC_URL#*://}"; PUBLIC_HOST="${PUBLIC_HOST%%[/:]*}"
|
|
99
|
+
|
|
100
|
+
echo "==> gh"
|
|
101
|
+
if ! command -v gh >/dev/null; then
|
|
102
|
+
case "$(uname -m)" in aarch64|arm64) arch=arm64;; *) arch=amd64;; esac
|
|
103
|
+
v=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest | jq -r .tag_name)
|
|
104
|
+
curl -fsSL "https://github.com/cli/cli/releases/download/$v/gh_${v#v}_linux_${arch}.tar.gz" \
|
|
105
|
+
| tar -xz -C /tmp
|
|
106
|
+
sudo install -m 0755 "/tmp/gh_${v#v}_linux_${arch}/bin/gh" /usr/local/bin/gh
|
|
107
|
+
echo " installed gh $v ($arch)"
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
echo "==> claude"
|
|
111
|
+
if ! command -v claude >/dev/null; then
|
|
112
|
+
curl -fsSL https://claude.ai/install.sh | bash \
|
|
113
|
+
|| sudo npm install -g @anthropic-ai/claude-code
|
|
114
|
+
echo " installed claude — sign in AS $RS_USER before the first review"
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
echo "==> scripts"
|
|
118
|
+
# Bootstrap copies itself into $BIN, so it can also be re-run FROM $BIN. Installing a file
|
|
119
|
+
# onto itself makes `install` fail, and with `set -e` that aborted the run before the systemd
|
|
120
|
+
# and proxy steps ever executed — silently skipping the parts you were re-running it for.
|
|
121
|
+
if [ "$SRC" != "$BIN" ]; then
|
|
122
|
+
install -m 0755 "$SRC"/{pr-watch.sh,run-review.sh,run-qa.sh,bootstrap.sh} "$BIN/"
|
|
123
|
+
install -m 0755 "$SRC/server.py" "$BIN/"
|
|
124
|
+
install -m 0644 "$SRC"/rs_diff.py "$BIN/" # imported by the server, must sit beside it
|
|
125
|
+
install -m 0644 "$SRC"/rs_md.py "$BIN/"
|
|
126
|
+
install -m 0644 "$SRC"/rs_assets.py "$BIN/" # inlined brand logo + favicon
|
|
127
|
+
install -m 0644 "$SRC"/rs_learn.py "$BIN/" # learnings loop (imported + run by shell)
|
|
128
|
+
install -m 0644 "$SRC"/rs_agree.py "$BIN/" # convergence scoring, imported by the server
|
|
129
|
+
install -m 0644 "$SRC"/rs_rollup.py "$BIN/" # insights rollup, imported by the server
|
|
130
|
+
install -m 0644 "$SRC/lib-common.sh" "$BIN/"
|
|
131
|
+
else
|
|
132
|
+
echo " (running from $BIN — nothing to copy)"
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
echo "==> node + pnpm"
|
|
136
|
+
# The dashboard UI is a React + TypeScript app bundled by esbuild. The server builds it from
|
|
137
|
+
# source at install time (no build artifacts are committed). esbuild is low-memory, so a small
|
|
138
|
+
# VM handles it fine. pnpm is installed via npm since the repo pins no packageManager field.
|
|
139
|
+
if ! command -v node >/dev/null 2>&1 || [ "$(node -v 2>/dev/null | sed 's/^v//;s/\..*//')" -lt 18 ]; then
|
|
140
|
+
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
|
141
|
+
sudo apt-get install -y nodejs
|
|
142
|
+
echo " installed node $(node -v)"
|
|
143
|
+
fi
|
|
144
|
+
if ! command -v pnpm >/dev/null 2>&1; then
|
|
145
|
+
sudo npm install -g pnpm
|
|
146
|
+
echo " installed pnpm $(pnpm -v)"
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
echo "==> dashboard-ui build"
|
|
150
|
+
# The build writes the fingerprinted bundle (app-<hash>.js/.css plus assets.json naming them)
|
|
151
|
+
# to ../bin/static, i.e. $SRC/static. The server reads its bundle from $BIN/static, so copy the
|
|
152
|
+
# freshly built files across after the build — and clear the old fingerprinted pair first, or
|
|
153
|
+
# every upgrade leaves another dead copy of the bundle behind.
|
|
154
|
+
UI="$SRC/../dashboard-ui"
|
|
155
|
+
if [ -d "$UI" ]; then
|
|
156
|
+
( cd "$UI" && pnpm install --frozen-lockfile && pnpm build )
|
|
157
|
+
# Replace the directory rather than copying into it: the bundle is fingerprinted, so an
|
|
158
|
+
# in-place copy leaves every previous release's app-<hash>.* behind for ever. This also
|
|
159
|
+
# unbreaks the copy itself — it used `install "$SRC/static/"*`, which under `set -e` aborted
|
|
160
|
+
# bootstrap the moment the PWA build started emitting a static/icons/ subdirectory.
|
|
161
|
+
rm -rf "$BIN/static"
|
|
162
|
+
mkdir -p "$BIN/static"
|
|
163
|
+
cp -R "$SRC/static/." "$BIN/static/"
|
|
164
|
+
chmod -R a+rX "$BIN/static"
|
|
165
|
+
echo " built SPA bundle -> $BIN/static ($(ls -1 "$BIN/static" | tr '\n' ' '))"
|
|
166
|
+
else
|
|
167
|
+
echo " !! dashboard-ui not found at $UI — the SPA will not load (set RS_SPA=0 to fall"
|
|
168
|
+
echo " back to the legacy HTML UI); build it and re-run bootstrap"
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
echo "==> base clones (one per repo, under $ROOT/repos)"
|
|
172
|
+
if [ -z "${GITHUB_PAT:-}" ]; then
|
|
173
|
+
# The documented flow is: bootstrap (writes .env) -> you paste the secrets -> re-run.
|
|
174
|
+
# Aborting here on the first run would strand that flow before systemd/proxy/cron ever
|
|
175
|
+
# got configured, so skip the clone and let the re-run pick it up.
|
|
176
|
+
echo " skipped — GITHUB_PAT is empty; re-run bootstrap once it is set"
|
|
177
|
+
elif [ -d "$ROOT/repo/.git" ] && [ ! -f "$ROOT/MIGRATED" ]; then
|
|
178
|
+
echo " legacy clone at $ROOT/repo — the server moves it into $ROOT/repos on its next start"
|
|
179
|
+
else
|
|
180
|
+
for repo in $(printf '%s %s' "${REPOS:-}" "${REPO:-}" | tr ',' ' ' | tr -s '[:space:]' '\n' | awk 'NF && !s[tolower($0)]++'); do
|
|
181
|
+
base="$ROOT/repos/${repo/\//__}"
|
|
182
|
+
[ -d "$base/.git" ] || GH_TOKEN="$GITHUB_PAT" gh repo clone "$repo" "$base" -- --filter=blob:none
|
|
183
|
+
[ -d "$base/.git" ] && git -C "$base" config credential.helper \
|
|
184
|
+
'!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f'
|
|
185
|
+
done
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
echo "==> pr-review skill"
|
|
189
|
+
mkdir -p "$HOME/.claude/skills"
|
|
190
|
+
# The skill ships in this repo. The agent runs with the PR worktree as its cwd, so a
|
|
191
|
+
# project-level copy would not be found — it has to be installed at the user level.
|
|
192
|
+
cp -r "$SRC/../skills/pr-review" "$HOME/.claude/skills/" 2>/dev/null \
|
|
193
|
+
|| echo " !! skills/pr-review not found next to $SRC — reviews will run without it"
|
|
194
|
+
cp -r "$SRC/../skills/pr-qa-guide" "$HOME/.claude/skills/" 2>/dev/null \
|
|
195
|
+
|| echo " !! skills/pr-qa-guide not found next to $SRC — QA guides will run without it"
|
|
196
|
+
|
|
197
|
+
# Editable team-default review skill. Seed $ROOT/skills/_global.md on first install so it shows
|
|
198
|
+
# real content and is editable from the dashboard; never overwrite once it exists (it's edited live).
|
|
199
|
+
mkdir -p "$ROOT/skills"
|
|
200
|
+
if [ ! -f "$ROOT/skills/_global.md" ] && [ -f "$SRC/../skills/global-review.md" ]; then
|
|
201
|
+
install -m 0644 "$SRC/../skills/global-review.md" "$ROOT/skills/_global.md"
|
|
202
|
+
echo " seeded team-default skill (_global.md)"
|
|
203
|
+
fi
|
|
204
|
+
|
|
205
|
+
echo "==> systemd unit"
|
|
206
|
+
sudo tee /etc/systemd/system/reviewstage.service >/dev/null <<EOF
|
|
207
|
+
[Unit]
|
|
208
|
+
Description=ReviewStage dashboard
|
|
209
|
+
After=network.target
|
|
210
|
+
|
|
211
|
+
[Service]
|
|
212
|
+
User=$RS_USER
|
|
213
|
+
Environment=RS_PORT=$PORT
|
|
214
|
+
# run-review.sh is spawned from this service and shells out to \`claude\`, which the native
|
|
215
|
+
# installer puts in ~/.local/bin — not on systemd's default PATH. Without this the agent
|
|
216
|
+
# step fails as "command not found" and surfaces only as an empty review.json.
|
|
217
|
+
Environment=PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
218
|
+
ExecStart=/usr/bin/python3 $BIN/server.py
|
|
219
|
+
# Only kill the server itself on stop. The default (control-group) reaps every process in
|
|
220
|
+
# the cgroup — including the detached run-review.sh a click spawned — so redeploying while a
|
|
221
|
+
# review was running silently killed it and left the PR stuck reading "reviewing".
|
|
222
|
+
KillMode=process
|
|
223
|
+
Restart=always
|
|
224
|
+
RestartSec=5
|
|
225
|
+
|
|
226
|
+
[Install]
|
|
227
|
+
WantedBy=multi-user.target
|
|
228
|
+
EOF
|
|
229
|
+
sudo systemctl daemon-reload
|
|
230
|
+
sudo systemctl enable reviewstage.service
|
|
231
|
+
# restart, not `enable --now`: the server reads .env once at startup, so an already-running
|
|
232
|
+
# instance would keep serving stale secrets and a stale DRY_RUN after you edit them.
|
|
233
|
+
sudo systemctl restart reviewstage.service
|
|
234
|
+
|
|
235
|
+
echo "==> reverse proxy"
|
|
236
|
+
# The server binds 127.0.0.1:$PORT only. Something you run must terminate TLS on $PUBLIC_HOST
|
|
237
|
+
# and forward to it — any reverse proxy will do. This step is OPTIONAL and only runs when you
|
|
238
|
+
# opt in with SETUP_APACHE=1: it writes a name-based Apache vhost for $PUBLIC_HOST that proxies
|
|
239
|
+
# to the server, config-tests it, and disables it again if anything is off, so an existing
|
|
240
|
+
# Apache on the box is never left broken. Without SETUP_APACHE it prints what to configure.
|
|
241
|
+
if [ "${SETUP_APACHE:-0}" = 1 ]; then
|
|
242
|
+
[ -n "$PUBLIC_HOST" ] || { echo " !! PUBLIC_URL has no hostname — fix .env, then re-run"; exit 1; }
|
|
243
|
+
# Extra hostnames that should also reach this instance (comma-separated in .env), e.g. an
|
|
244
|
+
# old name kept alive so already-sent links keep resolving.
|
|
245
|
+
aliases=$(printf '%s' "${RS_HOST_ALIASES:-}" | tr ',' ' ' | xargs || true)
|
|
246
|
+
alias_line=""
|
|
247
|
+
for a in $aliases; do [ "$a" = "$PUBLIC_HOST" ] || alias_line+=" ServerAlias $a"$'\n'; done
|
|
248
|
+
sudo a2enmod proxy proxy_http >/dev/null
|
|
249
|
+
sudo tee /etc/apache2/sites-available/reviewstage.conf >/dev/null <<EOF
|
|
250
|
+
<VirtualHost *:80>
|
|
251
|
+
ServerName $PUBLIC_HOST
|
|
252
|
+
${alias_line} ProxyPreserveHost On
|
|
253
|
+
# The app is served at the site root.
|
|
254
|
+
ProxyPass / http://127.0.0.1:$PORT/
|
|
255
|
+
ProxyPassReverse / http://127.0.0.1:$PORT/
|
|
256
|
+
ErrorLog \${APACHE_LOG_DIR}/reviewstage-error.log
|
|
257
|
+
CustomLog \${APACHE_LOG_DIR}/reviewstage-access.log combined
|
|
258
|
+
</VirtualHost>
|
|
259
|
+
EOF
|
|
260
|
+
sudo a2ensite reviewstage >/dev/null
|
|
261
|
+
if ! sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK"; then
|
|
262
|
+
sudo a2dissite reviewstage >/dev/null
|
|
263
|
+
echo " !! apache configtest FAILED — vhost disabled, nothing else touched"; exit 1
|
|
264
|
+
fi
|
|
265
|
+
# Verify functionally, by asking the endpoint through the vhost. A graceful reload is enough
|
|
266
|
+
# to pick up new LoadModule lines in practice; the restart stays only as a genuine fallback.
|
|
267
|
+
reviewstage_reachable() {
|
|
268
|
+
[ "$(curl -s -m 5 -H "Host: $PUBLIC_HOST" http://127.0.0.1/health 2>/dev/null)" = "ok" ]
|
|
269
|
+
}
|
|
270
|
+
sudo systemctl reload apache2
|
|
271
|
+
sleep 1
|
|
272
|
+
if ! reviewstage_reachable; then
|
|
273
|
+
echo " endpoint not reachable after reload — restarting apache"
|
|
274
|
+
sudo systemctl restart apache2
|
|
275
|
+
sleep 2
|
|
276
|
+
fi
|
|
277
|
+
if reviewstage_reachable; then
|
|
278
|
+
echo " apache ok (answers on $PUBLIC_HOST)"
|
|
279
|
+
else
|
|
280
|
+
sudo a2dissite reviewstage >/dev/null && sudo systemctl reload apache2
|
|
281
|
+
echo " !! endpoint unreachable — vhost disabled, nothing else touched"; exit 1
|
|
282
|
+
fi
|
|
283
|
+
else
|
|
284
|
+
echo " skipped (set SETUP_APACHE=1 to write an Apache vhost). Point your reverse proxy at"
|
|
285
|
+
echo " http://127.0.0.1:$PORT for $PUBLIC_HOST with TLS in front. nginx example:"
|
|
286
|
+
echo " location / { proxy_pass http://127.0.0.1:$PORT; proxy_set_header Host \$host; }"
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
echo "==> cron"
|
|
290
|
+
# Strip BOTH of our lines before re-adding, or each bootstrap run leaves another PATH=
|
|
291
|
+
# line behind. The PATH line cannot carry a trailing marker comment — cron would read the
|
|
292
|
+
# comment as part of the value — so it is matched literally instead.
|
|
293
|
+
cron_path="PATH=/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin"
|
|
294
|
+
tmp=$(mktemp)
|
|
295
|
+
crontab -l 2>/dev/null | grep -vF "$cron_path" | grep -v 'pr-watch.sh' > "$tmp" || true
|
|
296
|
+
{
|
|
297
|
+
echo "$cron_path"
|
|
298
|
+
echo "*/3 * * * * flock -n /tmp/pr-watch.lock $BIN/pr-watch.sh >> $ROOT/watch.log 2>&1"
|
|
299
|
+
} >> "$tmp"
|
|
300
|
+
crontab "$tmp"; rm -f "$tmp"
|
|
301
|
+
|
|
302
|
+
echo
|
|
303
|
+
echo "Done. Checks:"
|
|
304
|
+
echo " curl -s localhost:$PORT/health # -> ok"
|
|
305
|
+
echo " curl -s $PUBLIC_URL/health # -> ok (through your reverse proxy)"
|
|
306
|
+
echo " $BIN/pr-watch.sh # -> Slack card per open request"
|
|
307
|
+
echo
|
|
308
|
+
echo "Sign in (you and every teammate): $PUBLIC_URL/login"
|
|
309
|
+
echo " Each person signs in with GitHub (or pastes their own PAT) + Slack member ID once."
|
|
310
|
+
echo " Each reviewer runs their own review; posting and approving happen as each signed-in user."
|
|
311
|
+
echo
|
|
312
|
+
# Report the ACTUAL value, never a hardcoded assumption — a stale "nothing is written"
|
|
313
|
+
# reassurance is worse than none once someone has flipped it.
|
|
314
|
+
if grep -q '^DRY_RUN=0' "$ROOT/.env"; then
|
|
315
|
+
echo "DRY_RUN=0 — the dashboard POSTS AND APPROVES ON GITHUB for real, as $(grep '^REVIEWER=' "$ROOT/.env" | cut -d= -f2)."
|
|
316
|
+
else
|
|
317
|
+
echo "DRY_RUN=1 — reviews run and Slack reports them, but GitHub is never written."
|
|
318
|
+
fi
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""demo-fixture.py ROOT [PORT] — build the offline demo data set and print a sign-in link.
|
|
3
|
+
|
|
4
|
+
A Python port of dashboard-ui/e2e/fixture.ts (the Playwright fixture): a .env with a dummy
|
|
5
|
+
service token and TWO demo repositories, one demo user, a queue spanning both repos with
|
|
6
|
+
finished reviews on disk, and a fake `gh` that fails every call so the server renders from the
|
|
7
|
+
on-disk state and nothing ever reaches GitHub. Playwright signs in by injecting a cookie; here
|
|
8
|
+
the server's own /handoff/accept route mints the session instead, from a link signed with the
|
|
9
|
+
fixture's RS_SECRET.
|
|
10
|
+
"""
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import stat
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from hashlib import sha256
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent)) # rs_profile lives beside us
|
|
22
|
+
|
|
23
|
+
USER = "demo-reviewer"
|
|
24
|
+
REPO = "reviewstage/demo-repo" # the web app
|
|
25
|
+
REPO2 = "reviewstage/demo-api" # a second repo, so the UI shows the repo dimension
|
|
26
|
+
PR, PR2 = 101, 102 # in REPO
|
|
27
|
+
PR3 = 7 # in REPO2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def slug(repo):
|
|
31
|
+
return repo.replace("/", "__", 1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write(path: Path, body: str):
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
path.write_text(body)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def row(repo, num, title, adds, dels, files, head, created, updated):
|
|
40
|
+
return {"repo": repo, "number": num, "title": title,
|
|
41
|
+
"url": f"https://github.com/{repo}/pull/{num}", "additions": adds,
|
|
42
|
+
"deletions": dels, "changedFiles": files, "requested": [USER],
|
|
43
|
+
"author": "teammate", "isBot": False, "isDraft": False, "head": head,
|
|
44
|
+
"createdAt": created, "updatedAt": updated}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
root = Path(sys.argv[1] if len(sys.argv) > 1 else os.environ.get("ROOT", "~/.reviewstage"))
|
|
49
|
+
root = root.expanduser()
|
|
50
|
+
port = sys.argv[2] if len(sys.argv) > 2 else "8899"
|
|
51
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# Keep an existing secret so a restart does not invalidate the link printed last time.
|
|
54
|
+
env_f = root / ".env"
|
|
55
|
+
secret = ""
|
|
56
|
+
if env_f.exists():
|
|
57
|
+
for line in env_f.read_text().splitlines():
|
|
58
|
+
if line.startswith("RS_SECRET="):
|
|
59
|
+
secret = line.split("=", 1)[1].strip()
|
|
60
|
+
secret = secret or secrets.token_hex(32)
|
|
61
|
+
|
|
62
|
+
write(env_f, "\n".join([
|
|
63
|
+
f"RS_SECRET={secret}",
|
|
64
|
+
f"REVIEWER={USER}",
|
|
65
|
+
f"REPOS={REPO},{REPO2}",
|
|
66
|
+
"DRY_RUN=1",
|
|
67
|
+
f"PUBLIC_URL=http://localhost:{port}",
|
|
68
|
+
"GITHUB_PAT=ghp_demo_dummy_never_used",
|
|
69
|
+
"GH_DEVICE_FLOW=0", # the demo never reaches GitHub
|
|
70
|
+
]) + "\n")
|
|
71
|
+
env_f.chmod(0o600)
|
|
72
|
+
|
|
73
|
+
write(root / "users.json", json.dumps({
|
|
74
|
+
USER: {"name": "Demo Reviewer", "slack_id": "", "added": 1, "updated": 1}}))
|
|
75
|
+
(root / "users.json").chmod(0o600)
|
|
76
|
+
for name in ("seen", "used-nonces"):
|
|
77
|
+
(root / name).touch()
|
|
78
|
+
(root / "state").mkdir(exist_ok=True)
|
|
79
|
+
# A fresh fixture is already in the per-repo layout — nothing to migrate.
|
|
80
|
+
(root / "MIGRATED").write_text(json.dumps({"at": int(time.time()), "note": "demo"}) + "\n")
|
|
81
|
+
|
|
82
|
+
write(root / "queue.json", json.dumps([
|
|
83
|
+
row(REPO, PR, "Add lead-time badge to product cards", 42, 8, 5, "deadbeefcafe",
|
|
84
|
+
"2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z"),
|
|
85
|
+
row(REPO, PR2, "Cache vendor lead times", 12, 3, 2, "feedfacecafe",
|
|
86
|
+
"2026-05-03T10:00:00Z", "2026-05-04T10:00:00Z"),
|
|
87
|
+
row(REPO2, PR3, "Rate-limit the lead-time endpoint", 30, 4, 3, "0badf00dcafe",
|
|
88
|
+
"2026-05-05T10:00:00Z", "2026-05-06T10:00:00Z"),
|
|
89
|
+
]))
|
|
90
|
+
|
|
91
|
+
st = root / "state"
|
|
92
|
+
write(st / slug(REPO) / str(PR2) / "status", "done")
|
|
93
|
+
write(st / slug(REPO) / str(PR2) / "review.json", json.dumps({
|
|
94
|
+
"event": "COMMENT", "summary": "Caches vendor lead times. Looks fine.",
|
|
95
|
+
"keyPoints": ["A small, well-scoped cache.", "No blockers."],
|
|
96
|
+
"explainer": "- Adds a per-vendor cache for lead times.",
|
|
97
|
+
"analysis": "- Checked cache invalidation on vendor update.", "comments": []}))
|
|
98
|
+
write(st / slug(REPO) / str(PR) / "status", "done")
|
|
99
|
+
write(st / slug(REPO) / str(PR) / "review.json", json.dumps({
|
|
100
|
+
"event": "COMMENT",
|
|
101
|
+
"summary": "Adds a lead-time badge to product cards. Logic is sound; two small things.",
|
|
102
|
+
"keyPoints": [
|
|
103
|
+
"The badge logic is sound — no blockers.",
|
|
104
|
+
"Guard a null vendor before reading its lead time.",
|
|
105
|
+
"One nit: prefer const over let in the badge component."],
|
|
106
|
+
"explainer": "- Shows a lead-time badge on each product card.\n"
|
|
107
|
+
"- Reads the lead time from the product's vendor.",
|
|
108
|
+
"analysis": "- Verified the badge renders for products with and without a vendor.\n"
|
|
109
|
+
"- Skipped: styling and i18n.",
|
|
110
|
+
"comments": [
|
|
111
|
+
{"path": "app/models/Product.php", "line": 42, "severity": "should-fix",
|
|
112
|
+
"title": "A product with no vendor can crash the lead-time badge",
|
|
113
|
+
"impact": "A shopper viewing such a product sees the card fail instead of loading.",
|
|
114
|
+
"body": "Guard against a null vendor before reading its lead time.",
|
|
115
|
+
"reply_to": None, "suggestion": "if ($vendor === null) return null;",
|
|
116
|
+
"confidence": "high"},
|
|
117
|
+
{"path": "src/javascripts/Badge.tsx", "line": 10, "severity": "nit",
|
|
118
|
+
"title": "Use const instead of let", "impact": "Style only — no effect on behavior.",
|
|
119
|
+
"body": "Prefer `const` over `let` here.", "reply_to": None, "suggestion": "",
|
|
120
|
+
"confidence": "medium"}]}))
|
|
121
|
+
write(st / slug(REPO2) / str(PR3) / "status", "done")
|
|
122
|
+
write(st / slug(REPO2) / str(PR3) / "review.json", json.dumps({
|
|
123
|
+
"event": "COMMENT",
|
|
124
|
+
"summary": "Adds a token bucket in front of the lead-time endpoint. One blocker.",
|
|
125
|
+
"keyPoints": ["The limiter key ignores the tenant, so one tenant can starve another."],
|
|
126
|
+
"explainer": "- Rate-limits GET /lead-times per API key.",
|
|
127
|
+
"analysis": "- Traced the limiter key from the request to the store.",
|
|
128
|
+
"comments": [
|
|
129
|
+
{"path": "api/limits.py", "line": 18, "severity": "blocker",
|
|
130
|
+
"title": "Two tenants share one rate-limit bucket",
|
|
131
|
+
"impact": "A busy tenant can lock a quiet tenant out of lead times entirely.",
|
|
132
|
+
"body": "Key the bucket on (tenant, api_key), not api_key alone.",
|
|
133
|
+
"reply_to": None, "suggestion": "key = f\"{tenant}:{api_key}\"",
|
|
134
|
+
"confidence": "high"}]}))
|
|
135
|
+
|
|
136
|
+
# A repository profile for REPO, so the Skills page shows the "Repository profile" section
|
|
137
|
+
# as profiled; REPO2's last run failed, so the page shows the error state (the failure text,
|
|
138
|
+
# the log tail, Retry). Mirrors dashboard-ui/e2e/fixture.ts.
|
|
139
|
+
import rs_profile
|
|
140
|
+
prof = {
|
|
141
|
+
"summary": "A storefront: product cards read vendor lead times; payments and auth are "
|
|
142
|
+
"the sharp edges.",
|
|
143
|
+
"critical_paths": [
|
|
144
|
+
{"path_glob": "app/payments/**",
|
|
145
|
+
"why": "Charges real cards; a silent bug double-bills or under-bills a shopper.",
|
|
146
|
+
"checks": ["Every amount is in minor units end to end",
|
|
147
|
+
"Refund paths mirror the charge path"]},
|
|
148
|
+
{"path_glob": "app/models/Product.php",
|
|
149
|
+
"why": "Every product card and order line reads this model.",
|
|
150
|
+
"checks": ["Callers handle a null vendor",
|
|
151
|
+
"Lead-time cache is invalidated on vendor change"]}],
|
|
152
|
+
"risk_paths": [{"label": "payments", "pattern": "app/payments/"},
|
|
153
|
+
{"label": "auth", "pattern": "app/auth/"}],
|
|
154
|
+
"review_rules": ["Money is always integer cents; flag any float arithmetic on amounts."],
|
|
155
|
+
"do_not_flag": ["The committed pnpm-lock.yaml is intentional."],
|
|
156
|
+
"meta": {"generated_at": 1778000000, "model": "claude-sonnet-4-5",
|
|
157
|
+
"dropped_globs": ["app/billing/**"], "head": "deadbeefcafe0000",
|
|
158
|
+
"edited_at": None, "edited_by": ""}}
|
|
159
|
+
pdir = root / "profiles" / slug(REPO)
|
|
160
|
+
write(pdir / "profile.json", json.dumps(prof, indent=1) + "\n")
|
|
161
|
+
write(pdir / "profile.md", rs_profile.to_markdown(prof))
|
|
162
|
+
write(pdir / "status", "done")
|
|
163
|
+
write(pdir / "runner", USER)
|
|
164
|
+
write(pdir / "usage.json", json.dumps({
|
|
165
|
+
"model": "claude-sonnet-4-5", "input_tokens": 9120, "output_tokens": 1840,
|
|
166
|
+
"cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "cost_usd": 0.055,
|
|
167
|
+
"duration_ms": 48000}))
|
|
168
|
+
|
|
169
|
+
pdir2 = root / "profiles" / slug(REPO2)
|
|
170
|
+
write(pdir2 / "status", "failed: the model produced no result (see "
|
|
171
|
+
f"{pdir2 / 'agent.log'})")
|
|
172
|
+
write(pdir2 / "agent.log", "error: unknown option '---'\n\n(Did you mean --add-dir?)\n")
|
|
173
|
+
|
|
174
|
+
# Fake gh: every call fails, so the server falls back to the on-disk fixture.
|
|
175
|
+
gh = root / "fakebin" / "gh"
|
|
176
|
+
write(gh, "#!/bin/sh\nexit 1\n")
|
|
177
|
+
gh.chmod(gh.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
178
|
+
|
|
179
|
+
exp = int(time.time()) + 7 * 24 * 3600
|
|
180
|
+
sig = hmac.new(secret.encode(), f"handoff:{USER}:{exp}".encode(), sha256).hexdigest()
|
|
181
|
+
print("==> ReviewStage DEMO — offline fixture, nothing reaches GitHub or Claude")
|
|
182
|
+
print(f" repos: {REPO}, {REPO2}")
|
|
183
|
+
print(f" sign in here: http://localhost:{port}/handoff/accept?login={USER}&exp={exp}&sig={sig}")
|
|
184
|
+
print(" (the login form is disabled in demo mode — use the link above)", flush=True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|