@in-the-loop-labs/pair-review 3.5.1 → 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/package.json +20 -15
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/index.html +90 -0
- package/public/js/index.js +298 -25
- package/src/routes/github-collections.js +168 -90
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@in-the-loop-labs/pair-review",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
4
4
|
"description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,20 @@
|
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=20.0.0"
|
|
23
23
|
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"start": "node src/server.js",
|
|
26
|
+
"dev": "node bin/pair-review.js",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"test:coverage": "vitest run --coverage",
|
|
30
|
+
"test:e2e": "playwright test",
|
|
31
|
+
"test:e2e:headed": "playwright test --headed",
|
|
32
|
+
"test:e2e:debug": "playwright test --debug",
|
|
33
|
+
"generate:skill-prompts": "node scripts/generate-skill-prompts.js",
|
|
34
|
+
"changeset": "changeset",
|
|
35
|
+
"version": "changeset version && pnpm install --lockfile-only && bash scripts/generate-package-lock.sh && node scripts/sync-plugin-versions.js && git add package.json pnpm-lock.yaml package-lock.json CHANGELOG.md .changeset .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin-code-critic/.claude-plugin/plugin.json && git commit -m \"RELEASING: v$(node -p \"require('./package.json').version\")\"",
|
|
36
|
+
"release": "npm whoami > /dev/null || { echo 'Error: Not logged in to npm. Run: npm login'; exit 1; } && pnpm run version && changeset tag && npm publish && git push && git push --tags"
|
|
37
|
+
},
|
|
24
38
|
"keywords": [
|
|
25
39
|
"code-review",
|
|
26
40
|
"pull-request",
|
|
@@ -70,18 +84,9 @@
|
|
|
70
84
|
"supertest": "^7.1.4",
|
|
71
85
|
"vitest": "^4.0.16"
|
|
72
86
|
},
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"test:watch": "vitest",
|
|
78
|
-
"test:coverage": "vitest run --coverage",
|
|
79
|
-
"test:e2e": "playwright test",
|
|
80
|
-
"test:e2e:headed": "playwright test --headed",
|
|
81
|
-
"test:e2e:debug": "playwright test --debug",
|
|
82
|
-
"generate:skill-prompts": "node scripts/generate-skill-prompts.js",
|
|
83
|
-
"changeset": "changeset",
|
|
84
|
-
"version": "changeset version && pnpm install --lockfile-only && bash scripts/generate-package-lock.sh && node scripts/sync-plugin-versions.js && git add package.json pnpm-lock.yaml package-lock.json CHANGELOG.md .changeset .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin-code-critic/.claude-plugin/plugin.json && git commit -m \"RELEASING: v$(node -p \"require('./package.json').version\")\"",
|
|
85
|
-
"release": "npm whoami > /dev/null || { echo 'Error: Not logged in to npm. Run: npm login'; exit 1; } && pnpm run version && changeset tag && npm publish && git push && git push --tags"
|
|
87
|
+
"pnpm": {
|
|
88
|
+
"onlyBuiltDependencies": [
|
|
89
|
+
"better-sqlite3"
|
|
90
|
+
]
|
|
86
91
|
}
|
|
87
|
-
}
|
|
92
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
4
4
|
"description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-critic",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
4
4
|
"description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
File without changes
|
package/public/index.html
CHANGED
|
@@ -960,6 +960,65 @@
|
|
|
960
960
|
animation: spin 0.8s linear infinite;
|
|
961
961
|
}
|
|
962
962
|
|
|
963
|
+
/* Team Review Requests: filter-by-team control */
|
|
964
|
+
.team-filter-group {
|
|
965
|
+
display: flex;
|
|
966
|
+
align-items: center;
|
|
967
|
+
gap: 8px;
|
|
968
|
+
/* Push the filter to the left; fetched-at + refresh stay right. */
|
|
969
|
+
margin-right: auto;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
.team-filter {
|
|
973
|
+
display: flex;
|
|
974
|
+
align-items: center;
|
|
975
|
+
gap: 4px;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
.team-filter-input {
|
|
979
|
+
width: 160px;
|
|
980
|
+
height: 32px;
|
|
981
|
+
padding: 0 8px;
|
|
982
|
+
font-size: 13px;
|
|
983
|
+
background: var(--color-bg-primary);
|
|
984
|
+
border: 1px solid var(--color-border-primary);
|
|
985
|
+
border-radius: var(--radius-md);
|
|
986
|
+
color: var(--color-text-primary);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.team-filter-input:focus {
|
|
990
|
+
outline: none;
|
|
991
|
+
border-color: var(--ai-primary);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
.team-filter-input.invalid {
|
|
995
|
+
border-color: var(--color-danger);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.team-filter-apply,
|
|
999
|
+
.team-filter-clear {
|
|
1000
|
+
height: 32px;
|
|
1001
|
+
padding: 0 10px;
|
|
1002
|
+
font-size: 13px;
|
|
1003
|
+
background: transparent;
|
|
1004
|
+
border: 1px solid var(--color-border-primary);
|
|
1005
|
+
border-radius: var(--radius-md);
|
|
1006
|
+
color: var(--color-text-tertiary);
|
|
1007
|
+
cursor: pointer;
|
|
1008
|
+
transition: all var(--transition-fast);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.team-filter-apply:hover,
|
|
1012
|
+
.team-filter-clear:hover {
|
|
1013
|
+
background: var(--color-bg-secondary);
|
|
1014
|
+
color: var(--color-text-primary);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
.team-filter-hint {
|
|
1018
|
+
font-size: 12px;
|
|
1019
|
+
color: var(--color-danger);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
963
1022
|
.collection-pr-row {
|
|
964
1023
|
cursor: pointer;
|
|
965
1024
|
}
|
|
@@ -1343,6 +1402,7 @@
|
|
|
1343
1402
|
<div class="tab-bar" id="unified-tab-bar">
|
|
1344
1403
|
<button class="tab-btn active" data-tab="pr-tab" type="button">Pull Requests</button>
|
|
1345
1404
|
<button class="tab-btn" data-tab="review-requests-tab" type="button">My Review Requests</button>
|
|
1405
|
+
<button class="tab-btn" data-tab="team-reviews-tab" type="button">Team Review Requests</button>
|
|
1346
1406
|
<button class="tab-btn" data-tab="my-prs-tab" type="button">My PRs</button>
|
|
1347
1407
|
<span class="tab-divider"></span>
|
|
1348
1408
|
<button class="tab-btn" data-tab="local-tab" type="button">Local Reviews</button>
|
|
@@ -1392,6 +1452,36 @@
|
|
|
1392
1452
|
</div>
|
|
1393
1453
|
</div>
|
|
1394
1454
|
|
|
1455
|
+
<!-- Team Review Requests Tab -->
|
|
1456
|
+
<div class="tab-pane" id="team-reviews-tab">
|
|
1457
|
+
<div class="tab-pane-header">
|
|
1458
|
+
<div class="team-filter-group">
|
|
1459
|
+
<form class="team-filter" id="team-reviews-filter">
|
|
1460
|
+
<input
|
|
1461
|
+
type="text"
|
|
1462
|
+
class="team-filter-input"
|
|
1463
|
+
id="team-reviews-team-input"
|
|
1464
|
+
placeholder="org/team"
|
|
1465
|
+
autocomplete="off"
|
|
1466
|
+
spellcheck="false"
|
|
1467
|
+
aria-label="Filter by team (org/team)"
|
|
1468
|
+
aria-describedby="team-reviews-filter-hint"
|
|
1469
|
+
>
|
|
1470
|
+
<button type="submit" class="team-filter-apply" id="team-reviews-filter-apply" title="Filter by this team">Filter</button>
|
|
1471
|
+
<button type="button" class="team-filter-clear" id="team-reviews-filter-clear" title="Show all teams" hidden>Clear</button>
|
|
1472
|
+
</form>
|
|
1473
|
+
<span class="team-filter-hint" id="team-reviews-filter-hint" role="alert" hidden>Use the form org/team.</span>
|
|
1474
|
+
</div>
|
|
1475
|
+
<span class="fetched-at-label" id="team-reviews-fetched-at"></span>
|
|
1476
|
+
<button class="btn-refresh" id="refresh-team-reviews" title="Refresh from GitHub">
|
|
1477
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg>
|
|
1478
|
+
</button>
|
|
1479
|
+
</div>
|
|
1480
|
+
<div id="team-reviews-container" class="recent-reviews-loading">
|
|
1481
|
+
Loading...
|
|
1482
|
+
</div>
|
|
1483
|
+
</div>
|
|
1484
|
+
|
|
1395
1485
|
<!-- My PRs Tab -->
|
|
1396
1486
|
<div class="tab-pane" id="my-prs-tab">
|
|
1397
1487
|
<div class="tab-pane-header">
|
package/public/js/index.js
CHANGED
|
@@ -303,7 +303,21 @@
|
|
|
303
303
|
loaded: false
|
|
304
304
|
};
|
|
305
305
|
|
|
306
|
-
// ─── GitHub PR Collections (
|
|
306
|
+
// ─── GitHub PR Collections (Review Requests / Team Reviews / My PRs) ───────
|
|
307
|
+
|
|
308
|
+
/** Empty-state message per collection (used when a fetch returned no PRs). */
|
|
309
|
+
var COLLECTION_EMPTY_MESSAGES = {
|
|
310
|
+
'review-requests': 'No pull requests awaiting your review.',
|
|
311
|
+
'team-reviews': 'No pull requests awaiting your team’s review.',
|
|
312
|
+
'my-prs': 'You have no open pull requests.'
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** Noun used in the "Configure a GitHub token to see …" message per collection. */
|
|
316
|
+
var COLLECTION_TOKEN_LABELS = {
|
|
317
|
+
'review-requests': 'review requests',
|
|
318
|
+
'team-reviews': 'team review requests',
|
|
319
|
+
'my-prs': 'your pull requests'
|
|
320
|
+
};
|
|
307
321
|
|
|
308
322
|
var reviewRequestsState = {
|
|
309
323
|
loaded: false,
|
|
@@ -311,16 +325,90 @@
|
|
|
311
325
|
fetchedAt: null
|
|
312
326
|
};
|
|
313
327
|
|
|
328
|
+
var teamReviewsState = {
|
|
329
|
+
loaded: false,
|
|
330
|
+
prs: [],
|
|
331
|
+
fetchedAt: null,
|
|
332
|
+
// Monotonic token for the most recent team-reviews request (see
|
|
333
|
+
// beginTeamReviewsRequest). Stale responses compare against this and bail.
|
|
334
|
+
activeRequest: 0
|
|
335
|
+
};
|
|
336
|
+
|
|
314
337
|
var myPrsState = {
|
|
315
338
|
loaded: false,
|
|
316
339
|
prs: [],
|
|
317
340
|
fetchedAt: null
|
|
318
341
|
};
|
|
319
342
|
|
|
343
|
+
// Filter-by-team support for the Team Review Requests tab.
|
|
344
|
+
// Client-side validation is UX only; the server re-validates before
|
|
345
|
+
// interpolating the value into the GitHub search query (query-injection
|
|
346
|
+
// guard). Pattern must match the server's TEAM_SLUG_PATTERN.
|
|
347
|
+
var TEAM_SLUG_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
348
|
+
var TEAM_FILTER_LS_KEY = 'github-collection-team:team-reviews';
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Read the persisted team filter, returning '' (all teams) for an absent or
|
|
352
|
+
* invalid stored value.
|
|
353
|
+
* @returns {string} A validated `org/team` slug, or ''.
|
|
354
|
+
*/
|
|
355
|
+
function getStoredTeamFilter() {
|
|
356
|
+
try {
|
|
357
|
+
var v = (localStorage.getItem(TEAM_FILTER_LS_KEY) || '').trim();
|
|
358
|
+
return v && TEAM_SLUG_PATTERN.test(v) ? v : '';
|
|
359
|
+
} catch (e) {
|
|
360
|
+
return '';
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Begin a new team-reviews request "epoch" and return its token. Every user
|
|
366
|
+
* action that (re)loads the team-reviews view — filter apply, tab switch,
|
|
367
|
+
* manual refresh, initial load — calls this once and threads the returned id
|
|
368
|
+
* through BOTH the cached GET and the live refresh. Because multiple in-flight
|
|
369
|
+
* fetches write into the shared `teamReviewsState` and render into the same
|
|
370
|
+
* container, completion order alone could otherwise let a slower earlier
|
|
371
|
+
* request (e.g. a previous team, or the all-teams view) repaint the table with
|
|
372
|
+
* PRs that no longer match the active selection. loadCollectionPrs /
|
|
373
|
+
* refreshCollectionPrs bail after `await fetch(...)` when their token is no
|
|
374
|
+
* longer `activeRequest`, discarding the stale response before it mutates
|
|
375
|
+
* state or the DOM.
|
|
376
|
+
* @returns {number} The token for this request epoch.
|
|
377
|
+
*/
|
|
378
|
+
function beginTeamReviewsRequest() {
|
|
379
|
+
teamReviewsState.activeRequest += 1;
|
|
380
|
+
return teamReviewsState.activeRequest;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Resync the Team Review Requests filter controls to a known-applied value
|
|
385
|
+
* (typically getStoredTeamFilter()). Switching tabs only toggles `.active` —
|
|
386
|
+
* it does not rebuild the Team Reviews DOM — so unapplied draft text typed
|
|
387
|
+
* into the input survives a tab switch. Because every reload path keys its
|
|
388
|
+
* fetch off getStoredTeamFilter() (the persisted value) rather than the live
|
|
389
|
+
* input, the visible filter could otherwise advertise one team while the
|
|
390
|
+
* queue, row-click navigation, and bulk actions operate on another. Any path
|
|
391
|
+
* that re-consumes the stored filter must call this first so the displayed
|
|
392
|
+
* filter cannot diverge from the team actually being fetched and rendered.
|
|
393
|
+
* @param {string} value - The validated `org/team` slug to display, or '' for all teams.
|
|
394
|
+
*/
|
|
395
|
+
function syncTeamFilterControls(value) {
|
|
396
|
+
var input = document.getElementById('team-reviews-team-input');
|
|
397
|
+
if (input) {
|
|
398
|
+
input.value = value || '';
|
|
399
|
+
input.classList.remove('invalid');
|
|
400
|
+
input.removeAttribute('aria-invalid');
|
|
401
|
+
}
|
|
402
|
+
var hint = document.getElementById('team-reviews-filter-hint');
|
|
403
|
+
if (hint) hint.hidden = true;
|
|
404
|
+
var clearBtn = document.getElementById('team-reviews-filter-clear');
|
|
405
|
+
if (clearBtn) clearBtn.hidden = !value;
|
|
406
|
+
}
|
|
407
|
+
|
|
320
408
|
/**
|
|
321
409
|
* Render a single row for a collection PR table.
|
|
322
410
|
* @param {Object} pr - PR object from the API
|
|
323
|
-
* @param {string} collection - The collection name ('review-requests' or 'my-prs')
|
|
411
|
+
* @param {string} collection - The collection name ('review-requests', 'team-reviews', or 'my-prs')
|
|
324
412
|
* @returns {string} HTML string for the table row
|
|
325
413
|
*/
|
|
326
414
|
function renderCollectionPrRow(pr, collection) {
|
|
@@ -366,15 +454,20 @@
|
|
|
366
454
|
* Render the collection table into a container element.
|
|
367
455
|
* @param {HTMLElement} container - The container element
|
|
368
456
|
* @param {Object} state - The collection state object
|
|
369
|
-
* @param {string} collection - The collection name ('review-requests' or 'my-prs')
|
|
457
|
+
* @param {string} collection - The collection name ('review-requests', 'team-reviews', or 'my-prs')
|
|
370
458
|
*/
|
|
371
459
|
function renderCollectionTable(container, state, collection) {
|
|
372
|
-
var sel = collection
|
|
373
|
-
sel.exit();
|
|
460
|
+
var sel = collectionSelections[collection];
|
|
461
|
+
if (sel) sel.exit();
|
|
374
462
|
|
|
375
|
-
var
|
|
376
|
-
var fetchedAtEl = document.getElementById(fetchedAtId);
|
|
463
|
+
var fetchedAtEl = document.getElementById(collection + '-fetched-at');
|
|
377
464
|
if (fetchedAtEl) {
|
|
465
|
+
// Note: this key is intentionally NOT per-team — unlike the DB cache key
|
|
466
|
+
// in src/routes/github-collections.js (which uses cacheKey()), the
|
|
467
|
+
// fetched-at timestamp is informational only. We accept a brief label
|
|
468
|
+
// mismatch when the user switches the team-reviews filter; the label
|
|
469
|
+
// updates once that view's next refresh completes. See the write site in
|
|
470
|
+
// refreshCollectionPrs.
|
|
378
471
|
var lsKey = 'github-collection-fetched-at:' + collection;
|
|
379
472
|
var displayTs = localStorage.getItem(lsKey) || state.fetchedAt;
|
|
380
473
|
fetchedAtEl.textContent = displayTs
|
|
@@ -383,13 +476,11 @@
|
|
|
383
476
|
}
|
|
384
477
|
|
|
385
478
|
if (state.prs.length === 0) {
|
|
386
|
-
var
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
emptyMsg = 'Click refresh to fetch from GitHub.';
|
|
392
|
-
}
|
|
479
|
+
var emptyLsKey = 'github-collection-fetched-at:' + collection;
|
|
480
|
+
var hasFetched = state.fetchedAt || localStorage.getItem(emptyLsKey);
|
|
481
|
+
var emptyMsg = hasFetched
|
|
482
|
+
? (COLLECTION_EMPTY_MESSAGES[collection] || 'No pull requests found.')
|
|
483
|
+
: 'Click refresh to fetch from GitHub.';
|
|
393
484
|
|
|
394
485
|
container.innerHTML =
|
|
395
486
|
'<div class="recent-reviews-empty">' +
|
|
@@ -401,7 +492,7 @@
|
|
|
401
492
|
|
|
402
493
|
var authorTh = collection === 'my-prs' ? '' : '<th>Author</th>';
|
|
403
494
|
|
|
404
|
-
var tbodyId = collection
|
|
495
|
+
var tbodyId = collection + '-tbody';
|
|
405
496
|
|
|
406
497
|
container.innerHTML =
|
|
407
498
|
'<table class="recent-reviews-table">' +
|
|
@@ -424,18 +515,28 @@
|
|
|
424
515
|
|
|
425
516
|
/**
|
|
426
517
|
* Load collection PRs from cached backend data.
|
|
427
|
-
* @param {string} collection - 'review-requests' or 'my-prs'
|
|
518
|
+
* @param {string} collection - 'review-requests', 'team-reviews', or 'my-prs'
|
|
428
519
|
* @param {string} containerId - DOM id of the container element
|
|
429
520
|
* @param {Object} state - The collection state object
|
|
521
|
+
* @param {string} [team] - Optional validated `org/team` filter (team-reviews
|
|
522
|
+
* only). When set, requests the namespaced cache for that team.
|
|
523
|
+
* @param {number} [requestId] - Optional team-reviews request token (see
|
|
524
|
+
* beginTeamReviewsRequest). When provided, the response is discarded if a
|
|
525
|
+
* newer request has superseded it before this fetch resolved.
|
|
430
526
|
*/
|
|
431
|
-
async function loadCollectionPrs(collection, containerId, state) {
|
|
527
|
+
async function loadCollectionPrs(collection, containerId, state, team, requestId) {
|
|
432
528
|
var container = document.getElementById(containerId);
|
|
433
529
|
|
|
434
530
|
try {
|
|
435
|
-
var
|
|
531
|
+
var url = '/api/github/' + collection + (team ? '?team=' + encodeURIComponent(team) : '');
|
|
532
|
+
var response = await fetch(url);
|
|
436
533
|
if (!response.ok) throw new Error('Failed to fetch');
|
|
437
534
|
var data = await response.json();
|
|
438
535
|
|
|
536
|
+
// Discard a response a newer team-reviews request has superseded, so a
|
|
537
|
+
// slow earlier load can't repaint the table with a stale team's PRs.
|
|
538
|
+
if (requestId != null && requestId !== state.activeRequest) return;
|
|
539
|
+
|
|
439
540
|
state.loaded = true;
|
|
440
541
|
state.prs = data.prs || [];
|
|
441
542
|
state.fetchedAt = data.fetched_at;
|
|
@@ -443,6 +544,8 @@
|
|
|
443
544
|
renderCollectionTable(container, state, collection);
|
|
444
545
|
} catch (error) {
|
|
445
546
|
console.error('Error loading ' + collection + ':', error);
|
|
547
|
+
// Don't paint the error over a view a newer request now owns.
|
|
548
|
+
if (requestId != null && requestId !== state.activeRequest) return;
|
|
446
549
|
container.innerHTML =
|
|
447
550
|
'<div class="recent-reviews-empty">' +
|
|
448
551
|
'<p>Failed to load. Click refresh to try again.</p>' +
|
|
@@ -453,14 +556,18 @@
|
|
|
453
556
|
|
|
454
557
|
/**
|
|
455
558
|
* Refresh collection PRs by fetching fresh data from GitHub.
|
|
456
|
-
* @param {string} collection - 'review-requests' or 'my-prs'
|
|
559
|
+
* @param {string} collection - 'review-requests', 'team-reviews', or 'my-prs'
|
|
457
560
|
* @param {string} containerId - DOM id of the container element
|
|
458
561
|
* @param {Object} state - The collection state object
|
|
562
|
+
* @param {string} [team] - Optional validated `org/team` filter (team-reviews
|
|
563
|
+
* only). When set, refreshes and caches under the namespaced key.
|
|
564
|
+
* @param {number} [requestId] - Optional team-reviews request token (see
|
|
565
|
+
* beginTeamReviewsRequest). When provided, the response is discarded if a
|
|
566
|
+
* newer request has superseded it before this fetch resolved.
|
|
459
567
|
*/
|
|
460
|
-
async function refreshCollectionPrs(collection, containerId, state) {
|
|
568
|
+
async function refreshCollectionPrs(collection, containerId, state, team, requestId) {
|
|
461
569
|
var container = document.getElementById(containerId);
|
|
462
|
-
var
|
|
463
|
-
var btn = document.getElementById(btnId);
|
|
570
|
+
var btn = document.getElementById('refresh-' + collection);
|
|
464
571
|
|
|
465
572
|
if (btn) btn.classList.add('refreshing');
|
|
466
573
|
|
|
@@ -470,7 +577,12 @@
|
|
|
470
577
|
}
|
|
471
578
|
|
|
472
579
|
try {
|
|
473
|
-
var
|
|
580
|
+
var refreshUrl = '/api/github/' + collection + '/refresh' + (team ? '?team=' + encodeURIComponent(team) : '');
|
|
581
|
+
var response = await fetch(refreshUrl, { method: 'POST' });
|
|
582
|
+
|
|
583
|
+
// A newer team-reviews request has superseded this one; drop it before
|
|
584
|
+
// touching the 401 message, shared state, or the table.
|
|
585
|
+
if (requestId != null && requestId !== state.activeRequest) return;
|
|
474
586
|
|
|
475
587
|
if (!response.ok) {
|
|
476
588
|
var errData = await response.json().catch(function() { return {}; });
|
|
@@ -478,7 +590,7 @@
|
|
|
478
590
|
container.innerHTML =
|
|
479
591
|
'<div class="recent-reviews-empty">' +
|
|
480
592
|
'<p>Configure a GitHub token to see ' +
|
|
481
|
-
(collection
|
|
593
|
+
(COLLECTION_TOKEN_LABELS[collection] || 'pull requests') +
|
|
482
594
|
'.</p>' +
|
|
483
595
|
'</div>';
|
|
484
596
|
container.classList.remove('recent-reviews-loading');
|
|
@@ -488,14 +600,26 @@
|
|
|
488
600
|
}
|
|
489
601
|
|
|
490
602
|
var data = await response.json();
|
|
603
|
+
|
|
604
|
+
// Re-check after the body parse: a newer request may have started while
|
|
605
|
+
// the response was streaming.
|
|
606
|
+
if (requestId != null && requestId !== state.activeRequest) return;
|
|
607
|
+
|
|
491
608
|
state.prs = data.prs || [];
|
|
492
609
|
state.fetchedAt = data.fetched_at;
|
|
493
610
|
state.loaded = true;
|
|
611
|
+
// Note: this key is intentionally NOT per-team — unlike the DB cache key
|
|
612
|
+
// in src/routes/github-collections.js (which uses cacheKey()), the
|
|
613
|
+
// fetched-at timestamp is informational only. We accept a brief label
|
|
614
|
+
// mismatch when the user switches the team-reviews filter; the label
|
|
615
|
+
// updates once the next refresh completes (read site: renderCollectionTable).
|
|
494
616
|
localStorage.setItem('github-collection-fetched-at:' + collection, new Date().toISOString());
|
|
495
617
|
|
|
496
618
|
renderCollectionTable(container, state, collection);
|
|
497
619
|
} catch (error) {
|
|
498
620
|
console.error('Error refreshing ' + collection + ':', error);
|
|
621
|
+
// Don't repaint a view a newer request now owns.
|
|
622
|
+
if (requestId != null && requestId !== state.activeRequest) return;
|
|
499
623
|
// If we had existing data, keep showing it
|
|
500
624
|
if (state.prs.length > 0) {
|
|
501
625
|
renderCollectionTable(container, state, collection);
|
|
@@ -511,6 +635,62 @@
|
|
|
511
635
|
}
|
|
512
636
|
}
|
|
513
637
|
|
|
638
|
+
/**
|
|
639
|
+
* Apply the Team Review Requests team filter from the input field.
|
|
640
|
+
* Validates client-side (UX only), persists the value, toggles the Clear
|
|
641
|
+
* affordance and inline hint, then reloads the team-reviews view for the
|
|
642
|
+
* selected team (empty input = all teams).
|
|
643
|
+
*/
|
|
644
|
+
function applyTeamFilter() {
|
|
645
|
+
var input = document.getElementById('team-reviews-team-input');
|
|
646
|
+
if (!input) return;
|
|
647
|
+
var hint = document.getElementById('team-reviews-filter-hint');
|
|
648
|
+
var clearBtn = document.getElementById('team-reviews-filter-clear');
|
|
649
|
+
var value = input.value.trim();
|
|
650
|
+
|
|
651
|
+
// Non-empty input must match the org/team format; skip the fetch and show
|
|
652
|
+
// the inline hint on invalid input. Empty means "all teams" (valid).
|
|
653
|
+
if (value !== '' && !TEAM_SLUG_PATTERN.test(value)) {
|
|
654
|
+
input.classList.add('invalid');
|
|
655
|
+
input.setAttribute('aria-invalid', 'true');
|
|
656
|
+
if (hint) hint.hidden = false;
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
input.classList.remove('invalid');
|
|
661
|
+
input.removeAttribute('aria-invalid');
|
|
662
|
+
if (hint) hint.hidden = true;
|
|
663
|
+
|
|
664
|
+
try {
|
|
665
|
+
if (value) {
|
|
666
|
+
localStorage.setItem(TEAM_FILTER_LS_KEY, value);
|
|
667
|
+
} else {
|
|
668
|
+
localStorage.removeItem(TEAM_FILTER_LS_KEY);
|
|
669
|
+
}
|
|
670
|
+
} catch (e) { /* localStorage unavailable; filter still applies for this view */ }
|
|
671
|
+
|
|
672
|
+
if (clearBtn) clearBtn.hidden = !value;
|
|
673
|
+
|
|
674
|
+
// Reset loaded state so we read the (namespaced) cache for the new view
|
|
675
|
+
// before refreshing live from GitHub.
|
|
676
|
+
teamReviewsState.loaded = false;
|
|
677
|
+
teamReviewsState.prs = [];
|
|
678
|
+
// Paint a placeholder so the previous team's rows don't linger while
|
|
679
|
+
// loadCollectionPrs / refreshCollectionPrs run asynchronously.
|
|
680
|
+
var container = document.getElementById('team-reviews-container');
|
|
681
|
+
if (container) {
|
|
682
|
+
container.innerHTML = '<div class="recent-reviews-loading">Loading...</div>';
|
|
683
|
+
}
|
|
684
|
+
var team = value || '';
|
|
685
|
+
// One token for this user action, threaded through both the cached GET and
|
|
686
|
+
// the live refresh, so a slower earlier request can't clobber this view.
|
|
687
|
+
var requestId = beginTeamReviewsRequest();
|
|
688
|
+
loadCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, team, requestId)
|
|
689
|
+
.then(function () {
|
|
690
|
+
refreshCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, team, requestId);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
514
694
|
/**
|
|
515
695
|
* Fetch and display local review sessions (initial load).
|
|
516
696
|
*/
|
|
@@ -1610,6 +1790,17 @@
|
|
|
1610
1790
|
]
|
|
1611
1791
|
});
|
|
1612
1792
|
|
|
1793
|
+
var teamReviewsSelection = new SelectionMode({
|
|
1794
|
+
tabId: 'team-reviews-tab',
|
|
1795
|
+
containerId: 'team-reviews-container',
|
|
1796
|
+
tbodyId: 'team-reviews-tbody',
|
|
1797
|
+
rowIdAttr: 'prUrl',
|
|
1798
|
+
actions: [
|
|
1799
|
+
{ label: 'Open', className: 'btn-bulk-open', handler: handleBulkOpen },
|
|
1800
|
+
{ label: 'Analyze', className: 'btn-bulk-analyze', handler: handleBulkAnalyze }
|
|
1801
|
+
]
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1613
1804
|
var myPrsSelection = new SelectionMode({
|
|
1614
1805
|
tabId: 'my-prs-tab',
|
|
1615
1806
|
containerId: 'my-prs-container',
|
|
@@ -1621,6 +1812,13 @@
|
|
|
1621
1812
|
]
|
|
1622
1813
|
});
|
|
1623
1814
|
|
|
1815
|
+
/** Map of collection name → its SelectionMode instance. */
|
|
1816
|
+
var collectionSelections = {
|
|
1817
|
+
'review-requests': reviewRequestsSelection,
|
|
1818
|
+
'team-reviews': teamReviewsSelection,
|
|
1819
|
+
'my-prs': myPrsSelection
|
|
1820
|
+
};
|
|
1821
|
+
|
|
1624
1822
|
async function handleBulkDeletePR(selectedIds, selectionInstance) {
|
|
1625
1823
|
var count = selectedIds.size;
|
|
1626
1824
|
selectionInstance.showConfirm('Delete ' + count + ' review' + (count === 1 ? '' : 's') + '?', async function () {
|
|
@@ -1762,6 +1960,19 @@
|
|
|
1762
1960
|
return;
|
|
1763
1961
|
}
|
|
1764
1962
|
|
|
1963
|
+
var refreshTeamReviewsBtn = event.target.closest('#refresh-team-reviews');
|
|
1964
|
+
if (refreshTeamReviewsBtn) {
|
|
1965
|
+
event.preventDefault();
|
|
1966
|
+
// Resync the controls to the applied filter before refreshing: unapplied
|
|
1967
|
+
// draft text in the input must not advertise a team different from the one
|
|
1968
|
+
// we actually fetch (which keys off the persisted value).
|
|
1969
|
+
var teamReviewsRefreshFilter = getStoredTeamFilter();
|
|
1970
|
+
syncTeamFilterControls(teamReviewsRefreshFilter);
|
|
1971
|
+
var teamReviewsRefreshId = beginTeamReviewsRequest();
|
|
1972
|
+
refreshCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, teamReviewsRefreshFilter, teamReviewsRefreshId);
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1765
1976
|
var refreshMyPrsBtn = event.target.closest('#refresh-my-prs');
|
|
1766
1977
|
if (refreshMyPrsBtn) {
|
|
1767
1978
|
event.preventDefault();
|
|
@@ -1774,7 +1985,7 @@
|
|
|
1774
1985
|
if (selectToggle) {
|
|
1775
1986
|
event.preventDefault();
|
|
1776
1987
|
var tabId = selectToggle.dataset.selectionTab;
|
|
1777
|
-
var instances = { 'pr-tab': prSelection, 'local-tab': localSelection, 'review-requests-tab': reviewRequestsSelection, 'my-prs-tab': myPrsSelection };
|
|
1988
|
+
var instances = { 'pr-tab': prSelection, 'local-tab': localSelection, 'review-requests-tab': reviewRequestsSelection, 'team-reviews-tab': teamReviewsSelection, 'my-prs-tab': myPrsSelection };
|
|
1778
1989
|
var instance = instances[tabId];
|
|
1779
1990
|
if (instance) instance.toggle();
|
|
1780
1991
|
return;
|
|
@@ -1851,6 +2062,18 @@
|
|
|
1851
2062
|
}
|
|
1852
2063
|
refreshCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
|
|
1853
2064
|
}
|
|
2065
|
+
if (tabId === 'team-reviews-tab') {
|
|
2066
|
+
var teamFilter = getStoredTeamFilter();
|
|
2067
|
+
// Re-entering the tab preserves any unapplied draft text in the input
|
|
2068
|
+
// (switchTab does not rebuild the DOM). Resync the controls to the
|
|
2069
|
+
// applied filter so the visible team matches the one we reload.
|
|
2070
|
+
syncTeamFilterControls(teamFilter);
|
|
2071
|
+
var teamReviewsTabRequestId = beginTeamReviewsRequest();
|
|
2072
|
+
if (!teamReviewsState.loaded) {
|
|
2073
|
+
await loadCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, teamFilter, teamReviewsTabRequestId);
|
|
2074
|
+
}
|
|
2075
|
+
refreshCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, teamFilter, teamReviewsTabRequestId);
|
|
2076
|
+
}
|
|
1854
2077
|
if (tabId === 'my-prs-tab') {
|
|
1855
2078
|
if (!myPrsState.loaded) {
|
|
1856
2079
|
await loadCollectionPrs('my-prs', 'my-prs-container', myPrsState);
|
|
@@ -1894,6 +2117,12 @@
|
|
|
1894
2117
|
loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState)
|
|
1895
2118
|
.then(function () { refreshCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState); });
|
|
1896
2119
|
}
|
|
2120
|
+
if (savedTab === 'team-reviews-tab') {
|
|
2121
|
+
var initialTeamFilter = getStoredTeamFilter();
|
|
2122
|
+
var initialTeamRequestId = beginTeamReviewsRequest();
|
|
2123
|
+
loadCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, initialTeamFilter, initialTeamRequestId)
|
|
2124
|
+
.then(function () { refreshCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, initialTeamFilter, initialTeamRequestId); });
|
|
2125
|
+
}
|
|
1897
2126
|
if (savedTab === 'my-prs-tab') {
|
|
1898
2127
|
loadCollectionPrs('my-prs', 'my-prs-container', myPrsState)
|
|
1899
2128
|
.then(function () { refreshCollectionPrs('my-prs', 'my-prs-container', myPrsState); });
|
|
@@ -1922,6 +2151,37 @@
|
|
|
1922
2151
|
browseBtn.addEventListener('click', handleBrowseLocal);
|
|
1923
2152
|
}
|
|
1924
2153
|
|
|
2154
|
+
// Set up Team Review Requests team-filter handlers and restore the
|
|
2155
|
+
// persisted value.
|
|
2156
|
+
const teamFilterInput = document.getElementById('team-reviews-team-input');
|
|
2157
|
+
const teamFilterForm = document.getElementById('team-reviews-filter');
|
|
2158
|
+
const teamFilterClearBtn = document.getElementById('team-reviews-filter-clear');
|
|
2159
|
+
if (teamFilterInput) {
|
|
2160
|
+
// Restore the persisted filter into the controls on initial load (same
|
|
2161
|
+
// resync the reload paths use, so the displayed team always matches the
|
|
2162
|
+
// applied filter).
|
|
2163
|
+
syncTeamFilterControls(getStoredTeamFilter());
|
|
2164
|
+
// Clear the invalid state / inline hint as the user edits.
|
|
2165
|
+
teamFilterInput.addEventListener('input', function () {
|
|
2166
|
+
teamFilterInput.classList.remove('invalid');
|
|
2167
|
+
teamFilterInput.removeAttribute('aria-invalid');
|
|
2168
|
+
const hint = document.getElementById('team-reviews-filter-hint');
|
|
2169
|
+
if (hint) hint.hidden = true;
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
if (teamFilterForm) {
|
|
2173
|
+
teamFilterForm.addEventListener('submit', function (event) {
|
|
2174
|
+
event.preventDefault();
|
|
2175
|
+
applyTeamFilter();
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
if (teamFilterClearBtn) {
|
|
2179
|
+
teamFilterClearBtn.addEventListener('click', function () {
|
|
2180
|
+
if (teamFilterInput) teamFilterInput.value = '';
|
|
2181
|
+
applyTeamFilter();
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
|
|
1925
2185
|
// Set up PR Analyze button handler
|
|
1926
2186
|
const analyzeReviewBtn = document.getElementById('analyze-review-btn');
|
|
1927
2187
|
if (analyzeReviewBtn) {
|
|
@@ -1985,6 +2245,19 @@
|
|
|
1985
2245
|
rrHeader.insertBefore(rrBtn, rrHeader.firstChild);
|
|
1986
2246
|
}
|
|
1987
2247
|
|
|
2248
|
+
// Team Reviews tab: add to existing header. Insert before the fetched-at
|
|
2249
|
+
// label rather than as firstChild — the leading `.team-filter-group` carries
|
|
2250
|
+
// `margin-right: auto`, so a firstChild button would be shoved to the far
|
|
2251
|
+
// left. Placing it here keeps Select grouped with fetched-at + refresh on
|
|
2252
|
+
// the right, matching the other collection tabs.
|
|
2253
|
+
var trHeader = document.querySelector('#team-reviews-tab .tab-pane-header');
|
|
2254
|
+
if (trHeader) {
|
|
2255
|
+
var trBtn = createSelectButton('team-reviews-tab');
|
|
2256
|
+
teamReviewsSelection._toggleBtn = trBtn;
|
|
2257
|
+
var trFetchedAt = document.getElementById('team-reviews-fetched-at');
|
|
2258
|
+
trHeader.insertBefore(trBtn, trFetchedAt || trHeader.firstChild);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
1988
2261
|
// My PRs tab: add to existing header
|
|
1989
2262
|
var mpHeader = document.querySelector('#my-prs-tab .tab-pane-header');
|
|
1990
2263
|
if (mpHeader) {
|
|
@@ -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;
|