pierre-review 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/dist/api/plugins/error-handler.js +40 -0
  4. package/dist/api/routes/health.js +7 -0
  5. package/dist/api/routes/me.js +34 -0
  6. package/dist/api/routes/mergers.js +7 -0
  7. package/dist/api/routes/open-prs.js +21 -0
  8. package/dist/api/routes/prs.js +50 -0
  9. package/dist/api/routes/repos.js +188 -0
  10. package/dist/api/routes/threads.js +20 -0
  11. package/dist/api/routes/timeline.js +73 -0
  12. package/dist/api/routes/users.js +28 -0
  13. package/dist/app.js +48 -0
  14. package/dist/ascii.js +12 -0
  15. package/dist/cli.js +138 -0
  16. package/dist/config.js +41 -0
  17. package/dist/db/cleanup.js +22 -0
  18. package/dist/db/client.js +13 -0
  19. package/dist/db/migrate.js +15 -0
  20. package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
  21. package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
  22. package/dist/db/migrations/0002_famous_sersi.sql +9 -0
  23. package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
  24. package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
  25. package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
  26. package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
  27. package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
  28. package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
  29. package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
  30. package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
  31. package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
  32. package/dist/db/migrations/meta/_journal.json +48 -0
  33. package/dist/db/queries.js +837 -0
  34. package/dist/db/run-migrations.js +10 -0
  35. package/dist/db/schema.js +248 -0
  36. package/dist/db/triage.js +168 -0
  37. package/dist/github/auth.js +19 -0
  38. package/dist/github/client.js +30 -0
  39. package/dist/github/local-user.js +92 -0
  40. package/dist/github/queries.js +249 -0
  41. package/dist/index.js +49 -0
  42. package/dist/sync/bot-detection.js +24 -0
  43. package/dist/sync/commit-files.js +50 -0
  44. package/dist/sync/derive-thread-state.js +38 -0
  45. package/dist/sync/scheduler.js +28 -0
  46. package/dist/sync/sync-manager.js +150 -0
  47. package/dist/sync/sync-repo.js +122 -0
  48. package/dist/sync/upsert.js +528 -0
  49. package/package.json +46 -0
  50. package/public/assets/index-6p3C9xk7.css +10 -0
  51. package/public/assets/index-C-CZcLLq.js +1360 -0
  52. package/public/index.html +25 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Wakeman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # pierre-review
2
+
3
+ A **local-only dashboard for tracking your team's GitHub PR activity** across
4
+ multiple repositories. Run it on your machine, see at a glance who's doing what,
5
+ which PRs are stalled, which review threads are sitting untouched, and what needs
6
+ _your_ attention right now — all rendered as an interactive timeline.
7
+
8
+ There's no hosted backend, no database server, and no stored credentials. It
9
+ authenticates by shelling out to your already-logged-in `gh` CLI, syncs activity
10
+ into a local SQLite file, and serves the dashboard from a single local process.
11
+
12
+ ## Quick start
13
+
14
+ ```bash
15
+ npx pierre-review
16
+ ```
17
+
18
+ This starts the server, prints a local URL, and opens your browser to it.
19
+
20
+ Or install globally and use the short command:
21
+
22
+ ```bash
23
+ npm install -g pierre-review
24
+ pierre
25
+ ```
26
+
27
+ ## Prerequisites
28
+
29
+ - **Node.js ≥ 20.**
30
+ - **GitHub CLI**, installed and authenticated. Install it from
31
+ <https://cli.github.com>, then run:
32
+
33
+ ```bash
34
+ gh auth login
35
+ ```
36
+
37
+ `pierre` reads your team's activity using your `gh` token. It pre-checks this on
38
+ startup and exits with a friendly message if `gh` is missing or not authed.
39
+
40
+ > **Native module note:** `pierre-review` depends on `better-sqlite3`, a native
41
+ > addon. npm installs a prebuilt binary for common platforms; if none matches your
42
+ > Node/OS/arch, npm compiles it from source on install (needs a C++ toolchain —
43
+ > Xcode Command Line Tools on macOS, `build-essential` + Python on Linux, MSVC
44
+ > build tools on Windows).
45
+
46
+ ## Usage
47
+
48
+ ```
49
+ pierre [options]
50
+ pierre-review [options]
51
+ ```
52
+
53
+ | Flag | Env | Default | Description |
54
+ |------|-----|---------|-------------|
55
+ | `--no-open` | `NO_OPEN` | — | Don't open the browser on start. |
56
+ | `--port <n>` | `PORT` | `4000` | Port to listen on. |
57
+ | `--db <path>` | `DATABASE_URL` | `~/.pierre-review/pierre-review.sqlite` | SQLite DB path. |
58
+ | `-h`, `--help` | — | — | Show usage. |
59
+
60
+ Examples:
61
+
62
+ ```bash
63
+ pierre --port 4123 --no-open
64
+ pierre --db /tmp/pierre.sqlite
65
+ ```
66
+
67
+ ## Data directory
68
+
69
+ By default the local SQLite database lives at:
70
+
71
+ ```
72
+ ~/.pierre-review/pierre-review.sqlite
73
+ ```
74
+
75
+ The directory is created automatically. Override the location with `--db` or the
76
+ `DATABASE_URL` environment variable. No team activity data or credentials are ever
77
+ sent anywhere — everything stays on your machine.
78
+
79
+ ## How it works
80
+
81
+ Once running, open the printed URL (default <http://localhost:4000>). Add the
82
+ repositories you want to watch from the in-app picker; the app syncs their PR
83
+ activity (full backfill on first sync, incremental every few minutes thereafter)
84
+ into your local DB and renders it as a timeline.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,40 @@
1
+ // Centralised error handling: validation errors -> 400, anything with an
2
+ // explicit statusCode is honoured, everything else -> 500.
3
+ //
4
+ // `spaFallback` is set in single-process production mode (when the built SPA is
5
+ // served alongside the API). Fastify allows only one not-found handler per
6
+ // context, so the SPA fallback lives here rather than as a second
7
+ // setNotFoundHandler in app.ts.
8
+ export function registerErrorHandler(app, spaFallback = false) {
9
+ app.setErrorHandler((err, req, reply) => {
10
+ if (err.validation) {
11
+ reply.status(400).send({
12
+ error: 'ValidationError',
13
+ message: err.message,
14
+ details: err.validation,
15
+ });
16
+ return;
17
+ }
18
+ const status = err.statusCode ?? 500;
19
+ if (status >= 500) {
20
+ req.log.error({ err }, 'request failed');
21
+ }
22
+ reply.status(status).send({
23
+ error: err.name || 'Error',
24
+ message: err.message,
25
+ });
26
+ });
27
+ app.setNotFoundHandler((req, reply) => {
28
+ // SPA fallback: any non-/api GET that didn't match a static asset returns
29
+ // index.html so client-side routes work on reload. Unknown /api routes still
30
+ // return JSON 404 (below). `reply.sendFile` is decorated by @fastify/static.
31
+ if (spaFallback && req.method === 'GET' && !req.url.startsWith('/api')) {
32
+ return reply.sendFile('index.html');
33
+ }
34
+ return reply.status(404).send({
35
+ error: 'NotFound',
36
+ message: `Route ${req.method} ${req.url} not found`,
37
+ });
38
+ });
39
+ }
40
+ //# sourceMappingURL=error-handler.js.map
@@ -0,0 +1,7 @@
1
+ export async function healthRoutes(app) {
2
+ app.get('/api/health', async () => ({
3
+ status: 'ok',
4
+ time: new Date().toISOString(),
5
+ }));
6
+ }
7
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1,34 @@
1
+ import { ensureLocalUser } from '../../github/local-user.js';
2
+ import { dismissMyTurn, getMyTurn } from '../../db/queries.js';
3
+ const dismissSchema = {
4
+ body: {
5
+ type: 'object',
6
+ required: ['kind', 'refId'],
7
+ additionalProperties: false,
8
+ properties: {
9
+ kind: { type: 'string', enum: ['review_request', 'thread'] },
10
+ refId: { type: 'integer' },
11
+ },
12
+ },
13
+ };
14
+ export async function meRoutes(app) {
15
+ app.get('/api/me', async () => {
16
+ const user = ensureLocalUser();
17
+ const myTurn = getMyTurn();
18
+ return {
19
+ user,
20
+ counts: {
21
+ awaitingReview: myTurn.awaitingReview.length,
22
+ yourPrsActivity: myTurn.yourPrs.length,
23
+ threadsAwaiting: myTurn.threadsAwaiting.length,
24
+ },
25
+ };
26
+ });
27
+ app.get('/api/my-turn', async () => getMyTurn());
28
+ app.post('/api/my-turn/dismiss', { schema: dismissSchema }, async (req) => {
29
+ const { kind, refId } = req.body;
30
+ dismissMyTurn(kind, refId);
31
+ return { status: 'ok' };
32
+ });
33
+ }
34
+ //# sourceMappingURL=me.js.map
@@ -0,0 +1,7 @@
1
+ import { getMergers } from '../../db/queries.js';
2
+ // Per-repo "merge rights" inference (distinct users who've merged a PR there).
3
+ // Reference data — no filters; the frontend looks it up per timeline row.
4
+ export async function mergersRoutes(app) {
5
+ app.get('/api/mergers', async () => getMergers());
6
+ }
7
+ //# sourceMappingURL=mergers.js.map
@@ -0,0 +1,21 @@
1
+ import { getOpenPrs } from '../../db/queries.js';
2
+ function parseIntList(raw) {
3
+ if (!raw)
4
+ return null;
5
+ const ids = raw
6
+ .split(',')
7
+ .map((s) => Number.parseInt(s.trim(), 10))
8
+ .filter((n) => Number.isFinite(n));
9
+ return ids.length > 0 ? ids : null;
10
+ }
11
+ export async function openPrsRoutes(app) {
12
+ app.get('/api/open-prs', async (req) => {
13
+ const q = req.query;
14
+ const prs = getOpenPrs({
15
+ repoIds: parseIntList(q.repoIds),
16
+ userIds: parseIntList(q.userIds),
17
+ });
18
+ return { prs };
19
+ });
20
+ }
21
+ //# sourceMappingURL=open-prs.js.map
@@ -0,0 +1,50 @@
1
+ import { getPrDetail, markPrViewed } from '../../db/queries.js';
2
+ const idParamSchema = {
3
+ params: {
4
+ type: 'object',
5
+ required: ['id'],
6
+ properties: { id: { type: 'integer' } },
7
+ },
8
+ };
9
+ const markViewedSchema = {
10
+ ...idParamSchema,
11
+ body: {
12
+ type: 'object',
13
+ additionalProperties: false,
14
+ properties: { sha: { type: 'string' } },
15
+ },
16
+ };
17
+ export async function prRoutes(app) {
18
+ app.get('/api/prs/:id', { schema: idParamSchema }, async (req, reply) => {
19
+ const { id } = req.params;
20
+ const pr = getPrDetail(id);
21
+ if (!pr) {
22
+ reply.status(404);
23
+ return { error: 'NotFound', message: `PR ${id} not found` };
24
+ }
25
+ return pr;
26
+ });
27
+ // Record that the local user has seen this PR up to `sha` (defaults to the
28
+ // current head). Clears "new since last viewed" badges.
29
+ app.post('/api/prs/:id/mark-viewed', { schema: markViewedSchema }, async (req, reply) => {
30
+ const { id } = req.params;
31
+ const { sha } = (req.body ?? {});
32
+ const ok = markPrViewed(id, sha);
33
+ if (!ok) {
34
+ reply.status(404);
35
+ return { error: 'NotFound', message: `PR ${id} not found` };
36
+ }
37
+ return { status: 'ok' };
38
+ });
39
+ // Explicit "I've seen this" without opening — same effect as mark-viewed.
40
+ app.post('/api/prs/:id/dismiss', { schema: idParamSchema }, async (req, reply) => {
41
+ const { id } = req.params;
42
+ const ok = markPrViewed(id);
43
+ if (!ok) {
44
+ reply.status(404);
45
+ return { error: 'NotFound', message: `PR ${id} not found` };
46
+ }
47
+ return { status: 'ok' };
48
+ });
49
+ }
50
+ //# sourceMappingURL=prs.js.map
@@ -0,0 +1,188 @@
1
+ import { getGraphqlClient } from '../../github/client.js';
2
+ import { REPO_ID_QUERY, REPO_SEARCH_QUERY, } from '../../github/queries.js';
3
+ import { upsertRepo } from '../../sync/upsert.js';
4
+ import { getSyncStatus, isSyncRunning, runSyncForRepo, } from '../../sync/sync-manager.js';
5
+ import { deleteRepo, getRepo, getWatchedRepoNodeIds, listRepos, } from '../../db/queries.js';
6
+ const createRepoSchema = {
7
+ body: {
8
+ type: 'object',
9
+ required: ['owner', 'name'],
10
+ additionalProperties: false,
11
+ properties: {
12
+ owner: { type: 'string', minLength: 1 },
13
+ name: { type: 'string', minLength: 1 },
14
+ },
15
+ },
16
+ };
17
+ const idParamSchema = {
18
+ params: {
19
+ type: 'object',
20
+ required: ['id'],
21
+ properties: { id: { type: 'integer' } },
22
+ },
23
+ };
24
+ const syncSchema = {
25
+ ...idParamSchema,
26
+ querystring: {
27
+ type: 'object',
28
+ additionalProperties: false,
29
+ properties: { full: { type: 'boolean', default: false } },
30
+ },
31
+ };
32
+ const searchSchema = {
33
+ querystring: {
34
+ type: 'object',
35
+ required: ['q'],
36
+ additionalProperties: false,
37
+ properties: {
38
+ q: { type: 'string', minLength: 1, maxLength: 256 },
39
+ cursor: { type: 'string', minLength: 1 },
40
+ limit: { type: 'integer', default: 10, minimum: 1, maximum: 25 },
41
+ },
42
+ },
43
+ };
44
+ export async function repoRoutes(app) {
45
+ app.get('/api/repos', async () => listRepos());
46
+ // Live GitHub repository search for the Add-repo picker. Best-match ordering
47
+ // (GitHub default), already-watched repos filtered out, owned/member repos
48
+ // floated to the top. Detail comes straight from GitHub — nothing persisted.
49
+ app.get('/api/repos/search', { schema: searchSchema }, async (req, reply) => {
50
+ const { q, cursor, limit } = req.query;
51
+ const term = q.trim();
52
+ if (!term) {
53
+ reply.status(400);
54
+ return { error: 'BadRequest', message: 'Search query must not be empty' };
55
+ }
56
+ const client = getGraphqlClient();
57
+ let resp;
58
+ try {
59
+ // NB: the GraphQL variable is `searchQuery`, not `query` — @octokit/graphql
60
+ // reserves `query` for the document body and rejects it as a variable name.
61
+ resp = await client(REPO_SEARCH_QUERY, {
62
+ searchQuery: term,
63
+ first: limit,
64
+ cursor: cursor ?? null,
65
+ });
66
+ }
67
+ catch (err) {
68
+ reply.status(502);
69
+ return {
70
+ error: 'GitHubError',
71
+ message: err instanceof Error ? err.message : 'GitHub search failed',
72
+ };
73
+ }
74
+ const watched = getWatchedRepoNodeIds();
75
+ const me = resp.viewer.login.toLowerCase();
76
+ const orgLogins = new Set(resp.viewer.organizations.nodes.map((o) => o.login.toLowerCase()));
77
+ const results = resp.search.nodes
78
+ // Guard the union: type:REPOSITORY yields repositories, but a non-repo node
79
+ // would serialise as {} (no id) — drop those, then drop already-watched.
80
+ .filter((n) => typeof n.id === 'string')
81
+ .filter((n) => !watched.has(n.id))
82
+ .map((n) => {
83
+ const ownerLogin = n.owner.login;
84
+ return {
85
+ githubNodeId: n.id,
86
+ owner: ownerLogin,
87
+ name: n.name,
88
+ fullName: n.nameWithOwner,
89
+ description: n.description,
90
+ ownerAvatarUrl: n.owner.avatarUrl,
91
+ stargazerCount: n.stargazerCount,
92
+ openPrCount: n.pullRequests.totalCount,
93
+ url: n.url,
94
+ isPrivate: n.isPrivate,
95
+ isOwnedOrMember: ownerLogin.toLowerCase() === me ||
96
+ orgLogins.has(ownerLogin.toLowerCase()),
97
+ };
98
+ });
99
+ // Float owned/member repos to the top, preserving GitHub's best-match order
100
+ // within each group (Array.prototype.sort is stable on Node ≥ 12).
101
+ results.sort((a, b) => Number(b.isOwnedOrMember) - Number(a.isOwnedOrMember));
102
+ const body = {
103
+ results,
104
+ hasNextPage: resp.search.pageInfo.hasNextPage,
105
+ cursor: resp.search.pageInfo.endCursor,
106
+ };
107
+ return body;
108
+ });
109
+ app.post('/api/repos', { schema: createRepoSchema }, async (req, reply) => {
110
+ const { owner, name } = req.body;
111
+ const client = getGraphqlClient();
112
+ let resp;
113
+ try {
114
+ resp = await client(REPO_ID_QUERY, { owner, name });
115
+ }
116
+ catch (err) {
117
+ reply.status(502);
118
+ return {
119
+ error: 'GitHubError',
120
+ message: err instanceof Error ? err.message : 'GitHub request failed',
121
+ };
122
+ }
123
+ if (!resp.repository) {
124
+ reply.status(404);
125
+ return {
126
+ error: 'NotFound',
127
+ message: `Repository ${owner}/${name} not found or inaccessible. Check the name and your gh auth / SSO.`,
128
+ };
129
+ }
130
+ const canonOwner = resp.repository.owner.login;
131
+ const canonName = resp.repository.name;
132
+ const repoId = upsertRepo(canonOwner, canonName, resp.repository.id);
133
+ // Kick off the initial backfill in the background.
134
+ runSyncForRepo(repoId, app.log, { background: true });
135
+ reply.status(201);
136
+ return getRepo(repoId);
137
+ });
138
+ app.delete('/api/repos/:id', { schema: idParamSchema }, async (req, reply) => {
139
+ const { id } = req.params;
140
+ // A sync in flight would re-create the repo (and its rows) right after we
141
+ // delete them, since the sync's upserts are still running. Refuse until it
142
+ // settles — the cron tick / initial backfill is short.
143
+ if (isSyncRunning(id)) {
144
+ reply.status(409);
145
+ return {
146
+ error: 'Conflict',
147
+ message: 'A sync is running for this repo — try removing it again in a moment.',
148
+ };
149
+ }
150
+ const ok = deleteRepo(id);
151
+ if (!ok) {
152
+ reply.status(404);
153
+ return { error: 'NotFound', message: `Repo ${id} not found` };
154
+ }
155
+ reply.status(204);
156
+ return null;
157
+ });
158
+ app.post('/api/repos/:id/sync', { schema: syncSchema }, async (req, reply) => {
159
+ const { id } = req.params;
160
+ if (!getRepo(id)) {
161
+ reply.status(404);
162
+ return { error: 'NotFound', message: `Repo ${id} not found` };
163
+ }
164
+ // ?full=true forces a full backfill (catches CI/thread-resolve changes
165
+ // that don't bump PR.updatedAt and so lag the incremental path).
166
+ const { full } = req.query;
167
+ const started = runSyncForRepo(id, app.log, {
168
+ background: true,
169
+ forceFull: full === true,
170
+ });
171
+ if (!started) {
172
+ reply.status(409);
173
+ return { error: 'Conflict', message: 'A sync is already running for this repo' };
174
+ }
175
+ reply.status(202);
176
+ return { status: 'started' };
177
+ });
178
+ app.get('/api/repos/:id/sync-status', { schema: idParamSchema }, async (req, reply) => {
179
+ const { id } = req.params;
180
+ const status = getSyncStatus(id);
181
+ if (!getRepo(id)) {
182
+ reply.status(404);
183
+ return { error: 'NotFound', message: `Repo ${id} not found` };
184
+ }
185
+ return status;
186
+ });
187
+ }
188
+ //# sourceMappingURL=repos.js.map
@@ -0,0 +1,20 @@
1
+ import { getThreadDetail } from '../../db/queries.js';
2
+ const idParamSchema = {
3
+ params: {
4
+ type: 'object',
5
+ required: ['id'],
6
+ properties: { id: { type: 'integer' } },
7
+ },
8
+ };
9
+ export async function threadRoutes(app) {
10
+ app.get('/api/threads/:id', { schema: idParamSchema }, async (req, reply) => {
11
+ const { id } = req.params;
12
+ const thread = getThreadDetail(id);
13
+ if (!thread) {
14
+ reply.status(404);
15
+ return { error: 'NotFound', message: `Thread ${id} not found` };
16
+ }
17
+ return thread;
18
+ });
19
+ }
20
+ //# sourceMappingURL=threads.js.map
@@ -0,0 +1,73 @@
1
+ import { getTimeline } from '../../db/queries.js';
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ // Local copies of the shared value constants. `@pierre-review/shared` is a
4
+ // types-only workspace package that is NOT shipped in the published tarball, so
5
+ // the backend must not import runtime values from it (only `import type`, which
6
+ // `verbatimModuleSyntax` erases). Keep these in sync with packages/shared.
7
+ const EVENT_TYPES = [
8
+ 'pr_opened',
9
+ 'pr_merged',
10
+ 'pr_closed',
11
+ 'pr_reopened',
12
+ 'pr_ready_for_review',
13
+ 'review_submitted',
14
+ 'review_comment',
15
+ 'pr_comment',
16
+ 'commit_pushed',
17
+ ];
18
+ const PR_STATUSES = ['draft', 'open', 'merged', 'closed'];
19
+ function parseIntList(raw) {
20
+ if (!raw)
21
+ return null;
22
+ const ids = raw
23
+ .split(',')
24
+ .map((s) => Number.parseInt(s.trim(), 10))
25
+ .filter((n) => Number.isFinite(n));
26
+ return ids.length > 0 ? ids : null;
27
+ }
28
+ function parseTypes(raw) {
29
+ if (!raw)
30
+ return null;
31
+ const allowed = new Set(EVENT_TYPES);
32
+ const types = raw
33
+ .split(',')
34
+ .map((s) => s.trim())
35
+ .filter((s) => allowed.has(s));
36
+ return types.length > 0 ? types : null;
37
+ }
38
+ // Absent (undefined) → null = no status filter (show all). Present, even empty
39
+ // ("") → an explicit (possibly empty) set, so deselecting every status shows
40
+ // nothing rather than falling back to "all".
41
+ function parseStatuses(raw) {
42
+ if (raw === undefined)
43
+ return null;
44
+ const allowed = new Set(PR_STATUSES);
45
+ return raw
46
+ .split(',')
47
+ .map((s) => s.trim())
48
+ .filter((s) => allowed.has(s));
49
+ }
50
+ function parseDate(raw, fallback) {
51
+ if (!raw)
52
+ return fallback;
53
+ const d = new Date(raw);
54
+ return Number.isNaN(d.getTime()) ? fallback : d;
55
+ }
56
+ export async function timelineRoutes(app) {
57
+ app.get('/api/timeline', async (req) => {
58
+ const q = req.query;
59
+ const now = new Date();
60
+ const filters = {
61
+ from: parseDate(q.from, new Date(now.getTime() - 14 * DAY_MS)),
62
+ to: parseDate(q.to, now),
63
+ repoIds: parseIntList(q.repoIds),
64
+ userIds: parseIntList(q.userIds),
65
+ types: parseTypes(q.types),
66
+ statuses: parseStatuses(q.statuses),
67
+ excludeBots: q.excludeBots !== 'false',
68
+ excludeStale: q.excludeStale === 'true',
69
+ };
70
+ return getTimeline(filters);
71
+ });
72
+ }
73
+ //# sourceMappingURL=timeline.js.map
@@ -0,0 +1,28 @@
1
+ import { listUsers, setUserBot } from '../../db/queries.js';
2
+ const patchUserSchema = {
3
+ params: {
4
+ type: 'object',
5
+ required: ['id'],
6
+ properties: { id: { type: 'integer' } },
7
+ },
8
+ body: {
9
+ type: 'object',
10
+ required: ['isBot'],
11
+ additionalProperties: false,
12
+ properties: { isBot: { type: 'boolean' } },
13
+ },
14
+ };
15
+ export async function userRoutes(app) {
16
+ app.get('/api/users', async () => listUsers());
17
+ app.patch('/api/users/:id', { schema: patchUserSchema }, async (req, reply) => {
18
+ const { id } = req.params;
19
+ const { isBot } = req.body;
20
+ const updated = setUserBot(id, isBot);
21
+ if (!updated) {
22
+ reply.status(404);
23
+ return { error: 'NotFound', message: `User ${id} not found` };
24
+ }
25
+ return updated;
26
+ });
27
+ }
28
+ //# sourceMappingURL=users.js.map
package/dist/app.js ADDED
@@ -0,0 +1,48 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import Fastify, {} from 'fastify';
4
+ import cors from '@fastify/cors';
5
+ import fastifyStatic from '@fastify/static';
6
+ import { registerErrorHandler } from './api/plugins/error-handler.js';
7
+ import { healthRoutes } from './api/routes/health.js';
8
+ import { repoRoutes } from './api/routes/repos.js';
9
+ import { userRoutes } from './api/routes/users.js';
10
+ import { timelineRoutes } from './api/routes/timeline.js';
11
+ import { prRoutes } from './api/routes/prs.js';
12
+ import { threadRoutes } from './api/routes/threads.js';
13
+ import { meRoutes } from './api/routes/me.js';
14
+ import { openPrsRoutes } from './api/routes/open-prs.js';
15
+ import { mergersRoutes } from './api/routes/mergers.js';
16
+ export async function buildApp() {
17
+ const app = Fastify({
18
+ logger: {
19
+ level: process.env.LOG_LEVEL ?? 'info',
20
+ transport: process.env.NODE_ENV === 'production'
21
+ ? undefined
22
+ : { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss' } },
23
+ },
24
+ });
25
+ await app.register(cors, { origin: true });
26
+ // Single-process production mode: serve the built SPA when the bundled assets
27
+ // exist next to the compiled server (release/dist → release/public). In dev
28
+ // there is no sibling `public/` dir, so this no-ops and Vite (:5173) serves the
29
+ // UI by proxying /api back to this server — dev stays unchanged.
30
+ const publicDir = resolve(import.meta.dirname, '../public');
31
+ const serveSpa = existsSync(resolve(publicDir, 'index.html'));
32
+ if (serveSpa) {
33
+ await app.register(fastifyStatic, { root: publicDir, wildcard: false });
34
+ }
35
+ // The not-found handler doubles as the SPA fallback when serving the SPA.
36
+ registerErrorHandler(app, serveSpa);
37
+ await app.register(healthRoutes);
38
+ await app.register(repoRoutes);
39
+ await app.register(userRoutes);
40
+ await app.register(timelineRoutes);
41
+ await app.register(prRoutes);
42
+ await app.register(threadRoutes);
43
+ await app.register(meRoutes);
44
+ await app.register(openPrsRoutes);
45
+ await app.register(mergersRoutes);
46
+ return app;
47
+ }
48
+ //# sourceMappingURL=app.js.map
package/dist/ascii.js ADDED
@@ -0,0 +1,12 @@
1
+ // Cursive "Pierre" banner shown by the CLI on launch — figlet "Script" font
2
+ // (a flowing handwriting style). 5 lines, max 30 columns, so it
3
+ // renders cleanly in any terminal. String.raw keeps the backslashes literal.
4
+ export const PIERRE_ASCII = String.raw `
5
+ , __
6
+ /|/ \o
7
+ |___/ _ ,_ ,_ _
8
+ | | |/ / | / | |/
9
+ | |_/|__/ |_/ |_/|__/
10
+ `;
11
+ export const TAGLINE = "Local-only dashboard for your team's GitHub PR activity.";
12
+ //# sourceMappingURL=ascii.js.map