@loicngr/kobo 1.11.20 → 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 (89) hide show
  1. package/CHANGELOG.md +15 -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/settings.js +11 -0
  7. package/dist/server/routes/workspaces.js +98 -0
  8. package/dist/server/services/agent/engines/claude-code/engine.js +22 -5
  9. package/dist/server/services/forge/bitbucket/provider.js +86 -0
  10. package/dist/server/services/forge/github/provider.js +76 -0
  11. package/dist/server/services/forge/gitlab/provider.js +44 -0
  12. package/dist/server/services/forge/none.js +4 -0
  13. package/dist/server/services/pr-checkout-service.js +380 -0
  14. package/dist/server/services/workspace-service.js +4 -3
  15. package/dist/server/services/worktree-service.js +5 -5
  16. package/package.json +3 -3
  17. package/src/client/dist/pwa/assets/ActivityFeed-BNyFzRkx.js +8 -0
  18. package/src/client/dist/pwa/assets/{ActivityFeed-DccO7DTb.css → ActivityFeed-DUjY6lCm.css} +1 -1
  19. package/src/client/dist/pwa/assets/{ChangelogPage-DQ8OO52f.js → ChangelogPage-7pgilQJn.js} +1 -1
  20. package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +1 -0
  21. package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +1 -0
  22. package/src/client/dist/pwa/assets/CreatePage-P4N9bro4.js +2 -0
  23. package/src/client/dist/pwa/assets/{DiffViewer-BvauBOQx.js → DiffViewer-Cl2w9liE.js} +4 -4
  24. package/src/client/dist/pwa/assets/{DocumentsPanel-J3O58nlG.js → DocumentsPanel-CARiAfZs.js} +1 -1
  25. package/src/client/dist/pwa/assets/GitPanel-txKWaxSr.js +3 -0
  26. package/src/client/dist/pwa/assets/HealthPage-B0_v81_H.js +1 -0
  27. package/src/client/dist/pwa/assets/MainLayout-Cl26t1Yw.js +2 -0
  28. package/src/client/dist/pwa/assets/QBtnDropdown-9dpk4IiI.js +1 -0
  29. package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +1 -0
  30. package/src/client/dist/pwa/assets/{SchedulePanel-djl5_gLX.js → SchedulePanel-CSJbQpWB.js} +1 -1
  31. package/src/client/dist/pwa/assets/{SearchPage-BDn_SyYF.js → SearchPage-B5Wndh73.js} +1 -1
  32. package/src/client/dist/pwa/assets/SettingsPage-BmSaJrE5.js +16 -0
  33. package/src/client/dist/pwa/assets/{SettingsPage-dMAg4JP8.css → SettingsPage-CDZhPV6A.css} +1 -1
  34. package/src/client/dist/pwa/assets/{TerminalPanel-CNe14CjM.js → TerminalPanel-CuKWlENm.js} +1 -1
  35. package/src/client/dist/pwa/assets/TouchPan-CflYDvzb.js +1 -0
  36. package/src/client/dist/pwa/assets/WorkspacePage-C8SAYVkt.js +4 -0
  37. package/src/client/dist/pwa/assets/WorkspacePage-xKaXyg_O.css +1 -0
  38. package/src/client/dist/pwa/assets/{build-path-tree-DFok1z0T.js → build-path-tree-nS5a0hfa.js} +1 -1
  39. package/src/client/dist/pwa/assets/{cssMode-BvUdbm-I.js → cssMode-BWoHpG_3.js} +1 -1
  40. package/src/client/dist/pwa/assets/{editor.api-BvICAep3.js → editor.api-CHHKMKIl.js} +1 -1
  41. package/src/client/dist/pwa/assets/efforts-C0gGtu_O.js +9 -0
  42. package/src/client/dist/pwa/assets/engineFeatures-Cm3wk7lo.js +1 -0
  43. package/src/client/dist/pwa/assets/expand-template-CRv7R1En.js +1 -0
  44. package/src/client/dist/pwa/assets/{freemarker2-vMz0R4BI.js → freemarker2-ChmoNkJH.js} +1 -1
  45. package/src/client/dist/pwa/assets/{handlebars-zmdpS7uO.js → handlebars-DjdwH2Vr.js} +1 -1
  46. package/src/client/dist/pwa/assets/{html-D9TWGmZz.js → html-DJszTgCl.js} +1 -1
  47. package/src/client/dist/pwa/assets/{htmlMode-CWLZjLLJ.js → htmlMode-D0QTtBWO.js} +1 -1
  48. package/src/client/dist/pwa/assets/i18n-BQFPc-Bc.js +1 -0
  49. package/src/client/dist/pwa/assets/{index-CprWQVsP.css → index-BpmxQlZi.css} +1 -1
  50. package/src/client/dist/pwa/assets/{index-Djqr-F_S.js → index-okqx5CVc.js} +7 -7
  51. package/src/client/dist/pwa/assets/{javascript-D0SGBD_r.js → javascript-ByhJGDgS.js} +1 -1
  52. package/src/client/dist/pwa/assets/{jsonMode-Dz5fXETW.js → jsonMode-Db1oPA-y.js} +1 -1
  53. package/src/client/dist/pwa/assets/{liquid-DROderzc.js → liquid-CGMily4q.js} +1 -1
  54. package/src/client/dist/pwa/assets/{lspLanguageFeatures-C_VifNi2.js → lspLanguageFeatures-B85CeJTe.js} +1 -1
  55. package/src/client/dist/pwa/assets/{mdx-Dp3MajEK.js → mdx-B5_UPmNo.js} +1 -1
  56. package/src/client/dist/pwa/assets/{network-auth-DIjlo9g6.js → network-auth-D9vq0xDI.js} +1 -1
  57. package/src/client/dist/pwa/assets/{notify-theme-_12WX0jW.js → notify-theme-kRr6YTnZ.js} +1 -1
  58. package/src/client/dist/pwa/assets/{pending-diff-open-CkkxdAgL.js → pending-diff-open-nLdNq-y-.js} +1 -1
  59. package/src/client/dist/pwa/assets/{permissionModes-B35Bl8_m.js → permissionModes-Du6lXebH.js} +3 -3
  60. package/src/client/dist/pwa/assets/{project-color-ajxFmK6B.js → project-color-tT7gsmB5.js} +1 -1
  61. package/src/client/dist/pwa/assets/{python-fI2DruWC.js → python-FzBHx7LG.js} +1 -1
  62. package/src/client/dist/pwa/assets/{razor-C6Z6sx25.js → razor-BU_JvIXc.js} +1 -1
  63. package/src/client/dist/pwa/assets/{render-chat-markdown-BxVWz3Uu.js → render-chat-markdown-DrGdVCku.js} +1 -1
  64. package/src/client/dist/pwa/assets/{tsMode-C1G2aMN3.js → tsMode-BDW6Cb6M.js} +1 -1
  65. package/src/client/dist/pwa/assets/{typescript-DB5TziUY.js → typescript-21K3hWN-.js} +1 -1
  66. package/src/client/dist/pwa/assets/{use-onboarding-B3SV8Z98.js → use-onboarding-BCM8AGRg.js} +1 -1
  67. package/src/client/dist/pwa/assets/{vs-dbLna43M.js → vs-Ch3CDiTS.js} +3 -3
  68. package/src/client/dist/pwa/assets/{workers-CvmGLkp5.js → workers-CbIvH33O.js} +1 -1
  69. package/src/client/dist/pwa/assets/{xml-DF73XjiE.js → xml-d-kArTuN.js} +1 -1
  70. package/src/client/dist/pwa/assets/{yaml-B3gN4ZVV.js → yaml-tzAPZ-1Q.js} +1 -1
  71. package/src/client/dist/pwa/index.html +2 -2
  72. package/src/client/dist/pwa/sw.js +1 -1
  73. package/src/client/dist/pwa/assets/ActivityFeed-BRJyKyYS.js +0 -8
  74. package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +0 -1
  75. package/src/client/dist/pwa/assets/CreatePage-DNBh8EKm.js +0 -2
  76. package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +0 -1
  77. package/src/client/dist/pwa/assets/GitPanel-DxiE3C9g.js +0 -3
  78. package/src/client/dist/pwa/assets/HealthPage-t95_NSgL.js +0 -1
  79. package/src/client/dist/pwa/assets/MainLayout-CUT4A1gR.js +0 -2
  80. package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +0 -1
  81. package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +0 -1
  82. package/src/client/dist/pwa/assets/SettingsPage-CjhmWrTd.js +0 -16
  83. package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +0 -1
  84. package/src/client/dist/pwa/assets/WorkspacePage-BKgqjufK.css +0 -1
  85. package/src/client/dist/pwa/assets/WorkspacePage-CIdCkKx7.js +0 -4
  86. package/src/client/dist/pwa/assets/efforts-CQkQbD_d.js +0 -9
  87. package/src/client/dist/pwa/assets/engineFeatures-B7bviOhV.js +0 -1
  88. package/src/client/dist/pwa/assets/expand-template-jmdZsKZM.js +0 -1
  89. package/src/client/dist/pwa/assets/i18n-DVcs7ySS.js +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ 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
+
16
+ ## 1.11.21
17
+
18
+ - fix: refine session liveness and interface feedback
19
+ - fix(client): restore outline accent
20
+ - feat(settings): compose custom skill prompts
21
+
7
22
  ## 1.11.20
8
23
 
9
24
  - fix(client): clarify automatic recovery
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import { Hono } from 'hono';
2
+ import { isValidSkillSuite } from '../../shared/skill-suite-prompts.js';
2
3
  import { getDb } from '../db/index.js';
3
4
  import { getBackendPort } from '../services/agent/orchestrator.js';
4
5
  import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, } from '../services/initial-prompt-template-service.js';
@@ -7,6 +8,7 @@ import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from '../services/review-template-serv
7
8
  import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT } from '../services/settings-defaults.js';
8
9
  import * as settingsService from '../services/settings-service.js';
9
10
  import { DEFAULT_CI_FIX_PROMPT_TEMPLATE, DEFAULT_FINALIZATION_PROMPT, DEFAULT_GIT_CONVENTIONS, DEFAULT_PR_PROMPT_TEMPLATE, } from '../services/settings-service.js';
11
+ import { getSuitePrompts } from '../services/skill-suite-prompts.js';
10
12
  import { listTemplates, replaceAllTemplates } from '../services/templates-service.js';
11
13
  import { countPrunableWsEvents } from '../services/ws-events-retention-service.js';
12
14
  /** Hono sub-router for global and per-project settings CRUD. */
@@ -33,6 +35,15 @@ app.get('/global', (c) => {
33
35
  return c.json({ error: message }, 500);
34
36
  }
35
37
  });
38
+ // GET /api/settings/skill-suite-prompts/:suite — source presets that can be
39
+ // copied field-by-field into a custom suite before the user edits them.
40
+ app.get('/skill-suite-prompts/:suite', (c) => {
41
+ const suite = c.req.param('suite');
42
+ if (!isValidSkillSuite(suite)) {
43
+ return c.json({ error: `Unknown skill suite: '${suite}'` }, 400);
44
+ }
45
+ return c.json(getSuitePrompts(suite, {}));
46
+ });
36
47
  // GET /api/settings/defaults — expose the in-code DEFAULT_* constants for
37
48
  // global text-template settings (PR / review / git conventions / Notion /
38
49
  // Sentry initial prompts) so the UI can offer a "reset to default" button
@@ -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;
@@ -259,6 +259,7 @@ export function createClaudeCodeEngine() {
259
259
  // Guard so the post-result drain watchdog and the natural loop exit (or
260
260
  // catch block) never both emit `session:ended` for the same run.
261
261
  let sessionEndedEmitted = false;
262
+ let isCompacting = false;
262
263
  const emitSessionEnded = (reason, exitCode) => {
263
264
  if (sessionEndedEmitted)
264
265
  return;
@@ -307,6 +308,12 @@ export function createClaudeCodeEngine() {
307
308
  }, RESULT_DRAIN_TIMEOUT_MS);
308
309
  resultDrainTimer.unref?.();
309
310
  };
311
+ const clearResultDrainWatchdog = () => {
312
+ if (!resultDrainTimer)
313
+ return;
314
+ clearTimeout(resultDrainTimer);
315
+ resultDrainTimer = undefined;
316
+ };
310
317
  // Safety net for the "waiting on a background subagent" branch below:
311
318
  // if `activeSubagentToolCallIds` never empties (a missed/unrecognised
312
319
  // terminal notification), the workspace would otherwise stay `executing`
@@ -347,6 +354,11 @@ export function createClaudeCodeEngine() {
347
354
  turnLiveness.start();
348
355
  try {
349
356
  for await (const msg of q) {
357
+ // This SDK message proves that any drain condition armed by a
358
+ // *previous* message is no longer current. A drain armed while
359
+ // processing this message remains active if the generator then
360
+ // goes silent.
361
+ clearResultDrainWatchdog();
350
362
  const events = mapSdkMessage(msg, mapperState);
351
363
  for (const ev of events) {
352
364
  if (ev.kind !== 'subagent:progress')
@@ -356,6 +368,14 @@ export function createClaudeCodeEngine() {
356
368
  else
357
369
  activeSubagentTaskIds.delete(ev.toolCallId);
358
370
  }
371
+ for (const ev of events) {
372
+ if (ev.kind === 'session:compacting')
373
+ isCompacting = ev.active;
374
+ // Older SDKs can emit only compact_boundary, without a trailing
375
+ // status update. This boundary marks compaction as complete.
376
+ else if (ev.kind === 'session:compacted')
377
+ isCompacting = false;
378
+ }
359
379
  // The stall watchdog is only armed while we're waiting purely on
360
380
  // background subagents (no further SDK turn expected unless one
361
381
  // reports done). React the moment the last one clears instead of
@@ -378,7 +398,7 @@ export function createClaudeCodeEngine() {
378
398
  // A background subagent or a pending permission card can stay
379
399
  // legitimately quiet for minutes; their own dedicated watchdogs
380
400
  // own those windows, so suspend the turn deadline meanwhile.
381
- if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0)
401
+ if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0 || isCompacting)
382
402
  turnLiveness.pause();
383
403
  else
384
404
  turnLiveness.resume();
@@ -434,10 +454,7 @@ export function createClaudeCodeEngine() {
434
454
  // The post-result drain watchdog (if armed) is moot once the
435
455
  // iterator has exited — clear it so a healthy run never triggers a
436
456
  // stray abort after it already ended.
437
- if (resultDrainTimer) {
438
- clearTimeout(resultDrainTimer);
439
- resultDrainTimer = undefined;
440
- }
457
+ clearResultDrainWatchdog();
441
458
  turnLiveness.stop();
442
459
  clearSubagentStallWatchdog();
443
460
  // Drain any callback still pending (SDK terminated while awaiting an
@@ -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
  };