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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/bin/reviewstage.js +49 -0
  4. package/flock.py +87 -0
  5. package/main.js +281 -0
  6. package/package.json +44 -0
  7. package/pages/phone.html +79 -0
  8. package/pages/preparing.html +66 -0
  9. package/preload.cjs +15 -0
  10. package/server/bin/bootstrap.sh +318 -0
  11. package/server/bin/demo-fixture.py +188 -0
  12. package/server/bin/doctor.sh +310 -0
  13. package/server/bin/entrypoint.sh +191 -0
  14. package/server/bin/lib-common.sh +308 -0
  15. package/server/bin/lib-limits.sh +18 -0
  16. package/server/bin/lib-settings.sh +37 -0
  17. package/server/bin/notify.sh +335 -0
  18. package/server/bin/poller-loop.sh +68 -0
  19. package/server/bin/pr-watch.sh +356 -0
  20. package/server/bin/profile-repo.sh +141 -0
  21. package/server/bin/rs_agree.py +163 -0
  22. package/server/bin/rs_assets.py +10 -0
  23. package/server/bin/rs_device_flow.py +225 -0
  24. package/server/bin/rs_devices.py +220 -0
  25. package/server/bin/rs_diff.py +260 -0
  26. package/server/bin/rs_learn.py +754 -0
  27. package/server/bin/rs_md.py +107 -0
  28. package/server/bin/rs_paths.py +285 -0
  29. package/server/bin/rs_personal.py +276 -0
  30. package/server/bin/rs_profile.py +1091 -0
  31. package/server/bin/rs_push.py +417 -0
  32. package/server/bin/rs_queue.py +791 -0
  33. package/server/bin/rs_review_body.py +133 -0
  34. package/server/bin/rs_rollup.py +382 -0
  35. package/server/bin/rs_settings.py +265 -0
  36. package/server/bin/rs_stack.py +76 -0
  37. package/server/bin/rs_state.py +184 -0
  38. package/server/bin/rs_webhook.py +250 -0
  39. package/server/bin/run-qa.sh +295 -0
  40. package/server/bin/run-review.sh +402 -0
  41. package/server/bin/server.py +6271 -0
  42. package/server/bin/static/app-c45f0338184b.css +2 -0
  43. package/server/bin/static/app-c45f0338184b.js +237 -0
  44. package/server/bin/static/assets.json +5 -0
  45. package/server/bin/static/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
  46. package/server/bin/static/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
  47. package/server/bin/static/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
  48. package/server/bin/static/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
  49. package/server/bin/static/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
  50. package/server/bin/static/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
  51. package/server/bin/static/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
  52. package/server/bin/static/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
  53. package/server/bin/static/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
  54. package/server/bin/static/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
  55. package/server/bin/static/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
  56. package/server/bin/static/icons/apple-touch-icon.png +0 -0
  57. package/server/bin/static/icons/icon-192.png +0 -0
  58. package/server/bin/static/icons/icon-512.png +0 -0
  59. package/server/bin/static/icons/maskable-192.png +0 -0
  60. package/server/bin/static/icons/maskable-512.png +0 -0
  61. package/server/bin/static/manifest.webmanifest +20 -0
  62. package/server/bin/static/offline.html +28 -0
  63. package/server/bin/static/sw.js +126 -0
  64. package/server/skills/examples/deep-depth-domain-example.md +68 -0
  65. package/server/skills/examples/team-default-domain-example.md +68 -0
  66. package/server/skills/global-review.md +58 -0
  67. package/server/skills/pr-qa-guide/SKILL.md +187 -0
  68. package/server/skills/pr-qa-guide/template.html +489 -0
  69. package/server/skills/pr-review/SKILL.md +129 -0
  70. package/server/skills/repo-profile/SKILL.md +67 -0
  71. package/server.js +140 -0
  72. package/tools.js +147 -0
  73. package/tools.json +30 -0
  74. 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()