pierre-review 0.1.83 → 0.1.85

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.
@@ -27,6 +27,11 @@ export async function activityRoutes(app) {
27
27
  const limit = q.limit != null ? Number(q.limit) : null;
28
28
  const offset = q.offset != null ? Number(q.offset) : 0;
29
29
  const prId = q.prId != null ? Number(q.prId) : null;
30
+ // Bot-only feed window (days) — clamped to 1..90; only honored on the botsOnly path.
31
+ const botWindowDaysRaw = q.botWindowDays != null ? Number(q.botWindowDays) : null;
32
+ const botWindowDays = botWindowDaysRaw != null && Number.isFinite(botWindowDaysRaw)
33
+ ? Math.min(90, Math.max(1, Math.trunc(botWindowDaysRaw)))
34
+ : null;
30
35
  return getConsolidatedFeed(accountIdOf(req), {
31
36
  repoIds: parseIntList(q.repoIds),
32
37
  userIds: parseIntList(q.userIds),
@@ -36,6 +41,7 @@ export async function activityRoutes(app) {
36
41
  excludeBots: q.excludeBots === 'true',
37
42
  allowBotIds: parseIntList(q.allowBotIds),
38
43
  botsOnly: q.botsOnly === 'true',
44
+ botWindowDays,
39
45
  });
40
46
  });
41
47
  // Mark the Activity Feed as seen (bumps the account's server-side "seen" marker to
@@ -1,4 +1,5 @@
1
- import { addBotMuteRule, deleteBotMuteRule, getBotAnalytics, getBotVendorPrs, getBotDedupClusters, listBotMuteRules, listDetectedReviewers, resolveScopeRepoIds, setReviewerOverride, } from '../../db/queries.js';
1
+ import { addBotMuteRule, deleteBotMuteRule, getBotAnalytics, getBotOnlyPrs, getBotVendorPrs, getBotDedupClusters, getResolvableBotThreadPrs, getResolvableBotThreadsForScope, listBotMuteRules, listDetectedReviewers, resolveScopeRepoIds, setReviewerOverride, SCOPE_RESOLVE_THREAD_CAP, } from '../../db/queries.js';
2
+ import { resolveThreadsOnGitHub } from '../../bot-triage/resolve.js';
2
3
  import { accountIdOf } from '../plugins/auth.js';
3
4
  // Parse a comma-separated id list into a positive-int array, or null when empty/absent (so
4
5
  // the query layer treats it as "all repos"). Mirrors the parser in activity.ts.
@@ -51,14 +52,14 @@ const analyticsSchema = {
51
52
  },
52
53
  },
53
54
  };
54
- // GET /api/bot-analytics/:kind/prs?window= — per-vendor PR drill-down. `kind` is left an open
55
- // string (AutomatedReviewerKind is types-only shared, unimportable at runtime; the query layer
56
- // coerces an unknown kind); the window reuses the same closed enum/default as /api/bot-analytics.
55
+ // GET /api/bot-analytics/vendor/:key/prs?window= — per-REVIEWER PR drill-down. `key` is the
56
+ // analytics-row identity: `u<userId>` (a single reviewer) or the 'pierre' sentinel; the handler
57
+ // parses it (anything else → 400). The window reuses the same closed enum/default as /api/bot-analytics.
57
58
  const vendorPrsSchema = {
58
59
  params: {
59
60
  type: 'object',
60
- required: ['kind'],
61
- properties: { kind: { type: 'string' } },
61
+ required: ['key'],
62
+ properties: { key: { type: 'string' } },
62
63
  },
63
64
  querystring: {
64
65
  type: 'object',
@@ -104,6 +105,39 @@ const ruleIdParamSchema = {
104
105
  properties: { id: { type: 'integer' } },
105
106
  },
106
107
  };
108
+ // GET /api/bot-threads/resolvable?scope=&repoIds= — the scope-wide review list. Same
109
+ // scope/repoIds resolution as the analytics routes (an explicit `repoIds` wins over `scope`).
110
+ const resolvableSchema = {
111
+ querystring: {
112
+ type: 'object',
113
+ additionalProperties: false,
114
+ properties: {
115
+ scope: { type: 'string' },
116
+ repoIds: { type: 'string' },
117
+ },
118
+ },
119
+ };
120
+ // POST /api/bot-threads/resolve — the confirm-gated scope-wide resolve. `threadIds` is the
121
+ // explicit reviewed list (required, non-empty allowed to be [] → no-op), capped at
122
+ // SCOPE_RESOLVE_THREAD_CAP per request (the client chunks a larger selection); `repoIds` is the
123
+ // optional scope the server re-derives eligibility against. maxItems is a hard input guard.
124
+ const scopeResolveSchema = {
125
+ body: {
126
+ type: 'object',
127
+ required: ['threadIds'],
128
+ additionalProperties: false,
129
+ properties: {
130
+ threadIds: {
131
+ type: 'array',
132
+ items: { type: 'integer' },
133
+ maxItems: SCOPE_RESOLVE_THREAD_CAP,
134
+ },
135
+ // Bounded like threadIds — an account has ≤100 repos, so 500 is generous headroom; an
136
+ // unbounded array would only ever be a hand-crafted request heading for a DB error.
137
+ repoIds: { type: 'array', items: { type: 'integer' }, maxItems: SCOPE_RESOLVE_THREAD_CAP },
138
+ },
139
+ },
140
+ };
107
141
  // Bot-triage platform routes (CORE, always registered). Detection/override, ROI analytics,
108
142
  // per-PR cross-bot dedup, and the mute / auto-triage rule store. Every handler is account-
109
143
  // scoped via accountIdOf(req); id-addressed reads/writes verify ownership → 404. No AI.
@@ -139,15 +173,40 @@ export async function botTriageRoutes(app) {
139
173
  const resp = await getBotAnalytics(accountId, window, scopeRepoIds);
140
174
  return resp;
141
175
  });
142
- // The per-vendor PR drill-down behind one Bot-ROI row: the PRs that automated reviewer kind
176
+ // The exact PR list behind the analytics totals.botOnlyPrs count "only a bot reviewed these".
177
+ // Same window/scope resolution as /api/bot-analytics (a specific `repoIds` wins over `scope`) so
178
+ // the amber caption's number and this expandable list are computed identically and can't drift.
179
+ // Unbounded but small (real bot-only PRs); no pagination.
180
+ app.get('/api/bot-analytics/bot-only-prs', { schema: analyticsSchema }, async (req) => {
181
+ const { window, scope, repoIds } = req.query;
182
+ const accountId = accountIdOf(req);
183
+ const explicit = parseIntList(repoIds);
184
+ const scopeRepoIds = explicit ?? (scope ? await resolveScopeRepoIds(accountId, scope) : null);
185
+ const { window: win, prs } = await getBotOnlyPrs(accountId, window, scopeRepoIds);
186
+ const resp = { window: win, prs, generatedAt: new Date().toISOString() };
187
+ return resp;
188
+ });
189
+ // The per-REVIEWER PR drill-down behind one Bot-ROI row: the PRs that one automated reviewer
143
190
  // touched in the window (threads/comments/acted-on/untouched/bot-only), newest-activity first.
144
- app.get('/api/bot-analytics/:kind/prs', { schema: vendorPrsSchema }, async (req) => {
145
- const { kind } = req.params;
191
+ // `key` is the analytics row identity `u<userId>` (a single reviewer) or the 'pierre' sentinel;
192
+ // anything else is a client bug → 400.
193
+ app.get('/api/bot-analytics/vendor/:key/prs', { schema: vendorPrsSchema }, async (req, reply) => {
194
+ const { key } = req.params;
146
195
  const { window, scope, repoIds } = req.query;
196
+ const m = /^u(\d+)$/.exec(key);
197
+ const target = m
198
+ ? { userId: Number(m[1]) }
199
+ : key === 'pierre'
200
+ ? { kind: 'pierre' }
201
+ : null;
202
+ if (!target) {
203
+ reply.status(400);
204
+ return { error: 'BadRequest', message: `Invalid vendor key: ${key}` };
205
+ }
147
206
  const accountId = accountIdOf(req);
148
207
  const explicit = parseIntList(repoIds);
149
208
  const scopeRepoIds = explicit ?? (scope ? await resolveScopeRepoIds(accountId, scope) : null);
150
- const resp = await getBotVendorPrs(accountId, kind, window, scopeRepoIds);
209
+ const resp = await getBotVendorPrs(accountId, target, window, scopeRepoIds);
151
210
  return resp;
152
211
  });
153
212
  // Cross-bot dedup clusters for one PR (≥2 automated reviewers on the same path/line window).
@@ -182,5 +241,46 @@ export async function botTriageRoutes(app) {
182
241
  reply.status(204);
183
242
  return null;
184
243
  });
244
+ // The scope-wide review list: every PR with ≥1 `likely_addressed` automated-reviewer thread
245
+ // across the account (or a repo scope), UNCAPPED, newest-thread-first, each row carrying all
246
+ // its resolvable thread ids + a bot thread-state mix + `totalThreads` (the whole backlog). The
247
+ // client sorts / paginates / "Select all"s across pages and chunks the resolve. Read-only.
248
+ app.get('/api/bot-threads/resolvable', { schema: resolvableSchema }, async (req) => {
249
+ const { scope, repoIds } = req.query;
250
+ const accountId = accountIdOf(req);
251
+ const explicit = parseIntList(repoIds);
252
+ const scopeRepoIds = explicit ?? (scope ? await resolveScopeRepoIds(accountId, scope) : null);
253
+ const { prs, totalThreads } = await getResolvableBotThreadPrs(accountId, scopeRepoIds);
254
+ const resp = {
255
+ prs,
256
+ totalThreads,
257
+ generatedAt: new Date().toISOString(),
258
+ };
259
+ return resp;
260
+ });
261
+ // The confirm-gated scope-wide resolve. NEVER blind: the server RE-DERIVES the eligible set
262
+ // (owned + automated-reviewer-originated + `likely_addressed` + unresolved) ∩ the client's
263
+ // explicit reviewed ids, scoped to `repoIds`, then resolves each via the SAME shared helper the
264
+ // per-PR route + the standing auto-triage job use. An empty list is a no-op (not an error);
265
+ // per-thread failures are reported, not fatal. The re-derive path passes `threadIds` so the
266
+ // page cap is bypassed — no requested-and-eligible id is silently dropped.
267
+ app.post('/api/bot-threads/resolve', { schema: scopeResolveSchema }, async (req) => {
268
+ const { threadIds, repoIds } = req.body;
269
+ const accountId = accountIdOf(req);
270
+ if (threadIds.length === 0) {
271
+ const noop = { resolved: 0, failed: 0, results: [] };
272
+ return noop;
273
+ }
274
+ const { threads: eligible } = await getResolvableBotThreadsForScope(accountId, repoIds ?? null, threadIds);
275
+ const result = await resolveThreadsOnGitHub(accountId, eligible.map((t) => ({ id: t.threadId, threadNodeId: t.threadNodeId })));
276
+ req.log.info({
277
+ accountId,
278
+ requested: threadIds.length,
279
+ eligible: eligible.length,
280
+ resolved: result.resolved,
281
+ failed: result.failed,
282
+ }, 'scope-wide bot-thread resolve');
283
+ return result;
284
+ });
185
285
  }
186
286
  //# sourceMappingURL=bot-triage.js.map
@@ -1,6 +1,6 @@
1
- import { getGraphqlClientFor } from '../../github/client.js';
1
+ import { getGraphqlClientFor, graphqlTolerant } from '../../github/client.js';
2
2
  import { getAccessToken } from '../../auth/account.js';
3
- import { OWNER_TYPE_QUERY, REPO_ID_QUERY, REPO_SEARCH_QUERY, } from '../../github/queries.js';
3
+ import { OWNER_TYPE_QUERY, REPO_ID_QUERY, REPO_SEARCH_QUERY, VIEWER_REPOS_QUERY, } from '../../github/queries.js';
4
4
  import { upsertRepo } from '../../sync/upsert.js';
5
5
  import { getSyncStatus, isSyncRunning, requestSyncCancel, runSyncForRepo, waitForSyncToStop, } from '../../sync/sync-manager.js';
6
6
  import { deleteRepo, getRepo, getWatchedRepoNodeIds, listRepos, setRepoInboxWatch, } from '../../db/queries.js';
@@ -188,6 +188,77 @@ export async function repoRoutes(app) {
188
188
  };
189
189
  return body;
190
190
  });
191
+ // First-run onboarding: the viewer's recently-active repositories, detected from their
192
+ // GitHub activity (recent pushes + contributions). Same token idiom as /search; already-
193
+ // watched repos filtered out; mapped to the picker's RepoSearchResult shape and ordered
194
+ // most-recently-pushed first, capped at 30. Detail comes straight from GitHub — nothing
195
+ // persisted. Local's broad `gh` token surfaces private + org repos; a scoped cloud token
196
+ // surfaces only what it can read (both are handled by the same null-tolerant merge below).
197
+ app.get('/api/repos/suggested', async (req, reply) => {
198
+ const accountId = accountIdOf(req);
199
+ const client = getGraphqlClientFor(await getAccessToken(accountId));
200
+ let resp;
201
+ try {
202
+ // Tolerate PARTIAL GraphQL errors (a scoped cloud token forbidden one sub-field answers
203
+ // 200 + partial data) — the null-drop merge below is built for exactly that. Only a
204
+ // response with NO usable data (auth failure, rate limit, network) 502s.
205
+ resp = await graphqlTolerant(client, VIEWER_REPOS_QUERY, {}, (errors) => req.log.warn({ errors }, 'partial viewer-repos response'));
206
+ }
207
+ catch (err) {
208
+ reply.status(502);
209
+ return {
210
+ error: 'GitHubError',
211
+ message: err instanceof Error ? err.message : 'GitHub request failed',
212
+ };
213
+ }
214
+ if (resp?.viewer == null)
215
+ return { results: [] };
216
+ const me = resp.viewer.login.toLowerCase();
217
+ const orgLogins = new Set(resp.viewer.organizations.nodes
218
+ .filter((o) => o != null)
219
+ .map((o) => o.login.toLowerCase()));
220
+ // Merge the two recency-ordered lists, deduping by repo node id and keeping the more
221
+ // recent pushedAt. Null node arrays (scoped token) and null nodes are dropped BEFORE any
222
+ // field read (ISO-8601 pushedAt strings compare lexically; a null pushedAt sorts last).
223
+ const byId = new Map();
224
+ const merged = [
225
+ ...(resp.viewer.repositories.nodes ?? []),
226
+ ...(resp.viewer.repositoriesContributedTo.nodes ?? []),
227
+ ];
228
+ for (const n of merged) {
229
+ if (n == null || typeof n.id !== 'string')
230
+ continue;
231
+ const existing = byId.get(n.id);
232
+ if (existing == null || (n.pushedAt ?? '') > (existing.pushedAt ?? '')) {
233
+ byId.set(n.id, n);
234
+ }
235
+ }
236
+ const watched = await getWatchedRepoNodeIds(accountId);
237
+ const results = [...byId.values()]
238
+ .filter((n) => !watched.has(n.id))
239
+ // Most-recently-pushed first; a missing pushedAt sorts to the bottom.
240
+ .sort((a, b) => (b.pushedAt ?? '').localeCompare(a.pushedAt ?? ''))
241
+ .slice(0, 30)
242
+ .map((n) => {
243
+ const ownerLogin = n.owner.login;
244
+ return {
245
+ githubNodeId: n.id,
246
+ owner: ownerLogin,
247
+ name: n.name,
248
+ fullName: n.nameWithOwner,
249
+ description: n.description,
250
+ ownerAvatarUrl: n.owner.avatarUrl,
251
+ stargazerCount: n.stargazerCount,
252
+ openPrCount: n.pullRequests.totalCount,
253
+ url: n.url,
254
+ isPrivate: n.isPrivate,
255
+ isOwnedOrMember: ownerLogin.toLowerCase() === me ||
256
+ orgLogins.has(ownerLogin.toLowerCase()),
257
+ };
258
+ });
259
+ const body = { results };
260
+ return body;
261
+ });
191
262
  app.post('/api/repos', { schema: createRepoSchema }, async (req, reply) => {
192
263
  const { owner, name } = req.body;
193
264
  const accountId = accountIdOf(req);
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile, execFileSync } from 'node:child_process';
3
- import { mkdirSync } from 'node:fs';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
- import { dirname, join } from 'node:path';
5
+ import { dirname, join, resolve } from 'node:path';
6
6
  // ── tiny ANSI helpers (degrade gracefully when not a TTY) ──────────────────
7
7
  const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
8
8
  const paint = (code, s) => useColor ? `[${code}m${s}` : s;
@@ -66,6 +66,11 @@ function printUsage() {
66
66
  Usage:
67
67
  pierre [options]
68
68
  pierre-review [options]
69
+ pierre status [options]
70
+
71
+ Commands:
72
+ status Your cross-repo "my turn" queue in the terminal, with
73
+ clickable links (see \`pierre status --help\`)
69
74
 
70
75
  Options:
71
76
  --no-open Don't open the browser (also honours NO_OPEN env)
@@ -98,17 +103,157 @@ function openBrowser(url) {
98
103
  /* ignore */
99
104
  });
100
105
  }
106
+ // The default local SQLite location — a user-writable home dir, never the (possibly
107
+ // read-only) install dir. Shared by the server boot and `pierre status`.
108
+ function defaultLocalDbPath() {
109
+ return join(homedir(), '.pierre-review', 'pierre-review.sqlite');
110
+ }
111
+ // Fail fast with a friendly message when the GitHub CLI isn't installed / authed.
112
+ // Both the local server boot and `pierre status` need a working `gh` token.
113
+ function ghAuthPreCheck() {
114
+ try {
115
+ execFileSync('gh', ['auth', 'token'], { stdio: 'ignore' });
116
+ }
117
+ catch {
118
+ console.error('');
119
+ console.error(bold('GitHub CLI not found or not authenticated.'));
120
+ console.error('Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.');
121
+ console.error('');
122
+ process.exit(1);
123
+ }
124
+ }
125
+ function parseStatusArgs(argv) {
126
+ const opts = {
127
+ watch: false,
128
+ sync: false,
129
+ intervalSeconds: 60,
130
+ help: false,
131
+ };
132
+ for (let i = 0; i < argv.length; i++) {
133
+ const arg = argv[i];
134
+ switch (arg) {
135
+ case '--watch':
136
+ opts.watch = true;
137
+ break;
138
+ case '--sync':
139
+ opts.sync = true;
140
+ break;
141
+ case '--interval': {
142
+ const v = argv[++i];
143
+ const n = v ? Number.parseInt(v, 10) : NaN;
144
+ if (!Number.isFinite(n)) {
145
+ console.error('--interval requires a number of seconds');
146
+ process.exit(1);
147
+ }
148
+ opts.intervalSeconds = Math.max(10, n);
149
+ break;
150
+ }
151
+ case '--db': {
152
+ const v = argv[++i];
153
+ if (!v) {
154
+ console.error('--db requires a path');
155
+ process.exit(1);
156
+ }
157
+ opts.db = v;
158
+ break;
159
+ }
160
+ case '--help':
161
+ case '-h':
162
+ opts.help = true;
163
+ break;
164
+ default:
165
+ console.error(`Unknown argument: ${arg}`);
166
+ console.error('Run `pierre status --help` for usage.');
167
+ process.exit(1);
168
+ }
169
+ }
170
+ return opts;
171
+ }
172
+ function printStatusUsage() {
173
+ console.log(`pierre status — your cross-repo "my turn" queue in the terminal
174
+
175
+ Usage:
176
+ pierre status [options]
177
+
178
+ Options:
179
+ --sync Fetch fresh state from GitHub first (otherwise shows data as of
180
+ the last sync). Under --watch, re-syncs at most every 5 minutes.
181
+ --watch Repaint on an interval until you quit (Ctrl-C)
182
+ --interval <n> Seconds between repaints under --watch (default 60, min 10)
183
+ --db <path> SQLite DB path (also DATABASE_URL env)
184
+ -h, --help Show this help
185
+
186
+ Reads the local pierre database. Without --sync it shows data as of the last sync
187
+ — run it alongside \`pierre\`, or pass --sync to fetch fresh state first. Links are
188
+ clickable in terminals that support OSC-8 hyperlinks.
189
+ `);
190
+ }
191
+ async function runStatusCommand(argv) {
192
+ const opts = parseStatusArgs(argv);
193
+ if (opts.help) {
194
+ printStatusUsage();
195
+ process.exit(0);
196
+ }
197
+ // status is LOCAL-only — it reads your on-disk SQLite database directly.
198
+ if (process.env.DEPLOYMENT_MODE === 'cloud') {
199
+ console.error('`pierre status` is local-only — it reads your local SQLite database.');
200
+ process.exit(1);
201
+ }
202
+ // Pin local BEFORE the dynamic import: config.ts loads .env files on import, and
203
+ // process.loadEnvFile never overrides an already-set var — so an .env-declared
204
+ // DEPLOYMENT_MODE=cloud can't flip status onto the Postgres driver after the guard.
205
+ process.env.DEPLOYMENT_MODE = 'local';
206
+ // ── map flags → env BEFORE any dynamic import (config/db snapshot env on load) ──
207
+ // resolve() the --db flag against the CWD (what the user means by a relative path);
208
+ // config.ts resolves relative paths against the INSTALL dir, so an unresolved value
209
+ // would make this guard check one file while the DB opens another.
210
+ process.env.NODE_ENV ??= 'production';
211
+ if (opts.db !== undefined)
212
+ process.env.DATABASE_URL = resolve(opts.db);
213
+ if (!process.env.DATABASE_URL)
214
+ process.env.DATABASE_URL = defaultLocalDbPath();
215
+ try {
216
+ mkdirSync(dirname(process.env.DATABASE_URL), { recursive: true });
217
+ }
218
+ catch {
219
+ /* best-effort; client.ts also mkdirs */
220
+ }
221
+ // Friendlier than the server path: don't silently create an empty DB just to show
222
+ // an empty queue. --sync legitimately bootstraps a fresh DB, so skip the guard then.
223
+ if (!opts.sync && !existsSync(process.env.DATABASE_URL)) {
224
+ console.error(`No pierre database found at ${process.env.DATABASE_URL} — run \`pierre\` first to sync, or pass --db.`);
225
+ process.exit(1);
226
+ }
227
+ // ensureLocalAccount (`gh api user`) and --sync both need a working gh token.
228
+ ghAuthPreCheck();
229
+ const { runStatus } = await import('./status.js');
230
+ await runStatus({
231
+ watch: opts.watch,
232
+ sync: opts.sync,
233
+ intervalSeconds: opts.intervalSeconds,
234
+ });
235
+ }
101
236
  async function main() {
102
- const opts = parseArgs(process.argv.slice(2));
237
+ const argv = process.argv.slice(2);
238
+ // `pierre status` — a read-only subcommand with its own parser, usage, and env
239
+ // mapping. Peeled off BEFORE parseArgs (whose default case rejects any bare token),
240
+ // and it never reaches the server boot below.
241
+ if (argv[0] === 'status') {
242
+ await runStatusCommand(argv.slice(1));
243
+ return;
244
+ }
245
+ const opts = parseArgs(argv);
103
246
  if (opts.help) {
104
247
  printUsage();
105
248
  process.exit(0);
106
249
  }
107
250
  // ── map flags → env BEFORE importing config/app (config reads these) ──────
251
+ // --db is resolved against the CWD (config.ts resolves relative paths against the
252
+ // install dir, which is never what a user typing a relative path means).
108
253
  if (opts.port !== undefined)
109
254
  process.env.PORT = String(opts.port);
110
255
  if (opts.db !== undefined)
111
- process.env.DATABASE_URL = opts.db;
256
+ process.env.DATABASE_URL = resolve(opts.db);
112
257
  if (opts.open === false)
113
258
  process.env.NO_OPEN = '1';
114
259
  if (opts.cloud)
@@ -120,8 +265,7 @@ async function main() {
120
265
  // ── local: default the DB to a user-writable home location (mkdir -p) ────
121
266
  // The package dir is read-only for global installs, so never write there.
122
267
  if (!process.env.DATABASE_URL) {
123
- const dbPath = join(homedir(), '.pierre-review', 'pierre-review.sqlite');
124
- process.env.DATABASE_URL = dbPath;
268
+ process.env.DATABASE_URL = defaultLocalDbPath();
125
269
  }
126
270
  try {
127
271
  mkdirSync(dirname(process.env.DATABASE_URL), { recursive: true });
@@ -130,16 +274,7 @@ async function main() {
130
274
  /* best-effort; client.ts also mkdirs */
131
275
  }
132
276
  // ── pre-check gh auth with a friendly message (contract §6) ─────────────
133
- try {
134
- execFileSync('gh', ['auth', 'token'], { stdio: 'ignore' });
135
- }
136
- catch {
137
- console.error('');
138
- console.error(bold('GitHub CLI not found or not authenticated.'));
139
- console.error('Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.');
140
- console.error('');
141
- process.exit(1);
142
- }
277
+ ghAuthPreCheck();
143
278
  }
144
279
  // Cloud mode: no gh pre-check (each account brings its own OAuth token) and no
145
280
  // SQLite home default (DATABASE_URL must be a Postgres URL). config.ts /
@@ -0,0 +1,9 @@
1
+ -- Part A: deterministic "was this thread addressed?" upgrade. Store a graded addressed-confidence
2
+ -- + a machine reason tag + who resolved the thread, alongside the existing derivedState. All
3
+ -- additive (defaulted / nullable) so the backfill is a no-op — the next sync recomputes them.
4
+ -- The Postgres baseline is regenerated separately via `pnpm db:generate:pg`.
5
+ ALTER TABLE `review_threads` ADD `addressed_confidence` text DEFAULT 'none' NOT NULL;
6
+ --> statement-breakpoint
7
+ ALTER TABLE `review_threads` ADD `addressed_reason` text;
8
+ --> statement-breakpoint
9
+ ALTER TABLE `review_threads` ADD `resolved_by_login` text;
@@ -232,6 +232,13 @@
232
232
  "when": 1783820880005,
233
233
  "tag": "0032_benchmark_contributions",
234
234
  "breakpoints": true
235
+ },
236
+ {
237
+ "idx": 33,
238
+ "version": "6",
239
+ "when": 1784200000000,
240
+ "tag": "0033_thread_addressed_confidence",
241
+ "breakpoints": true
235
242
  }
236
243
  ]
237
244
  }
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "review_threads" ADD COLUMN "addressed_confidence" text DEFAULT 'none' NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "review_threads" ADD COLUMN "addressed_reason" text;--> statement-breakpoint
3
+ ALTER TABLE "review_threads" ADD COLUMN "resolved_by_login" text;