@in-the-loop-labs/pair-review 3.5.0 → 3.5.2
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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/index.html +90 -0
- package/public/js/index.js +298 -25
- package/src/ai/claude-provider.js +68 -56
- package/src/ai/codex-provider.js +64 -33
- package/src/chat/api-reference.js +1 -1
- package/src/chat/chat-providers.js +26 -0
- package/src/chat/codex-bridge.js +238 -29
- package/src/chat/session-manager.js +1 -0
- package/src/main.js +3 -2
- package/src/routes/github-collections.js +168 -90
|
@@ -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
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
*
|
|
105
|
+
* Fetch cached rows for a collection, newest first.
|
|
74
106
|
*/
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
COLLECTIONS.forEach(registerCollection);
|
|
125
203
|
|
|
126
204
|
module.exports = router;
|