@in-the-loop-labs/pair-review 3.5.1 → 3.6.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 (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +603 -6
  5. package/public/index.html +90 -0
  6. package/public/js/components/ChatPanel.js +163 -3
  7. package/public/js/components/KeyboardShortcuts.js +10 -26
  8. package/public/js/components/TourBar.js +248 -0
  9. package/public/js/index.js +298 -25
  10. package/public/js/local.js +6 -0
  11. package/public/js/modules/cancel-background-job.js +183 -0
  12. package/public/js/modules/hunk-summary-renderer.js +116 -0
  13. package/public/js/modules/storage-cleanup.js +16 -0
  14. package/public/js/modules/tour-renderer.js +725 -0
  15. package/public/js/pr.js +1276 -2
  16. package/public/js/utils/modal-detection.js +77 -0
  17. package/public/local.html +17 -0
  18. package/public/pr.html +17 -0
  19. package/src/ai/abort-signal-wiring.js +130 -0
  20. package/src/ai/background-queue.js +290 -0
  21. package/src/ai/claude-cli.js +1 -1
  22. package/src/ai/claude-provider.js +50 -7
  23. package/src/ai/codex-provider.js +28 -5
  24. package/src/ai/copilot-provider.js +22 -3
  25. package/src/ai/cursor-agent-provider.js +22 -6
  26. package/src/ai/executable-provider.js +4 -19
  27. package/src/ai/gemini-provider.js +22 -5
  28. package/src/ai/hunk-hashing.js +161 -0
  29. package/src/ai/index.js +2 -0
  30. package/src/ai/opencode-provider.js +21 -5
  31. package/src/ai/pi-provider.js +21 -5
  32. package/src/ai/prompts/hunk-summary.js +199 -0
  33. package/src/ai/prompts/tour.js +232 -0
  34. package/src/ai/provider.js +21 -1
  35. package/src/ai/summary-generator.js +469 -0
  36. package/src/ai/tour-generator.js +568 -0
  37. package/src/config.js +114 -0
  38. package/src/database.js +282 -1
  39. package/src/local-review.js +189 -169
  40. package/src/routes/config.js +16 -1
  41. package/src/routes/context-files.js +2 -29
  42. package/src/routes/github-collections.js +168 -90
  43. package/src/routes/local.js +311 -4
  44. package/src/routes/middleware/validate-review-id.js +53 -0
  45. package/src/routes/pr.js +259 -4
  46. package/src/routes/reviews.js +145 -29
  47. package/src/utils/diff-hunks.js +65 -0
  48. package/src/utils/json-extractor.js +5 -2
@@ -3,8 +3,14 @@
3
3
  * GitHub Collections Routes
4
4
  *
5
5
  * Handles endpoints for PR collections:
6
- * - Review Requests: PRs where the user's review is requested
6
+ * - Review Requests: PRs where the user's review is requested directly
7
+ * - Team Review Requests: PRs where a team the user belongs to is requested,
8
+ * but the user is not requested directly
7
9
  * - My PRs: PRs authored by the user
10
+ *
11
+ * Each collection exposes two endpoints registered by `registerCollection`:
12
+ * - GET /api/github/:collection → cached rows from github_pr_cache
13
+ * - POST /api/github/:collection/refresh → fetch from GitHub and re-cache
8
14
  */
9
15
 
10
16
  const express = require('express');
@@ -15,112 +21,184 @@ const logger = require('../utils/logger');
15
21
 
16
22
  const router = express.Router();
17
23
 
24
+ const SELECT_COLUMNS = 'owner, repo, number, title, author, updated_at, html_url, state, fetched_at';
25
+
26
+ // Valid `org/team` slug: two non-empty segments of GitHub-allowed characters.
27
+ // Used to guard against query injection when interpolating the team into the
28
+ // GitHub search query string. Must be applied server-side; client validation
29
+ // is UX only.
30
+ const TEAM_SLUG_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
31
+
18
32
  /**
19
- * Get cached review request PRs.
33
+ * Collection definitions. `buildQuery` receives the authenticated user's login
34
+ * and an optional params object, and returns the GitHub search query for that
35
+ * collection. Only collections with `supportsTeamFilter: true` consume
36
+ * `params.team` (currently just `team-reviews`); the others accept and ignore
37
+ * it. The flag also gates the team plumbing in `registerCollection`: a stray
38
+ * `?team=` on a collection that doesn't support it is ignored rather than
39
+ * validated or folded into the cache key, so it can never create a misleading
40
+ * namespaced cache entry for a query whose results are identical to the
41
+ * unfiltered view.
20
42
  */
21
- router.get('/api/github/review-requests', async (req, res) => {
22
- try {
23
- const db = req.app.get('db');
24
- const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['review-requests']);
25
-
26
- const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
27
- res.json({ success: true, prs: rows, fetched_at: fetchedAt });
28
- } catch (error) {
29
- logger.error('Failed to fetch review requests:', error);
30
- res.status(500).json({ success: false, error: 'Failed to fetch review requests' });
43
+ const COLLECTIONS = [
44
+ {
45
+ name: 'review-requests',
46
+ label: 'review requests',
47
+ buildQuery: (login) => `is:pr is:open archived:false user-review-requested:${login}`
48
+ },
49
+ {
50
+ name: 'team-reviews',
51
+ label: 'team review requests',
52
+ supportsTeamFilter: true,
53
+ // Review requested from a team the user belongs to, excluding PRs where the
54
+ // user is requested directly (those appear under review-requests).
55
+ //
56
+ // When a specific `team` (org/team) is provided, narrow to that team's open
57
+ // review requests and drop the `-user-review-requested` exclusion: once the
58
+ // user explicitly picks a team, "show everything awaiting this team" is the
59
+ // least surprising behavior. The team value MUST already be validated.
60
+ buildQuery: (login, params) => {
61
+ const team = params && params.team;
62
+ if (team) {
63
+ return `is:pr is:open archived:false team-review-requested:${team}`;
64
+ }
65
+ return `is:pr is:open archived:false review-requested:${login} -user-review-requested:${login}`;
66
+ }
67
+ },
68
+ {
69
+ name: 'my-prs',
70
+ label: 'your pull requests',
71
+ buildQuery: (login) => `is:pr is:open archived:false author:${login}`
31
72
  }
32
- });
73
+ ];
33
74
 
34
75
  /**
35
- * Refresh review request PRs from GitHub.
76
+ * Derive the cache storage key for a collection, namespacing by team so a
77
+ * filtered view never clobbers the all-teams cache. Both the GET (read) and
78
+ * POST refresh (write/delete) handlers must route through this single helper so
79
+ * they never diverge.
80
+ * @param {string} name - Collection name.
81
+ * @param {string} [team] - Validated `org/team` slug, or falsy for all-teams.
82
+ * @returns {string} The `collection` column value to use.
36
83
  */
37
- router.post('/api/github/review-requests/refresh', async (req, res) => {
38
- try {
39
- const config = req.app.get('config');
40
- const githubToken = getGitHubToken(config);
41
- if (!githubToken) {
42
- return res.status(401).json({ success: false, error: 'GitHub token not configured' });
43
- }
84
+ function cacheKey(name, team) {
85
+ return team ? `${name}:${team}` : name;
86
+ }
44
87
 
45
- const db = req.app.get('db');
46
- const client = new GitHubClient(githubToken);
47
- const user = await client.getAuthenticatedUser();
48
- const prs = await client.searchPullRequests(`is:pr is:open archived:false user-review-requested:${user.login}`);
49
-
50
- await withTransaction(db, async () => {
51
- await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['review-requests']);
52
- for (const pr of prs) {
53
- await run(db,
54
- 'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
55
- [pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, 'review-requests']
56
- );
57
- }
58
- });
59
-
60
- const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['review-requests']);
61
- const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
62
- res.json({ success: true, prs: rows, fetched_at: fetchedAt });
63
- } catch (error) {
64
- if (error.status === 401 || error.status === 403) {
65
- return res.status(401).json({ success: false, error: 'GitHub token is invalid or expired' });
66
- }
67
- logger.error('Failed to refresh review requests:', error);
68
- res.status(500).json({ success: false, error: 'Failed to refresh review requests' });
88
+ /**
89
+ * Validate and normalize the `team` query param.
90
+ * @param {*} raw - The raw `team` value from the request.
91
+ * @returns {{ team: string|null, error: string|null }} `team` is the validated
92
+ * slug (or null for all-teams); `error` is set when the input is invalid.
93
+ */
94
+ function parseTeamParam(raw) {
95
+ if (raw === undefined || raw === null || raw === '') {
96
+ return { team: null, error: null };
97
+ }
98
+ if (typeof raw !== 'string' || !TEAM_SLUG_PATTERN.test(raw)) {
99
+ return { team: null, error: 'Invalid team. Use the form org/team.' };
69
100
  }
70
- });
101
+ return { team: raw, error: null };
102
+ }
71
103
 
72
104
  /**
73
- * Get cached user's own PRs.
105
+ * Fetch cached rows for a collection, newest first.
74
106
  */
75
- router.get('/api/github/my-prs', async (req, res) => {
76
- try {
77
- const db = req.app.get('db');
78
- const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['my-prs']);
79
-
80
- const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
81
- res.json({ success: true, prs: rows, fetched_at: fetchedAt });
82
- } catch (error) {
83
- logger.error('Failed to fetch my PRs:', error);
84
- res.status(500).json({ success: false, error: 'Failed to fetch my PRs' });
85
- }
86
- });
107
+ async function getCachedRows(db, collection) {
108
+ return query(
109
+ db,
110
+ `SELECT ${SELECT_COLUMNS} FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC`,
111
+ [collection]
112
+ );
113
+ }
87
114
 
88
115
  /**
89
- * Refresh user's own PRs from GitHub.
116
+ * Register the GET (cached) and POST (refresh) routes for a single collection.
117
+ * @param {Object} def - Collection definition
118
+ * ({ name, label, buildQuery, supportsTeamFilter }).
90
119
  */
91
- router.post('/api/github/my-prs/refresh', async (req, res) => {
92
- try {
93
- const config = req.app.get('config');
94
- const githubToken = getGitHubToken(config);
95
- if (!githubToken) {
96
- return res.status(401).json({ success: false, error: 'GitHub token not configured' });
120
+ function registerCollection(def) {
121
+ const { name, label, buildQuery, supportsTeamFilter } = def;
122
+
123
+ // GET cached PRs.
124
+ router.get(`/api/github/${name}`, async (req, res) => {
125
+ try {
126
+ // Only team-aware collections consume `team`; for the rest, ignore any
127
+ // stray `?team=` so it can't validate-error or skew the cache key.
128
+ let team = null;
129
+ if (supportsTeamFilter) {
130
+ const parsed = parseTeamParam(req.query.team);
131
+ if (parsed.error) {
132
+ return res.status(400).json({ success: false, error: parsed.error });
133
+ }
134
+ team = parsed.team;
135
+ }
136
+
137
+ const db = req.app.get('db');
138
+ const rows = await getCachedRows(db, cacheKey(name, team));
139
+ const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
140
+ res.json({ success: true, prs: rows, fetched_at: fetchedAt });
141
+ } catch (error) {
142
+ logger.error(`Failed to fetch ${label}:`, error);
143
+ res.status(500).json({ success: false, error: `Failed to fetch ${label}` });
97
144
  }
145
+ });
98
146
 
99
- const db = req.app.get('db');
100
- const client = new GitHubClient(githubToken);
101
- const user = await client.getAuthenticatedUser();
102
- const prs = await client.searchPullRequests(`is:pr is:open archived:false author:${user.login}`);
103
-
104
- await withTransaction(db, async () => {
105
- await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', ['my-prs']);
106
- for (const pr of prs) {
107
- await run(db,
108
- 'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
109
- [pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, 'my-prs']
110
- );
147
+ // POST refresh from GitHub.
148
+ router.post(`/api/github/${name}/refresh`, async (req, res) => {
149
+ try {
150
+ // Only team-aware collections consume `team` (from the query string or
151
+ // request body); for the rest, ignore any stray value so it can't
152
+ // validate-error or skew the cache key. Empty/absent means "all teams".
153
+ let team = null;
154
+ if (supportsTeamFilter) {
155
+ const rawTeam = req.query.team !== undefined ? req.query.team : (req.body && req.body.team);
156
+ const parsed = parseTeamParam(rawTeam);
157
+ if (parsed.error) {
158
+ return res.status(400).json({ success: false, error: parsed.error });
159
+ }
160
+ team = parsed.team;
111
161
  }
112
- });
113
-
114
- const rows = await query(db, 'SELECT owner, repo, number, title, author, updated_at, html_url, state, fetched_at FROM github_pr_cache WHERE collection = ? ORDER BY updated_at DESC', ['my-prs']);
115
- const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
116
- res.json({ success: true, prs: rows, fetched_at: fetchedAt });
117
- } catch (error) {
118
- if (error.status === 401 || error.status === 403) {
119
- return res.status(401).json({ success: false, error: 'GitHub token is invalid or expired' });
162
+
163
+ const config = req.app.get('config');
164
+ const githubToken = getGitHubToken(config);
165
+ if (!githubToken) {
166
+ return res.status(401).json({ success: false, error: 'GitHub token not configured' });
167
+ }
168
+
169
+ const db = req.app.get('db');
170
+ const client = new GitHubClient(githubToken);
171
+ const user = await client.getAuthenticatedUser();
172
+ const prs = await client.searchPullRequests(buildQuery(user.login, { team }));
173
+
174
+ // Namespace the cache so a filtered view never clobbers the all-teams
175
+ // cache. Every distinct team string the user tries creates its own cached
176
+ // rows that are never garbage-collected; negligible for a local SQLite
177
+ // home-page feature.
178
+ const key = cacheKey(name, team);
179
+ await withTransaction(db, async () => {
180
+ await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', [key]);
181
+ for (const pr of prs) {
182
+ await run(db,
183
+ 'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
184
+ [pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, key]
185
+ );
186
+ }
187
+ });
188
+
189
+ const rows = await getCachedRows(db, key);
190
+ const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
191
+ res.json({ success: true, prs: rows, fetched_at: fetchedAt });
192
+ } catch (error) {
193
+ if (error.status === 401 || error.status === 403) {
194
+ return res.status(401).json({ success: false, error: 'GitHub token is invalid or expired' });
195
+ }
196
+ logger.error(`Failed to refresh ${label}:`, error);
197
+ res.status(500).json({ success: false, error: `Failed to refresh ${label}` });
120
198
  }
121
- logger.error('Failed to refresh my PRs:', error);
122
- res.status(500).json({ success: false, error: 'Failed to refresh my PRs' });
123
- }
124
- });
199
+ });
200
+ }
201
+
202
+ COLLECTIONS.forEach(registerCollection);
125
203
 
126
204
  module.exports = router;