pierre-review 0.1.23 → 0.1.25

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.
@@ -1,4 +1,4 @@
1
- import { getInsights } from '../../db/queries.js';
1
+ import { getInsights, getRepoAnalytics } from '../../db/queries.js';
2
2
  import { accountIdOf } from '../plugins/auth.js';
3
3
  function parseIntList(raw) {
4
4
  if (!raw)
@@ -19,5 +19,17 @@ export async function insightsRoutes(app) {
19
19
  repoIds: parseIntList(q.repoIds),
20
20
  });
21
21
  });
22
+ // Heavier per-repo analytics for the drill-down chart panel — loaded on demand.
23
+ // Ownership-scoped: a repo not owned by the account 404s.
24
+ app.get('/api/insights/:repoId/analytics', async (req, reply) => {
25
+ const { repoId } = req.params;
26
+ const id = Number.parseInt(repoId, 10);
27
+ const data = Number.isFinite(id)
28
+ ? await getRepoAnalytics(accountIdOf(req), id)
29
+ : null;
30
+ if (!data)
31
+ return reply.code(404).send({ error: 'repo not found' });
32
+ return data;
33
+ });
22
34
  }
23
35
  //# sourceMappingURL=insights.js.map
@@ -8,7 +8,10 @@ const dismissSchema = {
8
8
  required: ['kind', 'refId'],
9
9
  additionalProperties: false,
10
10
  properties: {
11
- kind: { type: 'string', enum: ['review_request', 'thread'] },
11
+ kind: {
12
+ type: 'string',
13
+ enum: ['review_request', 'thread', 'watched_repo_pr', 'claude_review'],
14
+ },
12
15
  refId: { type: 'integer' },
13
16
  },
14
17
  },
@@ -23,6 +26,7 @@ export async function meRoutes(app) {
23
26
  awaitingReview: myTurn.awaitingReview.length,
24
27
  yourPrsActivity: myTurn.yourPrs.length,
25
28
  threadsAwaiting: myTurn.threadsAwaiting.length,
29
+ watchedRepoPrs: myTurn.watchedRepoPrs.length,
26
30
  claudeReviewsToAction: myTurn.claudeReviewsToAction.length,
27
31
  },
28
32
  claudeReviewEnabled: config.claudeReviewEnabled,
@@ -35,7 +39,8 @@ export async function meRoutes(app) {
35
39
  await dismissMyTurn(accountIdOf(req), kind, refId);
36
40
  return { status: 'ok' };
37
41
  });
38
- // The "Done" tab: entries dismissed in the past 90 days (review_request + thread).
42
+ // The "Done" tab: entries dismissed in the past 90 days (review_request + thread
43
+ // + claude_review).
39
44
  app.get('/api/my-turn/done', async (req) => getCompletedDismissals(accountIdOf(req), 90));
40
45
  // Un-dismiss: move a completed entry back to the inbox.
41
46
  app.post('/api/my-turn/undismiss', { schema: dismissSchema }, async (req) => {
@@ -1,9 +1,9 @@
1
1
  import { getGraphqlClientFor } from '../../github/client.js';
2
2
  import { getAccessToken } from '../../auth/account.js';
3
- import { REPO_ID_QUERY, REPO_SEARCH_QUERY, } from '../../github/queries.js';
3
+ import { OWNER_TYPE_QUERY, REPO_ID_QUERY, REPO_SEARCH_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
- import { deleteRepo, getRepo, getWatchedRepoNodeIds, listRepos, } from '../../db/queries.js';
6
+ import { deleteRepo, getRepo, getWatchedRepoNodeIds, listRepos, setRepoInboxWatch, } from '../../db/queries.js';
7
7
  import { accountIdOf } from '../plugins/auth.js';
8
8
  // Local copy of the shared MAX_REPOS_PER_ACCOUNT value. `@pierre-review/shared` is
9
9
  // a types-only package (not shipped in the published tarball), so the backend must
@@ -17,6 +17,9 @@ const createRepoSchema = {
17
17
  properties: {
18
18
  owner: { type: 'string', minLength: 1 },
19
19
  name: { type: 'string', minLength: 1 },
20
+ // When true, also Watch the repo for the inbox on add (the picker passes
21
+ // true for "yours" repos). Optional; defaults to not-watched.
22
+ watch: { type: 'boolean' },
20
23
  },
21
24
  },
22
25
  };
@@ -27,6 +30,15 @@ const idParamSchema = {
27
30
  properties: { id: { type: 'integer' } },
28
31
  },
29
32
  };
33
+ const watchSchema = {
34
+ ...idParamSchema,
35
+ body: {
36
+ type: 'object',
37
+ required: ['inboxWatch'],
38
+ additionalProperties: false,
39
+ properties: { inboxWatch: { type: 'boolean' } },
40
+ },
41
+ };
30
42
  const syncSchema = {
31
43
  ...idParamSchema,
32
44
  querystring: {
@@ -60,13 +72,50 @@ export async function repoRoutes(app) {
60
72
  return { error: 'BadRequest', message: 'Search query must not be empty' };
61
73
  }
62
74
  const accountId = accountIdOf(req);
75
+ // Translate the user term into a literal GitHub search query. An `owner/...`
76
+ // prefix scopes results to that owner (org:/user: qualifier — resolved by type);
77
+ // the remainder (or the whole term) is matched against the repo NAME only
78
+ // (`in:name`), and `needle` drives the literal re-rank below.
79
+ const client = getGraphqlClientFor(await getAccessToken(accountId));
80
+ const slash = term.indexOf('/');
81
+ const owner = slash >= 0 ? term.slice(0, slash).trim() : '';
82
+ const rest = slash >= 0 ? term.slice(slash + 1).trim() : term;
83
+ let searchQuery;
84
+ let needle;
85
+ if (owner) {
86
+ // `owner/...` → scope to that owner. Note a stray leading slash ("/foo") leaves
87
+ // owner empty and falls through to the plain branch (no malformed `user:` query).
88
+ needle = rest;
89
+ let qualifier = `user:${owner}`;
90
+ try {
91
+ const ownerResp = await client(OWNER_TYPE_QUERY, {
92
+ login: owner,
93
+ });
94
+ const kind = ownerResp.repositoryOwner?.__typename ?? null;
95
+ if (kind == null) {
96
+ // Owner login doesn't exist → no possible matches.
97
+ return { results: [], hasNextPage: false, cursor: null };
98
+ }
99
+ qualifier = kind === 'Organization' ? `org:${owner}` : `user:${owner}`;
100
+ }
101
+ catch (err) {
102
+ // Lookup failed (network / rate limit) — default to user: scoping and run the
103
+ // search anyway. Logged so a wrong-scope result is diagnosable.
104
+ req.log.warn({ err, owner }, 'repo search: owner-type lookup failed; defaulting to user: scope');
105
+ }
106
+ searchQuery = rest ? `${qualifier} ${rest} in:name` : qualifier;
107
+ }
108
+ else {
109
+ // Plain term (or a stray leading slash) → literal repo-name search.
110
+ needle = rest;
111
+ searchQuery = rest ? `${rest} in:name` : term;
112
+ }
63
113
  let resp;
64
114
  try {
65
- const client = getGraphqlClientFor(await getAccessToken(accountId));
66
115
  // NB: the GraphQL variable is `searchQuery`, not `query` — @octokit/graphql
67
116
  // reserves `query` for the document body and rejects it as a variable name.
68
117
  resp = await client(REPO_SEARCH_QUERY, {
69
- searchQuery: term,
118
+ searchQuery,
70
119
  first: limit,
71
120
  cursor: cursor ?? null,
72
121
  });
@@ -103,9 +152,31 @@ export async function repoRoutes(app) {
103
152
  orgLogins.has(ownerLogin.toLowerCase()),
104
153
  };
105
154
  });
106
- // Float owned/member repos to the top, preserving GitHub's best-match order
107
- // within each group (Array.prototype.sort is stable on Node 12).
108
- results.sort((a, b) => Number(b.isOwnedOrMember) - Number(a.isOwnedOrMember));
155
+ // Re-rank for literal matching: closest name match first (exact < prefix <
156
+ // substring < other), then your own/org repos, then stars. Array.prototype.sort
157
+ // is stable (Node ≥ 12), so GitHub's best-match order breaks remaining ties.
158
+ const lc = needle.toLowerCase();
159
+ const nameTier = (name) => {
160
+ if (!lc)
161
+ return 0;
162
+ const n = name.toLowerCase();
163
+ if (n === lc)
164
+ return 0;
165
+ if (n.startsWith(lc))
166
+ return 1;
167
+ if (n.includes(lc))
168
+ return 2;
169
+ return 3;
170
+ };
171
+ results.sort((a, b) => {
172
+ const t = nameTier(a.name) - nameTier(b.name);
173
+ if (t !== 0)
174
+ return t;
175
+ const own = Number(b.isOwnedOrMember) - Number(a.isOwnedOrMember);
176
+ if (own !== 0)
177
+ return own;
178
+ return b.stargazerCount - a.stargazerCount;
179
+ });
109
180
  const body = {
110
181
  results,
111
182
  hasNextPage: resp.search.pageInfo.hasNextPage,
@@ -114,7 +185,7 @@ export async function repoRoutes(app) {
114
185
  return body;
115
186
  });
116
187
  app.post('/api/repos', { schema: createRepoSchema }, async (req, reply) => {
117
- const { owner, name } = req.body;
188
+ const { owner, name, watch } = req.body;
118
189
  const accountId = accountIdOf(req);
119
190
  let resp;
120
191
  try {
@@ -150,11 +221,28 @@ export async function repoRoutes(app) {
150
221
  const canonOwner = resp.repository.owner.login;
151
222
  const canonName = resp.repository.name;
152
223
  const repoId = await upsertRepo(canonOwner, canonName, resp.repository.id, null, accountId);
224
+ // Auto-watch "yours" repos for the inbox. Idempotent on re-add and preserves an
225
+ // existing watch-start (setRepoInboxWatch only stamps the start when unset).
226
+ if (watch === true)
227
+ await setRepoInboxWatch(accountId, repoId, true);
153
228
  // Kick off the initial backfill in the background.
154
229
  runSyncForRepo(repoId, app.log, { background: true });
155
230
  reply.status(201);
156
231
  return getRepo(repoId, accountId);
157
232
  });
233
+ // Toggle "Watch for inbox" on a repo. Inbox-only: it does not affect timeline
234
+ // visibility or syncing. Ownership-scoped → 404 for a repo this account doesn't own.
235
+ app.patch('/api/repos/:id', { schema: watchSchema }, async (req, reply) => {
236
+ const { id } = req.params;
237
+ const { inboxWatch } = req.body;
238
+ const accountId = accountIdOf(req);
239
+ const ok = await setRepoInboxWatch(accountId, id, inboxWatch);
240
+ if (!ok) {
241
+ reply.status(404);
242
+ return { error: 'NotFound', message: `Repo ${id} not found` };
243
+ }
244
+ return getRepo(id, accountId);
245
+ });
158
246
  app.delete('/api/repos/:id', { schema: idParamSchema }, async (req, reply) => {
159
247
  const { id } = req.params;
160
248
  const accountId = accountIdOf(req);
@@ -0,0 +1,9 @@
1
+ -- "Watch for inbox" per repo (additive). When `inbox_watch` is true, new open PRs by
2
+ -- others (opened on/after `inbox_watch_started_at`) surface in the My Turn inbox —
3
+ -- independent of timeline visibility and of removing the repo. `inbox_watch_started_at`
4
+ -- is set on the first watch and preserved across unwatch, so re-watching restores the
5
+ -- same window. Existing repos default to NOT watched (opt-in); new "yours" repos are
6
+ -- watched on add by the picker. Postgres baseline is regenerated separately via
7
+ -- db:generate:pg.
8
+ ALTER TABLE `repos` ADD `inbox_watch` integer DEFAULT false NOT NULL;--> statement-breakpoint
9
+ ALTER TABLE `repos` ADD `inbox_watch_started_at` integer;
@@ -106,6 +106,13 @@
106
106
  "when": 1780800000005,
107
107
  "tag": "0014_account_last_active",
108
108
  "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 15,
112
+ "version": "6",
113
+ "when": 1780800000006,
114
+ "tag": "0015_repos_inbox_watch",
115
+ "breakpoints": true
109
116
  }
110
117
  ]
111
118
  }
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "repos" ADD COLUMN "inbox_watch" boolean DEFAULT false NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "repos" ADD COLUMN "inbox_watch_started_at" timestamp with time zone;