pierre-review 0.1.64 → 0.1.65

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.
@@ -2,12 +2,42 @@ import { randomBytes } from 'node:crypto';
2
2
  import { config } from '../../config.js';
3
3
  import { encryptToken } from '../../auth/crypto.js';
4
4
  import { upsertCloudAccount } from '../../auth/account.js';
5
+ // Per-provider credential + the scope to request (empty for the GitHub App).
6
+ function providerCredentials(provider) {
7
+ return provider === 'app'
8
+ ? {
9
+ clientId: config.githubAppClientId,
10
+ clientSecret: config.githubAppClientSecret,
11
+ scope: '',
12
+ enabled: config.appProviderEnabled,
13
+ }
14
+ : {
15
+ clientId: config.githubOAuthClientId,
16
+ clientSecret: config.githubOAuthClientSecret,
17
+ scope: config.oauthScope,
18
+ enabled: config.oauthProviderEnabled,
19
+ };
20
+ }
5
21
  export async function authRoutes(app) {
6
22
  const redirectUri = `${config.appBaseUrl}/api/auth/callback`;
7
23
  const secureCookie = config.appBaseUrl.startsWith('https://');
8
- // 302 GitHub authorize, with a CSRF state in a short-lived signed cookie.
9
- app.get('/api/auth/login', async (_req, reply) => {
10
- const state = randomBytes(16).toString('hex');
24
+ // The default provider for the bare /api/auth/login link (landing/billing CTAs): prefer the
25
+ // frictionless OAuth App; fall back to the GitHub App if that's the only one configured.
26
+ const defaultProvider = config.oauthProviderEnabled ? 'oauth' : 'app';
27
+ // Which sign-in methods this deployment offers — read by the (signed-out) SignInGate to render
28
+ // the right button(s). Unauthenticated: it sits under the /api/auth/* auth-gate exemption.
29
+ app.get('/api/auth/providers', async () => ({
30
+ oauth: config.oauthProviderEnabled,
31
+ app: config.appProviderEnabled,
32
+ // Slug drives the private-repo install link on the gate; only meaningful with the App.
33
+ appSlug: config.appProviderEnabled ? config.githubAppSlug : '',
34
+ }));
35
+ const startLogin = (provider, reply) => {
36
+ const cred = providerCredentials(provider);
37
+ if (!cred.enabled)
38
+ return reply.redirect('/app?auth=unavailable');
39
+ // Fold the provider into the state so the callback knows which credential to exchange with.
40
+ const state = `${provider}.${randomBytes(16).toString('hex')}`;
11
41
  reply.setCookie('pierre_oauth_state', state, {
12
42
  path: '/',
13
43
  httpOnly: true,
@@ -17,20 +47,28 @@ export async function authRoutes(app) {
17
47
  maxAge: 600,
18
48
  });
19
49
  const params = new URLSearchParams({
20
- client_id: config.githubAppClientId,
50
+ client_id: cred.clientId,
21
51
  redirect_uri: redirectUri,
22
52
  state,
23
53
  });
54
+ if (cred.scope)
55
+ params.set('scope', cred.scope);
24
56
  return reply.redirect(`https://github.com/login/oauth/authorize?${params.toString()}`);
57
+ };
58
+ // Explicit provider choice (the SignInGate's two buttons).
59
+ app.get('/api/auth/login/:provider', async (req, reply) => {
60
+ const provider = req.params.provider === 'app' ? 'app' : 'oauth';
61
+ return startLogin(provider, reply);
25
62
  });
26
- // Verify state exchange code for a token fetch the user → upsert account →
27
- // set session 302 to the app.
63
+ // Back-compat bare login (landing/billing CTAs) → the default provider.
64
+ app.get('/api/auth/login', async (_req, reply) => startLogin(defaultProvider, reply));
65
+ // Verify state → exchange code for a token (against the state's provider) → fetch the user →
66
+ // upsert account → set session → 302 to the app.
28
67
  app.get('/api/auth/callback', async (req, reply) => {
29
- // Already signed in? The callback URL carries a one-time ?code= that GitHub
30
- // expires in ~10 min and invalidates on first use. A back-button, a restored
31
- // tab, or a Chrome profile switch can re-request this exact URL; re-running the
32
- // exchange would then fail on the already-consumed code ("code incorrect or
33
- // expired"). Short-circuit a valid session straight to the app instead.
68
+ // Already signed in? The callback URL carries a one-time ?code= that GitHub expires in
69
+ // ~10 min and invalidates on first use. A back-button, a restored tab, or a Chrome profile
70
+ // switch can re-request this exact URL; re-running the exchange would then fail on the
71
+ // already-consumed code. Short-circuit a valid session straight to the app instead.
34
72
  if (req.session.get('accountId') != null) {
35
73
  return reply.redirect('/app');
36
74
  }
@@ -41,10 +79,13 @@ export async function authRoutes(app) {
41
79
  : { valid: false, value: null };
42
80
  reply.clearCookie('pierre_oauth_state', { path: '/' });
43
81
  if (!code || !state || !unsigned.valid || unsigned.value !== state) {
44
- // Stale/replayed callback (state cookie expired or CSRF mismatch). Bounce
45
- // back so the user starts a fresh sign-in — never render raw JSON to a human.
82
+ // Stale/replayed callback (state cookie expired or CSRF mismatch). Bounce back so the
83
+ // user starts a fresh sign-in — never render raw JSON to a human.
46
84
  return reply.redirect('/app?auth=expired');
47
85
  }
86
+ // The provider chosen at /login is encoded in the state; pick the matching credential.
87
+ const provider = state.startsWith('app.') ? 'app' : 'oauth';
88
+ const cred = providerCredentials(provider);
48
89
  // Exchange the code for a user access token.
49
90
  let token;
50
91
  try {
@@ -52,21 +93,21 @@ export async function authRoutes(app) {
52
93
  method: 'POST',
53
94
  headers: { accept: 'application/json', 'content-type': 'application/json' },
54
95
  body: JSON.stringify({
55
- client_id: config.githubAppClientId,
56
- client_secret: config.githubAppClientSecret,
96
+ client_id: cred.clientId,
97
+ client_secret: cred.clientSecret,
57
98
  code,
58
99
  redirect_uri: redirectUri,
59
100
  }),
60
101
  });
61
102
  const json = (await res.json());
62
103
  if (!json.access_token) {
63
- req.log.warn({ err: json.error_description ?? json.error }, 'oauth token exchange returned no token');
104
+ req.log.warn({ err: json.error_description ?? json.error, provider }, 'oauth token exchange returned no token');
64
105
  return reply.redirect('/app?auth=failed');
65
106
  }
66
107
  token = json.access_token;
67
108
  }
68
109
  catch (err) {
69
- req.log.error({ err }, 'oauth token exchange request failed');
110
+ req.log.error({ err, provider }, 'oauth token exchange request failed');
70
111
  return reply.redirect('/app?auth=error');
71
112
  }
72
113
  // Identify the user.
package/dist/cli.js CHANGED
@@ -82,8 +82,10 @@ Prerequisite (local mode):
82
82
  \`gh auth login\`. The dashboard reads your activity using your gh token.
83
83
 
84
84
  Prerequisite (--cloud):
85
- Postgres + a GitHub App. See docs/LOCAL-CLOUD-TESTING.md. Provide
86
- DATABASE_URL, APP_BASE_URL, GITHUB_APP_*, SESSION_SECRET, ENCRYPTION_KEY.
85
+ Postgres + a GitHub sign-in method (an OAuth App and/or a GitHub App). See
86
+ docs/GITHUB-AUTH-SETUP.md. Provide DATABASE_URL, APP_BASE_URL, SESSION_SECRET,
87
+ ENCRYPTION_KEY, and at least one of GITHUB_OAUTH_CLIENT_ID+SECRET or
88
+ GITHUB_APP_CLIENT_ID+SECRET.
87
89
  `);
88
90
  }
89
91
  // Cross-platform browser open — no extra dependency.
package/dist/config.js CHANGED
@@ -43,7 +43,7 @@ function effortFromEnv(key, fallback) {
43
43
  // ---- Deployment mode (the master switch) ----
44
44
  // `local` (default): SQLite via better-sqlite3, `gh auth token` auth, one
45
45
  // implicit account, no landing page — the unchanged zero-config experience.
46
- // `cloud`: Postgres, GitHub App OAuth, many accounts, public landing page.
46
+ // `cloud`: Postgres, GitHub sign-in (OAuth App and/or GitHub App), many accounts, landing page.
47
47
  // Everything dialect/tenancy-specific branches off `deploymentMode`.
48
48
  const deploymentMode = process.env.DEPLOYMENT_MODE === 'cloud' ? 'cloud' : 'local';
49
49
  const isCloud = deploymentMode === 'cloud';
@@ -111,14 +111,34 @@ export const config = {
111
111
  retentionCron: process.env.RETENTION_CRON ?? '0 3 * * *',
112
112
  // Disable the periodic scheduler (used by scripts/tests).
113
113
  disableScheduler: process.env.DISABLE_SCHEDULER === 'true',
114
- // ---- Cloud (Railway) — GitHub App OAuth + sessions + token encryption ----
114
+ // ---- Cloud (Railway) — GitHub sign-in + sessions + token encryption ----
115
115
  // Public base URL of the deployment (no trailing slash); used to build the
116
116
  // OAuth redirect_uri and absolute links. Locally http://localhost:<port>.
117
117
  appBaseUrl: process.env.APP_BASE_URL?.replace(/\/$/, '') ??
118
118
  `http://localhost:${intFromEnv('PORT', 4000)}`,
119
+ // Cloud supports TWO sign-in providers side by side; a deployment configures either or both
120
+ // and the SignInGate offers whatever's set. They are SEPARATE GitHub registrations (distinct
121
+ // client id/secret) but share the same authorize/token endpoints — the flow only differs in
122
+ // which credential it uses and whether it requests `oauthScope` (see api/routes/auth.ts).
123
+ //
124
+ // • OAuth App — user-scoped token, NO installation. `public_repo` reads public repos incl.
125
+ // CI checks/statuses (what an unscoped token can't) + enables the interactive PR actions.
126
+ // • GitHub App — user-to-server token; for PRIVATE repos the App must be INSTALLED where they
127
+ // live (public repos work without install, but reading CI Checks/Actions needs install).
128
+ githubOAuthClientId: process.env.GITHUB_OAUTH_CLIENT_ID ?? '',
129
+ githubOAuthClientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET ?? '',
130
+ // Scopes requested by the OAuth App at authorize time. Default = public repos only (widen to
131
+ // `repo read:org` for private-repo access via OAuth).
132
+ oauthScope: process.env.GITHUB_OAUTH_SCOPE ?? 'public_repo read:org',
119
133
  githubAppClientId: process.env.GITHUB_APP_CLIENT_ID ?? '',
120
134
  githubAppClientSecret: process.env.GITHUB_APP_CLIENT_SECRET ?? '',
135
+ // The GitHub App's URL slug — builds the private-repo install link
136
+ // (github.com/apps/<slug>/installations/new), shown on the sign-in gate.
121
137
  githubAppSlug: process.env.GITHUB_APP_SLUG ?? '',
138
+ // Derived: is each provider fully configured? (Both are optional; assertCloudConfig requires
139
+ // at least one.) The auth routes + SignInGate gate on these.
140
+ oauthProviderEnabled: !!(process.env.GITHUB_OAUTH_CLIENT_ID && process.env.GITHUB_OAUTH_CLIENT_SECRET),
141
+ appProviderEnabled: !!(process.env.GITHUB_APP_CLIENT_ID && process.env.GITHUB_APP_CLIENT_SECRET),
122
142
  // Seals the session cookie ({accountId}). Rotate to invalidate all sessions.
123
143
  sessionSecret: process.env.SESSION_SECRET ?? '',
124
144
  // 32-byte (64-hex) key for AES-256-GCM encryption of stored access tokens.
@@ -227,12 +247,17 @@ export function assertCloudConfig() {
227
247
  const required = [
228
248
  ['DATABASE_URL', config.databaseUrl],
229
249
  ['APP_BASE_URL', process.env.APP_BASE_URL ?? ''],
230
- ['GITHUB_APP_CLIENT_ID', config.githubAppClientId],
231
- ['GITHUB_APP_CLIENT_SECRET', config.githubAppClientSecret],
232
- ['GITHUB_APP_SLUG', config.githubAppSlug],
233
250
  ['SESSION_SECRET', config.sessionSecret],
234
251
  ['ENCRYPTION_KEY', config.encryptionKey],
235
252
  ];
253
+ // At least ONE sign-in provider must be fully configured; a deployment may enable either or
254
+ // both. A GitHub App with no OAuth App (or vice-versa) is fine.
255
+ if (!config.oauthProviderEnabled && !config.appProviderEnabled) {
256
+ throw new Error('Cloud mode needs at least one GitHub sign-in method configured: set ' +
257
+ 'GITHUB_OAUTH_CLIENT_ID + GITHUB_OAUTH_CLIENT_SECRET (OAuth App, public repos) and/or ' +
258
+ 'GITHUB_APP_CLIENT_ID + GITHUB_APP_CLIENT_SECRET (GitHub App, adds private org repos via ' +
259
+ 'install). See docs/GITHUB-AUTH-SETUP.md.');
260
+ }
236
261
  const missing = required.filter(([, v]) => !v).map(([k]) => k);
237
262
  if (missing.length > 0) {
238
263
  throw new Error(`Cloud mode (DEPLOYMENT_MODE=cloud) requires these env vars: ${missing.join(', ')}. See .env.cloud.example and docs/DEPLOY-RAILWAY.md.`);
@@ -1,5 +1,56 @@
1
- import { graphql } from '@octokit/graphql';
1
+ import { graphql, GraphqlResponseError } from '@octokit/graphql';
2
2
  import { getGithubToken } from './auth.js';
3
+ // A parenthetical hint appended to a partial-GraphQL log, ONLY when the forbidden field is
4
+ // actually a CI-checks field — so a NOT_FOUND (deleted/inaccessible PR) isn't mislabelled as a
5
+ // checks-access problem. Empty otherwise (the raw errors still get logged). Cloud sign-in is an
6
+ // OAuth App (needs the `public_repo` scope for checks) and/or a GitHub App (needs installing on
7
+ // the repo's org with "Checks" read); either way public-repo checks read once that's in place.
8
+ export function graphqlChecksHint(errors) {
9
+ const mentionsChecks = Array.isArray(errors) &&
10
+ errors.some((e) => {
11
+ const path = e.path;
12
+ const inPath = Array.isArray(path) && path.some((p) => /statuscheckrollup|check/i.test(String(p)));
13
+ const inMsg = /check\b|statuscheckrollup/i.test(e.message ?? '');
14
+ return inPath || inMsg;
15
+ });
16
+ return mentionsChecks
17
+ ? " — the sign-in token can't read CI checks here (GitHub App: install on this org with Checks read; OAuth App: re-authorize; or it's a private repo)"
18
+ : '';
19
+ }
20
+ // Compact one-line summary of a GraphQL `errors` array for logging.
21
+ export function summarizeGraphqlErrors(errors) {
22
+ if (!Array.isArray(errors))
23
+ return String(errors);
24
+ return errors
25
+ .slice(0, 5)
26
+ .map((e) => {
27
+ const type = e.type ?? 'ERROR';
28
+ const path = e.path?.join('.') ?? '?';
29
+ const message = e.message ?? '';
30
+ return `${type}@${path}: ${message}`;
31
+ })
32
+ .join(' | ');
33
+ }
34
+ // Run a GraphQL query, TOLERATING GitHub's *partial* errors. GitHub answers HTTP 200 with a
35
+ // non-empty `errors` array AND partial `data` when the token may read most of a query but is
36
+ // FORBIDDEN one sub-field — e.g. CI check runs (`statusCheckRollup.contexts`) on a private repo
37
+ // the token can't reach, or on a token minted before it was granted the scope to read them. By
38
+ // default `@octokit/graphql` THROWS on any such response, throwing away the usable partial data
39
+ // — so one un-permitted field would wipe the PR body/comments/reviewers. This returns the
40
+ // partial data instead (the forbidden fields arrive as null) and reports the errors via
41
+ // `onPartial`. A response with NO usable data (auth failure, rate limit, network) still throws.
42
+ export async function graphqlTolerant(client, query, variables, onPartial) {
43
+ try {
44
+ return await client(query, variables);
45
+ }
46
+ catch (err) {
47
+ if (err instanceof GraphqlResponseError && err.data != null) {
48
+ onPartial?.(err.errors);
49
+ return err.data;
50
+ }
51
+ throw err;
52
+ }
53
+ }
3
54
  // Per-account GitHub clients. There is deliberately NO module-level token /
4
55
  // client cache: in a multi-tenant (cloud) process a cached token would leak one
5
56
  // account's credentials into another account's request. The token is ALWAYS
@@ -46,10 +97,12 @@ export async function ghRestGetText(token, path) {
46
97
  const text = await res.text().catch(() => '');
47
98
  return { status: res.status, ok: res.ok, text };
48
99
  }
49
- // REST GET returning a resource in GitHub's raw `diff` media type (Accept:
50
- // application/vnd.github.diff) — the per-account, cloud-ready way to fetch a PR's
51
- // unified diff (vs the local-only `gh pr diff`). Throws on a non-2xx status.
52
- export async function ghRestGetDiff(token, path) {
100
+ // REST GET in GitHub's raw `diff` media type (Accept: application/vnd.github.diff),
101
+ // NOT throwing on a non-2xx returns the status + body so the caller can branch. GitHub
102
+ // caps this media type at 20,000 lines and 406s past it (a huge PR), which the caller
103
+ // handles by falling back to the per-file endpoint. Mirrors ghRestGetText's non-throwing
104
+ // contract.
105
+ export async function ghRestGetDiffStatus(token, path) {
53
106
  const res = await fetch(`https://api.github.com${path}`, {
54
107
  method: 'GET',
55
108
  headers: {
@@ -58,11 +111,17 @@ export async function ghRestGetDiff(token, path) {
58
111
  'x-github-api-version': '2022-11-28',
59
112
  },
60
113
  });
114
+ const text = await res.text().catch(() => '');
115
+ return { status: res.status, ok: res.ok, text };
116
+ }
117
+ // Throwing variant of the above — the per-account, cloud-ready way to fetch a PR's
118
+ // unified diff (vs the local-only `gh pr diff`). Throws on a non-2xx status.
119
+ export async function ghRestGetDiff(token, path) {
120
+ const res = await ghRestGetDiffStatus(token, path);
61
121
  if (!res.ok) {
62
- const text = await res.text().catch(() => '');
63
- throw new Error(`GitHub REST GET(diff) ${path} -> ${res.status}: ${text.slice(0, 300)}`);
122
+ throw new Error(`GitHub REST GET(diff) ${path} -> ${res.status}: ${res.text.slice(0, 300)}`);
64
123
  }
65
- return res.text();
124
+ return res.text;
66
125
  }
67
126
  // REST PUT returning the parsed STATUS + body WITHOUT throwing on a non-2xx, so the caller can
68
127
  // map GitHub's meaningful merge / update-branch statuses to structured results (405 not
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // GraphQL note: @octokit/graphql RESERVES the variable name `query`. The
8
8
  // operation-variable here is named `input` / etc., never `query`.
9
- import { getGraphqlClientFor, ghRestGetDiff, ghRestGetFor, ghRestPostFor, ghRestPostNoContent, ghRestPutStatus, } from './client.js';
9
+ import { getGraphqlClientFor, ghRestGetDiffStatus, ghRestGetFor, ghRestPostFor, ghRestPostNoContent, ghRestPutStatus, } from './client.js';
10
10
  const ADD_REPLY_MUTATION = /* GraphQL */ `
11
11
  mutation AddThreadReply($input: AddPullRequestReviewThreadReplyInput!) {
12
12
  addPullRequestReviewThreadReply(input: $input) {
@@ -278,9 +278,49 @@ export async function updatePullRequestBranch(token, owner, name, number, expect
278
278
  return { ok: false, reason, message };
279
279
  }
280
280
  // The PR's unified diff via the REST API (Accept: application/vnd.github.diff) —
281
- // cloud-ready, per-account. Bounded implicitly by GitHub's own diff-size limit.
281
+ // cloud-ready, per-account. GitHub caps this media type at 20,000 lines and returns 406
282
+ // past it (a large PR — e.g. bulk data/generated files). In that case we fall back to the
283
+ // per-file endpoint, which has no total-lines cap, and synthesise a unified diff from each
284
+ // file's `patch`. Without this fallback the diff came back empty and the AI summary / CI
285
+ // analysis / fixer would (correctly, but uselessly) report "the diff is empty".
282
286
  export async function fetchPrUnifiedDiff(token, owner, name, number) {
283
- return ghRestGetDiff(token, `/repos/${owner}/${name}/pulls/${number}`);
287
+ const path = `/repos/${owner}/${name}/pulls/${number}`;
288
+ const res = await ghRestGetDiffStatus(token, path);
289
+ if (res.ok)
290
+ return res.text;
291
+ if (res.status === 406) {
292
+ return reconstructUnifiedDiffFromFiles(token, owner, name, number);
293
+ }
294
+ throw new Error(`GitHub REST GET(diff) ${path} -> ${res.status}: ${res.text.slice(0, 300)}`);
295
+ }
296
+ // Synthesise a unified diff from GitHub's per-file `patch`es — the fallback when the
297
+ // whole-PR .diff 406s (too large). The /files endpoint has no total-lines cap (up to 3000
298
+ // files); a file's `patch` is omitted when it's binary or a single file's own diff is too
299
+ // large, so we name those with their churn/status. The synthetic `diff --git a/… b/…`
300
+ // headers keep the result splittable by capDiff's per-file budgeter (so a summary still
301
+ // stays within the prompt's char budget, grounded in the files that DO have patches).
302
+ export function filesToUnifiedDiff(files) {
303
+ const parts = [];
304
+ for (const f of files) {
305
+ const aPath = f.previous_filename ?? f.filename;
306
+ parts.push(`diff --git a/${aPath} b/${f.filename}`);
307
+ if (f.patch) {
308
+ parts.push(`--- a/${aPath}`, `+++ b/${f.filename}`, f.patch);
309
+ }
310
+ else {
311
+ // GitHub omits `patch` for binary files, a single file whose own diff is too large,
312
+ // and some no-content changes (pure rename / mode-only). Lead with the authoritative
313
+ // status + churn (the real signal) and hedge the reason so a rename isn't mislabelled.
314
+ parts.push(`(diff not shown — status=${f.status}, +${f.additions}/-${f.deletions}; likely binary or too large)`);
315
+ }
316
+ }
317
+ return parts.join('\n');
318
+ }
319
+ async function reconstructUnifiedDiffFromFiles(token, owner, name, number) {
320
+ // 300 files is plenty for a grounded summary and bounds the paging to ~3 requests; the
321
+ // downstream capDiff trims further to the prompt's char budget.
322
+ const { files } = await fetchPrFilesWithPatch(token, owner, name, number, 300);
323
+ return filesToUnifiedDiff(files);
284
324
  }
285
325
  // Open a pull request (per-account). `head` is the branch to merge FROM (in the base
286
326
  // repo — the fixer only ever creates branches in the base repo, so no `owner:branch`
@@ -5,6 +5,7 @@ export const EMPTY_CAPABILITIES = {
5
5
  activityDigest: false,
6
6
  reviewMemory: false,
7
7
  aiAnalysis: false,
8
+ prSummary: false,
8
9
  aiFix: false,
9
10
  teamInsights: false,
10
11
  claudeReview: false,
@@ -17,7 +17,7 @@ import { eq } from 'drizzle-orm';
17
17
  import { db, schema } from '../db/client.js';
18
18
  import { config } from '../config.js';
19
19
  import { getAccessToken } from '../auth/account.js';
20
- import { getGraphqlClientFor } from '../github/client.js';
20
+ import { getGraphqlClientFor, graphqlChecksHint, graphqlTolerant, summarizeGraphqlErrors, } from '../github/client.js';
21
21
  import { PR_DETAIL_QUERY } from '../github/queries.js';
22
22
  import { checkRunsFrom } from './upsert.js';
23
23
  const { reviews, reviewComments, prComments, reviewThreads, pullRequests, repos } = schema;
@@ -39,9 +39,14 @@ async function fetchGhPrText(owner, name, number, accountId) {
39
39
  try {
40
40
  const token = await getAccessToken(accountId);
41
41
  const client = getGraphqlClientFor(token);
42
- resp = await client(PR_DETAIL_QUERY, { owner, name, number });
42
+ // Tolerate partial errors: if the token is FORBIDDEN one sub-field (e.g. `statusCheckRollup`
43
+ // check runs on a private repo it can't reach), that used to throw away the ENTIRE hydration
44
+ // (PR body, comments, diff hunks, commit messages) even though all of those came back fine.
45
+ // Now we keep them; only the forbidden field (CI checks) stays empty (and we log why).
46
+ resp = await graphqlTolerant(client, PR_DETAIL_QUERY, { owner, name, number }, (errors) => console.warn(`[hydrate] partial GraphQL for ${owner}/${name}#${number} — continuing with available fields${graphqlChecksHint(errors)}. ${summarizeGraphqlErrors(errors)}`));
43
47
  }
44
- catch {
48
+ catch (err) {
49
+ console.error(`[hydrate] PR detail fetch failed for ${owner}/${name}#${number}; returning stored metadata. ${err instanceof Error ? err.message : String(err)}`);
45
50
  return null;
46
51
  }
47
52
  const pr = resp.repository?.pullRequest;
@@ -1,7 +1,7 @@
1
1
  import { performance } from 'node:perf_hooks';
2
2
  import { eq } from 'drizzle-orm';
3
3
  import { db, schema } from '../db/client.js';
4
- import { getGraphqlClientFor } from '../github/client.js';
4
+ import { getGraphqlClientFor, graphqlChecksHint, graphqlTolerant, summarizeGraphqlErrors, } from '../github/client.js';
5
5
  import { REPO_ACTIVITY_QUERY } from '../github/queries.js';
6
6
  import { ensureCommitFiles } from './commit-files.js';
7
7
  import { createUserResolver, persistPr, upsertRepo } from './upsert.js';
@@ -62,15 +62,19 @@ export async function syncRepo(opts) {
62
62
  }
63
63
  pageStartCursor = cursor;
64
64
  const tPage = performance.now();
65
- const resp = await client(REPO_ACTIVITY_QUERY, {
66
- owner,
67
- name,
68
- cursor,
69
- });
65
+ // Tolerate partial errors so a forbidden sub-field (e.g. `statusCheckRollup` check runs on
66
+ // a private repo the token can't reach, or a token minted before its scope covered checks)
67
+ // doesn't abort the whole sync — the PRs, reviews, comments and review REQUESTS still
68
+ // persist; only CI check detail is dropped (the `ciStatus` rollup, when readable, is kept).
69
+ const resp = await graphqlTolerant(client, REPO_ACTIVITY_QUERY, { owner, name, cursor }, (errors) => log.warn(`sync ${owner}/${name}: partial GraphQL — continuing without forbidden fields${graphqlChecksHint(errors)}. ${summarizeGraphqlErrors(errors)}`));
70
70
  graphqlMs += performance.now() - tPage;
71
71
  pages += 1;
72
- totalCost += resp.rateLimit.cost;
73
- lastRemaining = resp.rateLimit.remaining;
72
+ // `rateLimit` is a top-level sibling of `repository`, so it survives a partial
73
+ // (forbidden-subfield) response — but guard it so a genuinely rateLimit-less partial
74
+ // (e.g. a NOT_FOUND payload salvaged by graphqlTolerant) can't NPE before the
75
+ // `!resp.repository` 404 below gets to fire.
76
+ totalCost += resp.rateLimit?.cost ?? 0;
77
+ lastRemaining = resp.rateLimit?.remaining ?? lastRemaining;
74
78
  if (!resp.repository) {
75
79
  const err = new Error(`Repository ${owner}/${name} not found or inaccessible`);
76
80
  err.statusCode = 404;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "description": "Dashboard for tracking your team's GitHub PR activity across repos — local (SQLite + gh) or self-hosted multi-tenant cloud (Postgres + GitHub App).",
5
5
  "type": "module",
6
6
  "author": "Alex Wakeman",