@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.5.1",
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
- "scripts": {
74
- "start": "node src/server.js",
75
- "dev": "node bin/pair-review.js",
76
- "test": "vitest run",
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.1",
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.1",
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",
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">
@@ -303,7 +303,21 @@
303
303
  loaded: false
304
304
  };
305
305
 
306
- // ─── GitHub PR Collections (My Review Requests / My PRs) ───────────────────
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 === 'review-requests' ? reviewRequestsSelection : myPrsSelection;
373
- sel.exit();
460
+ var sel = collectionSelections[collection];
461
+ if (sel) sel.exit();
374
462
 
375
- var fetchedAtId = collection === 'review-requests' ? 'review-requests-fetched-at' : 'my-prs-fetched-at';
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 emptyMsg = collection === 'review-requests'
387
- ? 'No pull requests awaiting your review.'
388
- : 'You have no open pull requests.';
389
-
390
- if (!state.fetchedAt) {
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 === 'review-requests' ? 'review-requests-tbody' : 'my-prs-tbody';
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 response = await fetch('/api/github/' + collection);
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 btnId = collection === 'review-requests' ? 'refresh-review-requests' : 'refresh-my-prs';
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 response = await fetch('/api/github/' + collection + '/refresh', { method: 'POST' });
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 === 'review-requests' ? 'review requests' : 'your pull requests') +
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
- * 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;