@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.
- package/CHANGELOG.md +9 -0
- package/dist/server/db/migrations.js +13 -0
- package/dist/server/db/schema.js +1 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/pull-requests.js +112 -0
- package/dist/server/routes/workspaces.js +98 -0
- package/dist/server/services/forge/bitbucket/provider.js +86 -0
- package/dist/server/services/forge/github/provider.js +76 -0
- package/dist/server/services/forge/gitlab/provider.js +44 -0
- package/dist/server/services/forge/none.js +4 -0
- package/dist/server/services/pr-checkout-service.js +380 -0
- package/dist/server/services/workspace-service.js +4 -3
- package/dist/server/services/worktree-service.js +5 -5
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-DtsapcUb.js → ActivityFeed-BNyFzRkx.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-D5FtafDI.js → ChangelogPage-7pgilQJn.js} +1 -1
- package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +1 -0
- package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-P4N9bro4.js +2 -0
- package/src/client/dist/pwa/assets/{DiffViewer-B7CQC4kD.js → DiffViewer-Cl2w9liE.js} +4 -4
- package/src/client/dist/pwa/assets/{DocumentsPanel-BgIrc-i0.js → DocumentsPanel-CARiAfZs.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-txKWaxSr.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-B0_v81_H.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-Cl26t1Yw.js +2 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-COnv-9Kx.js → QBtnDropdown-9dpk4IiI.js} +1 -1
- package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +1 -0
- package/src/client/dist/pwa/assets/{SchedulePanel-CS__azUn.js → SchedulePanel-CSJbQpWB.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-CBQqJmmb.js → SearchPage-B5Wndh73.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-BmSaJrE5.js +16 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-lsPJEdE-.js → TerminalPanel-CuKWlENm.js} +1 -1
- package/src/client/dist/pwa/assets/TouchPan-CflYDvzb.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-C8SAYVkt.js +4 -0
- package/src/client/dist/pwa/assets/{build-path-tree-CmNRcZvN.js → build-path-tree-nS5a0hfa.js} +1 -1
- package/src/client/dist/pwa/assets/{cssMode-DuFZt6Ie.js → cssMode-BWoHpG_3.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-Dt5YHQkt.js → editor.api-CHHKMKIl.js} +1 -1
- package/src/client/dist/pwa/assets/efforts-C0gGtu_O.js +9 -0
- package/src/client/dist/pwa/assets/engineFeatures-Cm3wk7lo.js +1 -0
- package/src/client/dist/pwa/assets/expand-template-CRv7R1En.js +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-DQWkQIhe.js → freemarker2-ChmoNkJH.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-C8DkGF05.js → handlebars-DjdwH2Vr.js} +1 -1
- package/src/client/dist/pwa/assets/{html-DlhhK7Jm.js → html-DJszTgCl.js} +1 -1
- package/src/client/dist/pwa/assets/{htmlMode-BVmEqAhh.js → htmlMode-D0QTtBWO.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BQFPc-Bc.js +1 -0
- package/src/client/dist/pwa/assets/{index-B29y2aJO.js → index-okqx5CVc.js} +7 -7
- package/src/client/dist/pwa/assets/{javascript-C80MQqD5.js → javascript-ByhJGDgS.js} +1 -1
- package/src/client/dist/pwa/assets/{jsonMode-r9kfqNya.js → jsonMode-Db1oPA-y.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-DT_yR5El.js → liquid-CGMily4q.js} +1 -1
- package/src/client/dist/pwa/assets/{lspLanguageFeatures-CY9UVeSD.js → lspLanguageFeatures-B85CeJTe.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-C_PbX1dC.js → mdx-B5_UPmNo.js} +1 -1
- package/src/client/dist/pwa/assets/{network-auth-BP2CrR3z.js → network-auth-D9vq0xDI.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-DIVwJeyL.js → notify-theme-kRr6YTnZ.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-B9fNhQic.js → pending-diff-open-nLdNq-y-.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-Be3ZDmns.js → permissionModes-Du6lXebH.js} +3 -3
- package/src/client/dist/pwa/assets/{project-color-Bc8ih-HF.js → project-color-tT7gsmB5.js} +1 -1
- package/src/client/dist/pwa/assets/{python-8G6qNwxa.js → python-FzBHx7LG.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BazUxiHR.js → razor-BU_JvIXc.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-CI48Q5rA.js → render-chat-markdown-DrGdVCku.js} +1 -1
- package/src/client/dist/pwa/assets/{tsMode-BUJh8zAj.js → tsMode-BDW6Cb6M.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-C5pPIpSU.js → typescript-21K3hWN-.js} +1 -1
- package/src/client/dist/pwa/assets/{use-onboarding-BQCOq7nO.js → use-onboarding-BCM8AGRg.js} +1 -1
- package/src/client/dist/pwa/assets/{vs-QND9VumP.js → vs-Ch3CDiTS.js} +3 -3
- package/src/client/dist/pwa/assets/{workers-CXEAdpVt.js → workers-CbIvH33O.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-DNCFiQ9N.js → xml-d-kArTuN.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-Ck1iNNOE.js → yaml-tzAPZ-1Q.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BhcHSOqz.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +0 -1
- package/src/client/dist/pwa/assets/GitPanel-DHvRFdbB.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-YqOn5Ig0.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-aDNwgrrT.js +0 -2
- package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +0 -1
- package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-kx7aNmeH.js +0 -16
- package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-Cyoc2Mvs.js +0 -4
- package/src/client/dist/pwa/assets/efforts-C3gjASPy.js +0 -9
- package/src/client/dist/pwa/assets/engineFeatures-DkgYepOC.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-CLqglUAa.js +0 -1
- 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;
|
package/dist/server/db/schema.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -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
|
};
|