@loicngr/kobo 1.11.21 → 1.11.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/server/db/migrations.js +13 -0
  3. package/dist/server/db/schema.js +1 -0
  4. package/dist/server/index.js +2 -0
  5. package/dist/server/routes/pull-requests.js +112 -0
  6. package/dist/server/routes/workspaces.js +98 -0
  7. package/dist/server/services/forge/bitbucket/provider.js +86 -0
  8. package/dist/server/services/forge/github/provider.js +76 -0
  9. package/dist/server/services/forge/gitlab/provider.js +44 -0
  10. package/dist/server/services/forge/none.js +4 -0
  11. package/dist/server/services/pr-checkout-service.js +380 -0
  12. package/dist/server/services/workspace-service.js +4 -3
  13. package/dist/server/services/worktree-service.js +5 -5
  14. package/package.json +1 -1
  15. package/src/client/dist/pwa/assets/{ActivityFeed-DtsapcUb.js → ActivityFeed-BNyFzRkx.js} +1 -1
  16. package/src/client/dist/pwa/assets/{ChangelogPage-D5FtafDI.js → ChangelogPage-7pgilQJn.js} +1 -1
  17. package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +1 -0
  18. package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +1 -0
  19. package/src/client/dist/pwa/assets/CreatePage-P4N9bro4.js +2 -0
  20. package/src/client/dist/pwa/assets/{DiffViewer-B7CQC4kD.js → DiffViewer-Cl2w9liE.js} +4 -4
  21. package/src/client/dist/pwa/assets/{DocumentsPanel-BgIrc-i0.js → DocumentsPanel-CARiAfZs.js} +1 -1
  22. package/src/client/dist/pwa/assets/GitPanel-txKWaxSr.js +3 -0
  23. package/src/client/dist/pwa/assets/HealthPage-B0_v81_H.js +1 -0
  24. package/src/client/dist/pwa/assets/MainLayout-Cl26t1Yw.js +2 -0
  25. package/src/client/dist/pwa/assets/{QBtnDropdown-COnv-9Kx.js → QBtnDropdown-9dpk4IiI.js} +1 -1
  26. package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +1 -0
  27. package/src/client/dist/pwa/assets/{SchedulePanel-CS__azUn.js → SchedulePanel-CSJbQpWB.js} +1 -1
  28. package/src/client/dist/pwa/assets/{SearchPage-CBQqJmmb.js → SearchPage-B5Wndh73.js} +1 -1
  29. package/src/client/dist/pwa/assets/SettingsPage-BmSaJrE5.js +16 -0
  30. package/src/client/dist/pwa/assets/{TerminalPanel-lsPJEdE-.js → TerminalPanel-CuKWlENm.js} +1 -1
  31. package/src/client/dist/pwa/assets/TouchPan-CflYDvzb.js +1 -0
  32. package/src/client/dist/pwa/assets/WorkspacePage-C8SAYVkt.js +4 -0
  33. package/src/client/dist/pwa/assets/{build-path-tree-CmNRcZvN.js → build-path-tree-nS5a0hfa.js} +1 -1
  34. package/src/client/dist/pwa/assets/{cssMode-DuFZt6Ie.js → cssMode-BWoHpG_3.js} +1 -1
  35. package/src/client/dist/pwa/assets/{editor.api-Dt5YHQkt.js → editor.api-CHHKMKIl.js} +1 -1
  36. package/src/client/dist/pwa/assets/efforts-C0gGtu_O.js +9 -0
  37. package/src/client/dist/pwa/assets/engineFeatures-Cm3wk7lo.js +1 -0
  38. package/src/client/dist/pwa/assets/expand-template-CRv7R1En.js +1 -0
  39. package/src/client/dist/pwa/assets/{freemarker2-DQWkQIhe.js → freemarker2-ChmoNkJH.js} +1 -1
  40. package/src/client/dist/pwa/assets/{handlebars-C8DkGF05.js → handlebars-DjdwH2Vr.js} +1 -1
  41. package/src/client/dist/pwa/assets/{html-DlhhK7Jm.js → html-DJszTgCl.js} +1 -1
  42. package/src/client/dist/pwa/assets/{htmlMode-BVmEqAhh.js → htmlMode-D0QTtBWO.js} +1 -1
  43. package/src/client/dist/pwa/assets/i18n-BQFPc-Bc.js +1 -0
  44. package/src/client/dist/pwa/assets/{index-B29y2aJO.js → index-okqx5CVc.js} +7 -7
  45. package/src/client/dist/pwa/assets/{javascript-C80MQqD5.js → javascript-ByhJGDgS.js} +1 -1
  46. package/src/client/dist/pwa/assets/{jsonMode-r9kfqNya.js → jsonMode-Db1oPA-y.js} +1 -1
  47. package/src/client/dist/pwa/assets/{liquid-DT_yR5El.js → liquid-CGMily4q.js} +1 -1
  48. package/src/client/dist/pwa/assets/{lspLanguageFeatures-CY9UVeSD.js → lspLanguageFeatures-B85CeJTe.js} +1 -1
  49. package/src/client/dist/pwa/assets/{mdx-C_PbX1dC.js → mdx-B5_UPmNo.js} +1 -1
  50. package/src/client/dist/pwa/assets/{network-auth-BP2CrR3z.js → network-auth-D9vq0xDI.js} +1 -1
  51. package/src/client/dist/pwa/assets/{notify-theme-DIVwJeyL.js → notify-theme-kRr6YTnZ.js} +1 -1
  52. package/src/client/dist/pwa/assets/{pending-diff-open-B9fNhQic.js → pending-diff-open-nLdNq-y-.js} +1 -1
  53. package/src/client/dist/pwa/assets/{permissionModes-Be3ZDmns.js → permissionModes-Du6lXebH.js} +3 -3
  54. package/src/client/dist/pwa/assets/{project-color-Bc8ih-HF.js → project-color-tT7gsmB5.js} +1 -1
  55. package/src/client/dist/pwa/assets/{python-8G6qNwxa.js → python-FzBHx7LG.js} +1 -1
  56. package/src/client/dist/pwa/assets/{razor-BazUxiHR.js → razor-BU_JvIXc.js} +1 -1
  57. package/src/client/dist/pwa/assets/{render-chat-markdown-CI48Q5rA.js → render-chat-markdown-DrGdVCku.js} +1 -1
  58. package/src/client/dist/pwa/assets/{tsMode-BUJh8zAj.js → tsMode-BDW6Cb6M.js} +1 -1
  59. package/src/client/dist/pwa/assets/{typescript-C5pPIpSU.js → typescript-21K3hWN-.js} +1 -1
  60. package/src/client/dist/pwa/assets/{use-onboarding-BQCOq7nO.js → use-onboarding-BCM8AGRg.js} +1 -1
  61. package/src/client/dist/pwa/assets/{vs-QND9VumP.js → vs-Ch3CDiTS.js} +3 -3
  62. package/src/client/dist/pwa/assets/{workers-CXEAdpVt.js → workers-CbIvH33O.js} +1 -1
  63. package/src/client/dist/pwa/assets/{xml-DNCFiQ9N.js → xml-d-kArTuN.js} +1 -1
  64. package/src/client/dist/pwa/assets/{yaml-Ck1iNNOE.js → yaml-tzAPZ-1Q.js} +1 -1
  65. package/src/client/dist/pwa/index.html +1 -1
  66. package/src/client/dist/pwa/sw.js +1 -1
  67. package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +0 -1
  68. package/src/client/dist/pwa/assets/CreatePage-BhcHSOqz.js +0 -2
  69. package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +0 -1
  70. package/src/client/dist/pwa/assets/GitPanel-DHvRFdbB.js +0 -3
  71. package/src/client/dist/pwa/assets/HealthPage-YqOn5Ig0.js +0 -1
  72. package/src/client/dist/pwa/assets/MainLayout-aDNwgrrT.js +0 -2
  73. package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +0 -1
  74. package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +0 -1
  75. package/src/client/dist/pwa/assets/SettingsPage-kx7aNmeH.js +0 -16
  76. package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +0 -1
  77. package/src/client/dist/pwa/assets/WorkspacePage-Cyoc2Mvs.js +0 -4
  78. package/src/client/dist/pwa/assets/efforts-C3gjASPy.js +0 -9
  79. package/src/client/dist/pwa/assets/engineFeatures-DkgYepOC.js +0 -1
  80. package/src/client/dist/pwa/assets/expand-template-CLqglUAa.js +0 -1
  81. package/src/client/dist/pwa/assets/i18n-C-chrpJN.js +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 1.11.23
8
+
9
+ - fix(test): configure git identity on the pr-checkout temp repo
10
+ - feat: create workspaces from an existing PR/MR
11
+
12
+ ## 1.11.22
13
+
14
+ - feat: create workspaces from an existing PR/MR
15
+
7
16
  ## 1.11.21
8
17
 
9
18
  - fix: refine session liveness and interface feedback
@@ -661,6 +661,19 @@ export const migrations = [
661
661
  }
662
662
  },
663
663
  },
664
+ {
665
+ version: 37,
666
+ name: 'add-workspace-pr-url',
667
+ migrate: (db) => {
668
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workspaces'").get();
669
+ if (!table)
670
+ return;
671
+ const columns = db.prepare('PRAGMA table_info(workspaces)').all().map((column) => column.name);
672
+ if (!columns.includes('pr_url')) {
673
+ db.exec('ALTER TABLE workspaces ADD COLUMN pr_url TEXT');
674
+ }
675
+ },
676
+ },
664
677
  ];
665
678
  /** Current schema version — always equals the highest migration version. */
666
679
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -11,6 +11,7 @@ export function initSchema(db) {
11
11
  notion_url TEXT,
12
12
  notion_page_id TEXT,
13
13
  sentry_url TEXT,
14
+ pr_url TEXT,
14
15
  worktree_path TEXT,
15
16
  worktree_owned INTEGER NOT NULL DEFAULT 1,
16
17
  model TEXT NOT NULL DEFAULT 'claude-opus-4-8',
@@ -18,6 +18,7 @@ import healthRouter from './routes/health.js';
18
18
  import imagesRouter from './routes/images.js';
19
19
  import { migrationRouter } from './routes/migration.js';
20
20
  import notionRouter from './routes/notion.js';
21
+ import pullRequestsRouter from './routes/pull-requests.js';
21
22
  import searchRouter from './routes/search.js';
22
23
  import sentryRouter from './routes/sentry.js';
23
24
  import settingsRouter from './routes/settings.js';
@@ -128,6 +129,7 @@ app.use('/api/*', networkAuthMiddleware);
128
129
  app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion() }));
129
130
  // Mount route sub-routers
130
131
  app.route('/api/workspaces', workspacesRouter);
132
+ app.route('/api/pull-requests', pullRequestsRouter);
131
133
  app.route('/api/workspaces', imagesRouter);
132
134
  app.route('/api/notion', notionRouter);
133
135
  app.route('/api/sentry', sentryRouter);
@@ -0,0 +1,112 @@
1
+ import { Hono } from 'hono';
2
+ import { getForgeProvider } from '../services/forge/registry.js';
3
+ import { resolveForge } from '../services/forge/resolve.js';
4
+ import * as prCheckout from '../services/pr-checkout-service.js';
5
+ import { listWorkspaces } from '../services/workspace-service.js';
6
+ const app = new Hono();
7
+ const FILTERS = ['all', 'mine', 'review-requested'];
8
+ /** Map worktree path -> workspace id, keeping the service free of DB access. */
9
+ function attachedWorktreePaths(workspaces) {
10
+ const map = new Map();
11
+ for (const w of workspaces ?? listWorkspaces(true)) {
12
+ if (w.worktreePath)
13
+ map.set(w.worktreePath, w.id);
14
+ }
15
+ return map;
16
+ }
17
+ app.get('/', async (c) => {
18
+ try {
19
+ const projectPath = c.req.query('projectPath');
20
+ if (!projectPath)
21
+ return c.json({ error: 'Missing required field: projectPath' }, 400);
22
+ const provider = getForgeProvider(resolveForge(projectPath));
23
+ if (!provider.capabilities.canListPullRequests) {
24
+ return c.json({ error: 'This project has no forge that can list pull requests' }, 403);
25
+ }
26
+ const rawFilter = c.req.query('filter');
27
+ const page = await provider.listPullRequests(projectPath, {
28
+ filter: rawFilter && FILTERS.includes(rawFilter) ? rawFilter : 'all',
29
+ search: c.req.query('search') || undefined,
30
+ cursor: c.req.query('cursor') || null,
31
+ perPage: Number(c.req.query('perPage') ?? 25),
32
+ });
33
+ return c.json(page);
34
+ }
35
+ catch (err) {
36
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
37
+ }
38
+ });
39
+ app.post('/diagnose', async (c) => {
40
+ try {
41
+ const { projectPath, prNumber, worktreesPath } = await c.req.json();
42
+ if (!projectPath || !prNumber) {
43
+ return c.json({ error: 'Missing required fields: projectPath, prNumber' }, 400);
44
+ }
45
+ const provider = getForgeProvider(resolveForge(projectPath));
46
+ const availability = await provider.isAvailable(projectPath);
47
+ if (!availability.available) {
48
+ // The forge can't be reached, so we have no way to resolve the PR's
49
+ // head branch. Return a minimal report early instead of diagnosing a
50
+ // fake empty branch name, which would produce a nonsensical report.
51
+ const report = {
52
+ projectPath,
53
+ headBranch: '',
54
+ targetWorktreePath: '',
55
+ blockers: [{ kind: 'forge-unavailable', reason: availability.reason ?? 'cli_missing' }],
56
+ workspace: { state: 'none' },
57
+ worktree: { state: 'none' },
58
+ localChanges: { present: false, modified: 0, staged: 0, untracked: 0 },
59
+ ongoingOperation: null,
60
+ branch: { state: 'absent' },
61
+ };
62
+ return c.json({ report, pr: null, fingerprint: prCheckout.computeFingerprint(report) });
63
+ }
64
+ // Page through the full list — a PR the user explicitly diagnosed by
65
+ // number must never come back "not found" just for sitting past the
66
+ // first 100 (by recency) results.
67
+ let pr;
68
+ let cursor = null;
69
+ do {
70
+ const page = await provider.listPullRequests(projectPath, { filter: 'all', perPage: 100, cursor });
71
+ pr = page.items.find((item) => item.number === prNumber);
72
+ cursor = page.nextCursor;
73
+ } while (!pr && cursor !== null);
74
+ if (!pr)
75
+ return c.json({ error: `Pull request #${prNumber} not found` }, 404);
76
+ const workspaces = listWorkspaces(true);
77
+ const report = prCheckout.diagnoseLocalState(projectPath, pr.headBranch, worktreesPath ?? null, attachedWorktreePaths(workspaces), workspaces);
78
+ if (pr.isFork)
79
+ report.blockers.push({ kind: 'fork-pr' });
80
+ return c.json({ report, pr, fingerprint: prCheckout.computeFingerprint(report) });
81
+ }
82
+ catch (err) {
83
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
84
+ }
85
+ });
86
+ app.post('/resolve', async (c) => {
87
+ try {
88
+ const body = await c.req.json();
89
+ if (!body.projectPath || !body.fingerprint) {
90
+ return c.json({ error: 'Missing required fields: projectPath, fingerprint' }, 400);
91
+ }
92
+ const workspaces = listWorkspaces(true);
93
+ const result = await prCheckout.resolvePrCheckout({
94
+ projectPath: body.projectPath,
95
+ headBranch: body.headBranch,
96
+ baseBranch: body.baseBranch,
97
+ worktreesPath: body.worktreesPath ?? null,
98
+ decisions: body.decisions ?? {},
99
+ fingerprint: body.fingerprint,
100
+ workspaces,
101
+ attachedPaths: attachedWorktreePaths(workspaces),
102
+ });
103
+ return c.json(result);
104
+ }
105
+ catch (err) {
106
+ if (err instanceof prCheckout.StaleDiagnosisError) {
107
+ return c.json({ error: err.message, report: err.report }, 409);
108
+ }
109
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 422);
110
+ }
111
+ });
112
+ export default app;
@@ -61,6 +61,7 @@ export const CREATE_WORKSPACE_STEPS = [
61
61
  'inspect-worktree',
62
62
  'extract-notion',
63
63
  'extract-sentry',
64
+ 'extract-pr',
64
65
  'create-record',
65
66
  'create-tasks',
66
67
  'create-worktree',
@@ -210,6 +211,26 @@ function resolveCreateAgentPermissionMode(bodyValue, _projectPath, globalSetting
210
211
  return global;
211
212
  return 'bypass';
212
213
  }
214
+ /**
215
+ * Extract the numeric PR/MR number from a forge URL.
216
+ *
217
+ * Strips any query string or fragment first (a comment-anchor link like
218
+ * `.../merge_requests/42#note_123` would otherwise have its trailing
219
+ * fragment digits greedily matched instead of the actual PR number), then
220
+ * takes the last path segment and requires it to be purely numeric. Throws
221
+ * a clear error rather than silently defaulting to 0 when the URL doesn't
222
+ * end on a plain PR/MR number (e.g. a "Files changed" sub-tab URL).
223
+ */
224
+ function parsePrNumberFromUrl(prUrl) {
225
+ const withoutQueryOrFragment = prUrl.split(/[?#]/)[0];
226
+ const segments = withoutQueryOrFragment.replace(/\/+$/, '').split('/');
227
+ const last = segments[segments.length - 1];
228
+ const number = Number(last);
229
+ if (!last || !Number.isInteger(number) || number <= 0) {
230
+ throw new Error(`Could not parse a pull request number from '${prUrl}'`);
231
+ }
232
+ return number;
233
+ }
213
234
  app.get('/', (c) => {
214
235
  try {
215
236
  const workspaces = workspaceService.listWorkspaces();
@@ -491,6 +512,44 @@ app.post('/', migrationGuard, async (c) => {
491
512
  .catch((err) => console.error('[sentry] Auto-assign threw unexpectedly:', err));
492
513
  }
493
514
  }
515
+ // Pre-flight: extract the PR/MR before any DB write, mirroring the
516
+ // Notion / Sentry extraction above. A throw here must not leave a
517
+ // half-built workspace behind, so we run extraction before
518
+ // createWorkspace and surface failures as 422.
519
+ let prContent = null;
520
+ // Captured here so the write-context-files block below can reuse it
521
+ // instead of calling resolveForge/getForgeProvider (which shells out to
522
+ // git when the project's forge setting is 'auto') a second time.
523
+ let prForgeRequestTermShort;
524
+ if (body.prUrl) {
525
+ currentStep = 'extract-pr';
526
+ emitCreateProgress(creationId, currentStep);
527
+ try {
528
+ const provider = getForgeProvider(resolveForge(body.projectPath));
529
+ prForgeRequestTermShort = provider.capabilities.requestTermShort;
530
+ if (!provider.capabilities.canListPullRequests) {
531
+ throw new Error('This project has no forge that can list pull requests');
532
+ }
533
+ const number = parsePrNumberFromUrl(body.prUrl);
534
+ // Page through the full list rather than only the first 100 (by
535
+ // recency) — a PR the user explicitly picked by URL must never be
536
+ // reported "not found" just because it hasn't been touched recently.
537
+ let cursor = null;
538
+ do {
539
+ const page = await provider.listPullRequests(body.projectPath, { filter: 'all', perPage: 100, cursor });
540
+ prContent = page.items.find((item) => item.number === number) ?? null;
541
+ cursor = page.nextCursor;
542
+ } while (!prContent && cursor !== null);
543
+ if (!prContent)
544
+ throw new Error(`Pull request #${number} not found`);
545
+ }
546
+ catch (err) {
547
+ const message = err instanceof Error ? err.message : String(err);
548
+ const failure = `Failed to read the pull request: ${message}`;
549
+ emitCreateFailed(creationId, 'extract-pr', failure);
550
+ return c.json({ error: failure, step: 'extract-pr' }, 422);
551
+ }
552
+ }
494
553
  // Create workspace record
495
554
  const globalSettings = settingsService.getGlobalSettings();
496
555
  // workingBranch may be updated after Notion extraction to inject the ticket ID,
@@ -574,6 +633,7 @@ app.post('/', migrationGuard, async (c) => {
574
633
  notionUrl: body.notionUrl,
575
634
  notionPageId: body.notionPageId,
576
635
  sentryUrl: body.sentryUrl,
636
+ prUrl: body.prUrl,
577
637
  worktreePath: prospectiveWorktreePath,
578
638
  worktreeOwned: !useReusedWorktree,
579
639
  model: body.model,
@@ -656,6 +716,13 @@ app.post('/', migrationGuard, async (c) => {
656
716
  workspace = workspaceService.updateWorkspaceName(workspace.id, notionContent.title);
657
717
  }
658
718
  }
719
+ // Update workspace name with the PR/MR title if the user did not provide
720
+ // a custom name and neither Notion nor Sentry already claimed it. This
721
+ // makes the PR a THIRD context source, lowest priority of the three
722
+ // (Notion > Sentry > PR), consistent with the checks above.
723
+ if (prContent && !notionContent?.title && !sentryContent?.title && workspace.name === 'workspace') {
724
+ workspace = workspaceService.updateWorkspaceName(workspace.id, prContent.title);
725
+ }
659
726
  // Create manual tasks/criteria if no Notion content was extracted
660
727
  if (!notionContent && (Array.isArray(body.tasks) || Array.isArray(body.acceptanceCriteria))) {
661
728
  let sortOrder = 0;
@@ -879,6 +946,37 @@ app.post('/', migrationGuard, async (c) => {
879
946
  }
880
947
  }
881
948
  // ------------------------------------------------------------------------
949
+ // --- PR/MR file (extraction already done before worktree creation) -----
950
+ if (prContent && body.prUrl) {
951
+ try {
952
+ const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
953
+ fs.mkdirSync(thoughtsDir, { recursive: true });
954
+ const today = new Date().toISOString().split('T')[0];
955
+ const term = prForgeRequestTermShort ?? getForgeProvider(resolveForge(body.projectPath)).capabilities.requestTermShort;
956
+ let md = `# ${prContent.title}\n\n`;
957
+ md += `## Source\n\n`;
958
+ md += `- ${term === 'MR' ? 'Merge request' : 'Pull request'}: ${body.prUrl}\n`;
959
+ md += `- Author: ${prContent.author}\n`;
960
+ md += `- Branches: ${prContent.headBranch} → ${prContent.baseBranch}\n`;
961
+ md += `- Retrieved: ${today}\n\n`;
962
+ // Persist the user's initial instructions, exactly like the Notion
963
+ // and Sentry context writes above — the description field is never
964
+ // overwritten, only incorporated under this heading.
965
+ if (body.description?.trim()) {
966
+ md += `## User instructions\n\n${body.description.trim()}\n\n`;
967
+ }
968
+ fs.writeFileSync(path.join(thoughtsDir, `PR-${prContent.number}.md`), md, 'utf-8');
969
+ }
970
+ catch (err) {
971
+ // Best-effort, like the Notion and Sentry context writes: never break
972
+ // creation because a context file could not be written.
973
+ logError('workspaces', 'Failed to save PR content', {
974
+ workspaceId: workspace.id,
975
+ error: err instanceof Error ? err.message : String(err),
976
+ });
977
+ }
978
+ }
979
+ // ------------------------------------------------------------------------
882
980
  // Update Notion status if both property name and value are configured
883
981
  const notionStatusProp = effectiveSettings.notionStatusProperty;
884
982
  const notionTargetStatus = effectiveSettings.notionInProgressStatus;
@@ -102,6 +102,79 @@ function mapBktPr(raw, ci = { rollup: null, checks: [] }) {
102
102
  readyToMerge: deriveReadyToMerge({ state, ci, reviewDecision, reviewers, mergeable }),
103
103
  };
104
104
  }
105
+ /** Bitbucket Cloud's `next` URL always carries a `page` query parameter.
106
+ * Extract the bare page number so it matches Data Center's `nextPageStart`
107
+ * shape — both then feed the same `--page <n>` flag on the next call. */
108
+ function extractCloudPageCursor(next) {
109
+ try {
110
+ const page = new URL(next).searchParams.get('page');
111
+ if (page)
112
+ return page;
113
+ }
114
+ catch {
115
+ // fall through to regex fallback below
116
+ }
117
+ const match = next.match(/[?&]page=(\d+)/);
118
+ return match ? match[1] : next;
119
+ }
120
+ /** Map a Bitbucket Cloud or Data Center PR page. Exported for tests. */
121
+ export function mapBitbucketPage(raw) {
122
+ const payload = record(raw);
123
+ const rows = Array.isArray(payload.values) ? payload.values : [];
124
+ const items = rows.map((row) => {
125
+ const pr = record(row);
126
+ const sourceRepo = record(record(pr.source).repository);
127
+ const destRepo = record(record(pr.destination).repository);
128
+ const fromRepo = record(record(pr.fromRef).repository);
129
+ const toRepo = record(record(pr.toRef).repository);
130
+ const sourceId = string(sourceRepo.uuid) || String(fromRepo.id ?? '');
131
+ const destId = string(destRepo.uuid) || String(toRepo.id ?? '');
132
+ const updated = pr.updated_on ?? pr.updatedDate;
133
+ return {
134
+ number: number(pr.id),
135
+ title: string(pr.title),
136
+ url: url(pr),
137
+ author: login(pr.author),
138
+ headBranch: branch(pr, 'source', 'fromRef'),
139
+ baseBranch: branch(pr, 'destination', 'toRef'),
140
+ isFork: Boolean(sourceId) && Boolean(destId) && sourceId !== destId,
141
+ isDraft: pr.draft === true,
142
+ updatedAt: typeof updated === 'string' ? updated : typeof updated === 'number' ? new Date(updated).toISOString() : '',
143
+ // `bkt pr list` doesn't expose CI/review status in list output; avoiding a per-row lookup call here.
144
+ ci: null,
145
+ reviewDecision: null,
146
+ };
147
+ });
148
+ const next = string(payload.next);
149
+ if (next)
150
+ return { items, nextCursor: extractCloudPageCursor(next) };
151
+ if (payload.isLastPage === false && payload.nextPageStart != null) {
152
+ return { items, nextCursor: String(payload.nextPageStart) };
153
+ }
154
+ return { items, nextCursor: null };
155
+ }
156
+ /**
157
+ * Narrow a Bitbucket page client-side: `bkt` has no author/reviewer filter.
158
+ *
159
+ * `review-requested` is a KNOWN, DELIBERATE no-op here: `PullRequestSummary`
160
+ * (shared across all four forge providers) carries no reviewer information,
161
+ * and fetching it would mean one extra Bitbucket API call per row in the
162
+ * list — every other filter/search operation here works on data already in
163
+ * hand. Rather than guess at reviewer data this provider doesn't have,
164
+ * `review-requested` falls through to "no filtering" like `'all'`, same as
165
+ * before this comment was added; `mine`, unlike before, is now genuinely
166
+ * narrowed once `currentUser` is supplied by the caller.
167
+ */
168
+ export function filterBitbucketItems(items, opts, currentUser) {
169
+ const search = opts.search?.trim().toLowerCase();
170
+ return items.filter((item) => {
171
+ if (search && !item.title.toLowerCase().includes(search))
172
+ return false;
173
+ if (opts.filter === 'mine' && currentUser)
174
+ return item.author === currentUser;
175
+ return true;
176
+ });
177
+ }
105
178
  function availabilityFromError(err) {
106
179
  const code = err.code;
107
180
  if (code === 'ENOENT')
@@ -207,6 +280,7 @@ export const bitbucketProvider = {
207
280
  canChangePrBase: false,
208
281
  canMergeRequest: true,
209
282
  canDeleteRemoteBranch: true,
283
+ canListPullRequests: true,
210
284
  requestTermShort: 'PR',
211
285
  },
212
286
  async isAvailable(repoPath) {
@@ -283,4 +357,16 @@ export const bitbucketProvider = {
283
357
  async deleteRemoteBranch(repoPath, branchName) {
284
358
  await execFileAsync('git', ['push', 'origin', '--delete', branchName], bktOptions(repoPath));
285
359
  },
360
+ async listPullRequests(repoPath, opts) {
361
+ const target = await resolveTarget(repoPath);
362
+ const args = ['pr', 'list', '--limit', String(opts.perPage), ...targetArgs(target), '--json'];
363
+ if (opts.cursor)
364
+ args.push('--page', opts.cursor);
365
+ const { stdout } = await execFileAsync('bkt', bktArgs(target, args), bktOptions(repoPath));
366
+ const page = mapBitbucketPage(JSON.parse(stdout || '{}'));
367
+ if (opts.filter === 'all' && !opts.search?.trim())
368
+ return page;
369
+ const currentUser = getGlobalSettings().bitbucketUsername || undefined;
370
+ return { ...page, items: filterBitbucketItems(page.items, opts, currentUser) };
371
+ },
286
372
  };
@@ -83,6 +83,61 @@ function mapGhPrToSnapshot(raw) {
83
83
  readyToMerge: deriveReadyToMerge({ state, ci: { rollup, checks }, reviewDecision, reviewers, mergeable }),
84
84
  };
85
85
  }
86
+ function record(value) {
87
+ return value !== null && typeof value === 'object' ? value : {};
88
+ }
89
+ const GH_LIST_QUERY = `
90
+ query($q:String!,$first:Int!,$after:String){
91
+ search(query:$q, type:ISSUE, first:$first, after:$after){
92
+ pageInfo{ hasNextPage endCursor }
93
+ nodes{
94
+ ... on PullRequest {
95
+ number title url isDraft updatedAt
96
+ author{ login }
97
+ headRefName baseRefName isCrossRepository reviewDecision
98
+ commits(last:1){ nodes{ commit{ statusCheckRollup{ state } } } }
99
+ }
100
+ }
101
+ }
102
+ }`;
103
+ /** Build the `search` qualifier string. Exported for tests. */
104
+ export function buildGithubSearchQuery(nameWithOwner, opts) {
105
+ const parts = [`repo:${nameWithOwner}`, 'is:pr', 'is:open'];
106
+ if (opts.filter === 'mine')
107
+ parts.push('author:@me');
108
+ if (opts.filter === 'review-requested')
109
+ parts.push('review-requested:@me');
110
+ parts.push('sort:updated-desc');
111
+ const query = parts.join(' ');
112
+ const search = opts.search?.trim();
113
+ return search ? `${query} ${search}` : query;
114
+ }
115
+ /** Map a raw `gh api graphql` response into a page. Exported for tests. */
116
+ export function mapGithubSearchPage(raw) {
117
+ const search = record(record(record(raw).data).search);
118
+ const pageInfo = record(search.pageInfo);
119
+ const nodes = Array.isArray(search.nodes) ? search.nodes : [];
120
+ const items = nodes.map((node) => {
121
+ const pr = record(node);
122
+ const commitNodes = Array.isArray(record(pr.commits).nodes) ? record(pr.commits).nodes : [];
123
+ const rollup = record(record(record(commitNodes[0]).commit).statusCheckRollup).state;
124
+ const decision = pr.reviewDecision;
125
+ return {
126
+ number: typeof pr.number === 'number' ? pr.number : 0,
127
+ title: typeof pr.title === 'string' ? pr.title : '',
128
+ url: typeof pr.url === 'string' ? pr.url : '',
129
+ author: typeof record(pr.author).login === 'string' ? record(pr.author).login : '',
130
+ headBranch: typeof pr.headRefName === 'string' ? pr.headRefName : '',
131
+ baseBranch: typeof pr.baseRefName === 'string' ? pr.baseRefName : '',
132
+ isFork: pr.isCrossRepository === true,
133
+ isDraft: pr.isDraft === true,
134
+ updatedAt: typeof pr.updatedAt === 'string' ? pr.updatedAt : '',
135
+ ci: typeof rollup === 'string' ? rollup : null,
136
+ reviewDecision: typeof decision === 'string' ? decision : null,
137
+ };
138
+ });
139
+ return { items, nextCursor: pageInfo.hasNextPage === true ? String(pageInfo.endCursor ?? '') || null : null };
140
+ }
86
141
  /** Map an execFile rejection to a ForgeAvailability reason. */
87
142
  function availabilityFromError(err) {
88
143
  const code = err.code;
@@ -101,6 +156,7 @@ export const githubProvider = {
101
156
  canChangePrBase: true,
102
157
  canMergeRequest: true,
103
158
  canDeleteRemoteBranch: true,
159
+ canListPullRequests: true,
104
160
  requestTermShort: 'PR',
105
161
  },
106
162
  async isAvailable(repoPath) {
@@ -140,4 +196,24 @@ export const githubProvider = {
140
196
  async deleteRemoteBranch(repoPath, branch) {
141
197
  await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
142
198
  },
199
+ async listPullRequests(repoPath, opts) {
200
+ const { stdout: repoJson } = await execFileAsync('gh', ['repo', 'view', '--json', 'nameWithOwner'], cliOptions(repoPath));
201
+ const nameWithOwner = String(JSON.parse(repoJson).nameWithOwner ?? '');
202
+ if (!nameWithOwner)
203
+ throw new Error('Could not determine the GitHub repository for this path');
204
+ const args = [
205
+ 'api',
206
+ 'graphql',
207
+ '-f',
208
+ `query=${GH_LIST_QUERY}`,
209
+ '-f',
210
+ `q=${buildGithubSearchQuery(nameWithOwner, opts)}`,
211
+ '-F',
212
+ `first=${opts.perPage}`,
213
+ ];
214
+ if (opts.cursor)
215
+ args.push('-f', `after=${opts.cursor}`);
216
+ const { stdout } = await execFileAsync('gh', args, cliOptions(repoPath));
217
+ return mapGithubSearchPage(JSON.parse(stdout));
218
+ },
143
219
  };
@@ -118,6 +118,45 @@ async function fetchGlabCi(repoPath, branch) {
118
118
  return { rollup: null, checks: [] };
119
119
  }
120
120
  }
121
+ /** Build the `glab mr list` argv. Exported for tests. */
122
+ export function buildGitlabListArgs(opts) {
123
+ const page = opts.cursor && /^\d+$/.test(opts.cursor) ? opts.cursor : '1';
124
+ const args = ['mr', 'list', '--output', 'json', '--per-page', String(opts.perPage), '--page', page];
125
+ if (opts.filter === 'mine')
126
+ args.push('--author=@me');
127
+ if (opts.filter === 'review-requested')
128
+ args.push('--reviewer=@me');
129
+ const search = opts.search?.trim();
130
+ if (search)
131
+ args.push('--search', search);
132
+ return args;
133
+ }
134
+ /** Map a `glab mr list --output json` array into a page. Exported for tests. */
135
+ export function mapGitlabPage(raw, opts) {
136
+ const rows = Array.isArray(raw) ? raw : [];
137
+ const items = rows.map((row) => {
138
+ const mr = (row ?? {});
139
+ const author = (mr.author ?? {});
140
+ return {
141
+ number: typeof mr.iid === 'number' ? mr.iid : 0,
142
+ title: typeof mr.title === 'string' ? mr.title : '',
143
+ url: typeof mr.web_url === 'string' ? mr.web_url : '',
144
+ author: typeof author.username === 'string' ? author.username : '',
145
+ headBranch: typeof mr.source_branch === 'string' ? mr.source_branch : '',
146
+ baseBranch: typeof mr.target_branch === 'string' ? mr.target_branch : '',
147
+ isFork: mr.source_project_id !== mr.target_project_id,
148
+ isDraft: mr.draft === true || mr.work_in_progress === true,
149
+ updatedAt: typeof mr.updated_at === 'string' ? mr.updated_at : '',
150
+ // `glab mr list` carries no pipeline status; the picker shows no CI pill
151
+ // for GitLab rather than paying one `glab ci get` per row.
152
+ ci: null,
153
+ reviewDecision: null,
154
+ };
155
+ });
156
+ const page = Number(opts.cursor && /^\d+$/.test(opts.cursor) ? opts.cursor : '1');
157
+ const nextCursor = rows.length >= opts.perPage ? String(page + 1) : null;
158
+ return { items, nextCursor };
159
+ }
121
160
  function availabilityFromError(err) {
122
161
  const code = err.code;
123
162
  if (code === 'ENOENT')
@@ -135,6 +174,7 @@ export const gitlabProvider = {
135
174
  canChangePrBase: true,
136
175
  canMergeRequest: true,
137
176
  canDeleteRemoteBranch: true,
177
+ canListPullRequests: true,
138
178
  requestTermShort: 'MR',
139
179
  },
140
180
  async isAvailable(repoPath) {
@@ -189,4 +229,8 @@ export const gitlabProvider = {
189
229
  async deleteRemoteBranch(repoPath, branch) {
190
230
  await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
191
231
  },
232
+ async listPullRequests(repoPath, opts) {
233
+ const { stdout } = await execFileAsync('glab', buildGitlabListArgs(opts), cliOptions(repoPath));
234
+ return mapGitlabPage(JSON.parse(stdout || '[]'), opts);
235
+ },
192
236
  };
@@ -12,6 +12,7 @@ export const noneProvider = {
12
12
  canChangePrBase: false,
13
13
  canMergeRequest: false,
14
14
  canDeleteRemoteBranch: false,
15
+ canListPullRequests: false,
15
16
  requestTermShort: 'PR',
16
17
  },
17
18
  async isAvailable() {
@@ -32,4 +33,7 @@ export const noneProvider = {
32
33
  async deleteRemoteBranch() {
33
34
  throw new ForgeUnavailableError('This project has no supported forge configured');
34
35
  },
36
+ async listPullRequests() {
37
+ return { items: [], nextCursor: null };
38
+ },
35
39
  };