@loicngr/kobo 1.7.16 → 1.7.17

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 (85) hide show
  1. package/AGENTS.md +35 -4
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +2 -1
  4. package/dist/mcp-server/kobo-tasks-handlers.js +48 -5
  5. package/dist/mcp-server/kobo-tasks-server.js +8 -20
  6. package/dist/server/db/migrations.js +19 -0
  7. package/dist/server/db/schema.js +9 -0
  8. package/dist/server/routes/settings.js +2 -0
  9. package/dist/server/routes/workspaces.js +242 -56
  10. package/dist/server/services/agent/engines/claude-code/engine.js +51 -7
  11. package/dist/server/services/agent/engines/claude-code/options-builder.js +2 -2
  12. package/dist/server/services/agent/engines/codex/options-builder.js +2 -2
  13. package/dist/server/services/agent/orchestrator.js +1 -0
  14. package/dist/server/services/change-source-branch-service.js +150 -0
  15. package/dist/server/services/chat-history-service.js +41 -0
  16. package/dist/server/services/file-editor-service.js +59 -0
  17. package/dist/server/services/forge/github/provider.js +121 -0
  18. package/dist/server/services/forge/gitlab/provider.js +178 -0
  19. package/dist/server/services/forge/none.js +23 -0
  20. package/dist/server/services/forge/registry.js +17 -0
  21. package/dist/server/services/forge/resolve.js +34 -0
  22. package/dist/server/services/forge/types.js +9 -0
  23. package/dist/server/services/git-stats-service.js +32 -0
  24. package/dist/server/services/pr-watcher-service.js +33 -3
  25. package/dist/server/services/settings-defaults.js +77 -0
  26. package/dist/server/services/settings-service.js +34 -0
  27. package/dist/server/utils/git-ops.js +121 -134
  28. package/package.json +1 -1
  29. package/src/client/dist/spa/assets/ActivityFeed-CDhLuhI3.js +8 -0
  30. package/src/client/dist/spa/assets/{ClosePopup-CxvZA3ft.js → ClosePopup-A-tSm4aa.js} +1 -1
  31. package/src/client/dist/spa/assets/{CreatePage-CdZr7f3j.js → CreatePage-xIjxPliD.js} +1 -1
  32. package/src/client/dist/spa/assets/DiffViewer-C4L5y8Ho.css +1 -0
  33. package/src/client/dist/spa/assets/DiffViewer-CcgF65Mo.js +8 -0
  34. package/src/client/dist/spa/assets/{HealthPage-z1uIOpYk.js → HealthPage-Bw-9__wY.js} +1 -1
  35. package/src/client/dist/spa/assets/MainLayout-C2ULeep-.js +37 -0
  36. package/src/client/dist/spa/assets/{MainLayout-BJmBXwYn.css → MainLayout-KEr19FOv.css} +1 -1
  37. package/src/client/dist/spa/assets/{QExpansionItem-BTd5m2yV.js → QExpansionItem-CgJQdznK.js} +1 -1
  38. package/src/client/dist/spa/assets/{QMenu-C2Wwwf2E.js → QMenu-NVDU7D3u.js} +1 -1
  39. package/src/client/dist/spa/assets/{QScrollArea-A1wI0IXU.js → QScrollArea-_Ji1cgqL.js} +1 -1
  40. package/src/client/dist/spa/assets/{QTooltip-Bfdmzm_m.js → QTooltip-BC7PnZJ1.js} +1 -1
  41. package/src/client/dist/spa/assets/{SearchPage-ChmKHNKn.js → SearchPage-D2x2X7K7.js} +1 -1
  42. package/src/client/dist/spa/assets/SettingsPage-BLb9B9iY.js +9 -0
  43. package/src/client/dist/spa/assets/{SettingsPage-BJLyYrBN.css → SettingsPage-BTGPZaqC.css} +1 -1
  44. package/src/client/dist/spa/assets/{TouchPan-BIE5rs7U.js → TouchPan-D0fJnlOC.js} +1 -1
  45. package/src/client/dist/spa/assets/WorkspacePage-BlAVs03z.js +4 -0
  46. package/src/client/dist/spa/assets/{WorkspacePage-tFBswKV9.css → WorkspacePage-DTV0oWHS.css} +1 -1
  47. package/src/client/dist/spa/assets/{build-path-tree-BGUV3nY1.js → build-path-tree-CyqReJkk.js} +1 -1
  48. package/src/client/dist/spa/assets/{cssMode-BU4X8R6a.js → cssMode-BsT_HBz-.js} +1 -1
  49. package/src/client/dist/spa/assets/{editor.api-B4xBDzmJ.js → editor.api-CIxiApSC.js} +1 -1
  50. package/src/client/dist/spa/assets/{editor.main-CSZRkloL.js → editor.main-D-1-e3_n.js} +3 -3
  51. package/src/client/dist/spa/assets/{engineFeatures-CLOVr5b4.js → engineFeatures-baMvMT98.js} +1 -1
  52. package/src/client/dist/spa/assets/{expand-template-BxUkuL5g.js → expand-template-CF0lBr4L.js} +1 -1
  53. package/src/client/dist/spa/assets/{freemarker2-DRz20wAV.js → freemarker2-q2PyKiM2.js} +1 -1
  54. package/src/client/dist/spa/assets/{handlebars-C0dsvPnC.js → handlebars-DoaZIK6r.js} +1 -1
  55. package/src/client/dist/spa/assets/{html-Cqvj1pWs.js → html-DHcse-fd.js} +1 -1
  56. package/src/client/dist/spa/assets/{htmlMode-BTHNvkm6.js → htmlMode-DPZCU7DB.js} +1 -1
  57. package/src/client/dist/spa/assets/i18n-DwzfgKc3.js +1 -0
  58. package/src/client/dist/spa/assets/index-DMUFfCIq.js +52 -0
  59. package/src/client/dist/spa/assets/{javascript-C8n3U02v.js → javascript-Ddw7c3eO.js} +1 -1
  60. package/src/client/dist/spa/assets/{jsonMode-C3AFxQ6K.js → jsonMode-vmAmvg_N.js} +1 -1
  61. package/src/client/dist/spa/assets/{kobo-commands-BuxgteGZ.js → kobo-commands-B2AhWe1S.js} +1 -1
  62. package/src/client/dist/spa/assets/{liquid-C4wtUDrJ.js → liquid-Bwz3vr4k.js} +1 -1
  63. package/src/client/dist/spa/assets/{mdx-CaT1p1F2.js → mdx-B2uBtVef.js} +1 -1
  64. package/src/client/dist/spa/assets/{monaco.contribution-CJg5GKVf.js → monaco.contribution-B3cRHiXp.js} +2 -2
  65. package/src/client/dist/spa/assets/{notifications-BC6en6Lt.js → notifications-Hq-6rEYv.js} +1 -1
  66. package/src/client/dist/spa/assets/{permissionModes-BQHBTBwa.js → permissionModes-DuwIe4ty.js} +1 -1
  67. package/src/client/dist/spa/assets/{python-Cj54W2Tg.js → python-D5ykDuc8.js} +1 -1
  68. package/src/client/dist/spa/assets/{razor-D3gJxoX_.js → razor-CStvtec5.js} +1 -1
  69. package/src/client/dist/spa/assets/{render-chat-markdown-DxEHr3lW.js → render-chat-markdown-BywKNkXe.js} +1 -1
  70. package/src/client/dist/spa/assets/{tsMode-B6S4PLWH.js → tsMode-WEgYYKFt.js} +1 -1
  71. package/src/client/dist/spa/assets/{typescript-Ca8AEX3t.js → typescript-BzFHuirT.js} +1 -1
  72. package/src/client/dist/spa/assets/{use-onboarding-CNeLPDtv.js → use-onboarding-C98jCHZu.js} +1 -1
  73. package/src/client/dist/spa/assets/{xml-CsKo4k8C.js → xml-NuCdCQMI.js} +1 -1
  74. package/src/client/dist/spa/assets/{yaml-X5yKmi6z.js → yaml-CkTTgcUh.js} +1 -1
  75. package/src/client/dist/spa/index.html +2 -2
  76. package/src/mcp-server/kobo-tasks-handlers.ts +56 -5
  77. package/src/mcp-server/kobo-tasks-server.ts +8 -19
  78. package/src/client/dist/spa/assets/ActivityFeed-DU6lDEP0.js +0 -8
  79. package/src/client/dist/spa/assets/DiffViewer-DTdDcKZC.css +0 -1
  80. package/src/client/dist/spa/assets/DiffViewer-m801GPfI.js +0 -7
  81. package/src/client/dist/spa/assets/MainLayout-oJdQ-QKM.js +0 -37
  82. package/src/client/dist/spa/assets/SettingsPage-B59LoCos.js +0 -9
  83. package/src/client/dist/spa/assets/WorkspacePage-Bj1PJSWT.js +0 -4
  84. package/src/client/dist/spa/assets/i18n-D1I-Us2H.js +0 -1
  85. package/src/client/dist/spa/assets/index-KABmOIkF.js +0 -2
@@ -0,0 +1,150 @@
1
+ // src/server/services/change-source-branch-service.ts
2
+ import { spawn } from 'node:child_process';
3
+ import * as path from 'node:path';
4
+ import * as gitOps from '../utils/git-ops.js';
5
+ import { getAgentStatus } from './agent/orchestrator.js';
6
+ import { getForgeProvider } from './forge/registry.js';
7
+ import { resolveForge } from './forge/resolve.js';
8
+ import { getEffectiveSettings } from './settings-service.js';
9
+ import { getWorkspace, updateWorkspaceSourceBranch } from './workspace-service.js';
10
+ /** Above this many proper commits, refuse and ask for a manual rebase. */
11
+ export const MAX_PROPER_COMMITS = 50;
12
+ /** Wall-clock limit on the custom change-source-branch script. */
13
+ const SCRIPT_TIMEOUT_MS = 5 * 60 * 1000;
14
+ /**
15
+ * Re-target a workspace onto `newBase`: reconstruct its working branch via
16
+ * cherry-pick of its proper commits, update the `source_branch` metadata, and
17
+ * change the PR base if a PR exists. Throws on validation failures (agent
18
+ * running, unknown base). Returns a status discriminating the outcome.
19
+ */
20
+ export async function changeSourceBranch(workspaceId, newBase) {
21
+ const workspace = getWorkspace(workspaceId);
22
+ if (!workspace)
23
+ throw new Error(`Workspace '${workspaceId}' not found`);
24
+ if (getAgentStatus(workspaceId) !== null) {
25
+ throw new Error('Cannot change the source branch while the agent is running — stop it first');
26
+ }
27
+ const oldBase = workspace.sourceBranch;
28
+ const trimmedNew = newBase.trim();
29
+ if (!trimmedNew)
30
+ throw new Error('New source branch is required');
31
+ if (trimmedNew === oldBase)
32
+ throw new Error(`The source branch is already '${oldBase}'`);
33
+ const effective = getEffectiveSettings(workspace.projectPath);
34
+ if (effective.changeSourceBranchScript && effective.changeSourceBranchScript.trim().length > 0) {
35
+ return runCustomScript(workspace, oldBase, trimmedNew, effective.changeSourceBranchScript);
36
+ }
37
+ const worktreePath = workspace.worktreePath;
38
+ const workingBranch = workspace.workingBranch;
39
+ // Fetch every branch so all `origin/*` refs are current: the proper-commit
40
+ // computation and the `reset --hard` target both depend on fresh refs.
41
+ // Best-effort — offline still lets us proceed with whatever is local, and
42
+ // the branchExists check below is the authoritative gate for the new base
43
+ // (it throws a clean error, mapped to a 400, rather than a raw fetch error).
44
+ try {
45
+ gitOps.fetchAllBranches(worktreePath);
46
+ }
47
+ catch {
48
+ // offline / no remote — proceed with local refs
49
+ }
50
+ if (!gitOps.branchExists(worktreePath, trimmedNew, 'origin')) {
51
+ throw new Error(`Source branch 'origin/${trimmedNew}' does not exist`);
52
+ }
53
+ const commits = gitOps.listProperCommits(worktreePath, workingBranch, trimmedNew, oldBase);
54
+ const forcePushNeeded = gitOps.branchExists(worktreePath, workingBranch, 'origin');
55
+ if (commits.length > MAX_PROPER_COMMITS) {
56
+ return { status: 'too-many', forcePushNeeded, commitCount: commits.length };
57
+ }
58
+ const isAligned = commits.length === 0;
59
+ const dirty = gitOps.worktreeHasChanges(worktreePath);
60
+ if (!isAligned && dirty) {
61
+ return { status: 'dirty', forcePushNeeded, commitCount: commits.length };
62
+ }
63
+ const stashed = isAligned && dirty;
64
+ if (stashed)
65
+ gitOps.stashPush(worktreePath, 'kobo-change-source-branch');
66
+ try {
67
+ gitOps.reconstructBranchOnto(worktreePath, workingBranch, trimmedNew, commits);
68
+ }
69
+ catch (err) {
70
+ if (err instanceof gitOps.GitConflictError) {
71
+ // Record the new base even when conflicted: the cherry-pick is left in
72
+ // progress for the agent (or the cancel-source-change route) to resolve
73
+ // or abort. `stashed` is always false here — a conflict needs commits,
74
+ // the stash path is aligned-only — so no stash is stranded.
75
+ updateWorkspaceSourceBranch(workspaceId, trimmedNew);
76
+ return { status: 'conflict', forcePushNeeded, commitCount: commits.length };
77
+ }
78
+ throw err;
79
+ }
80
+ finally {
81
+ if (stashed)
82
+ gitOps.stashPop(worktreePath);
83
+ }
84
+ updateWorkspaceSourceBranch(workspaceId, trimmedNew);
85
+ try {
86
+ const provider = getForgeProvider(resolveForge(workspace.projectPath));
87
+ if (provider.capabilities.canChangePrBase) {
88
+ const pr = await provider.getPrStatus(worktreePath, workingBranch);
89
+ if (pr)
90
+ await provider.changePrBase(worktreePath, trimmedNew);
91
+ }
92
+ }
93
+ catch (err) {
94
+ console.error('[change-source-branch] PR base update failed (non-fatal):', err);
95
+ }
96
+ return { status: isAligned ? 'aligned' : 'done', forcePushNeeded, commitCount: commits.length };
97
+ }
98
+ /** Spawn the script with `bash -c`, return the standard result on exit 0, throw on non-zero. */
99
+ async function runCustomScript(workspace, oldBase, newBase, script) {
100
+ const forgeId = resolveForge(workspace.projectPath);
101
+ const projectName = path.basename(workspace.projectPath);
102
+ // Best-effort PR/MR lookup — '' on no PR / missing CLI / forge='none'.
103
+ let prNumber = '';
104
+ try {
105
+ const provider = getForgeProvider(forgeId);
106
+ const snapshot = await provider.getPrStatus(workspace.worktreePath, workspace.workingBranch);
107
+ if (snapshot?.number)
108
+ prNumber = String(snapshot.number);
109
+ }
110
+ catch (err) {
111
+ console.warn('[change-source-branch] PR lookup failed, KOBO_PR_NUMBER will be empty:', err);
112
+ }
113
+ return new Promise((resolve, reject) => {
114
+ const child = spawn('bash', ['-c', script], {
115
+ cwd: workspace.worktreePath,
116
+ env: {
117
+ ...process.env,
118
+ KOBO_NEW_BASE: newBase,
119
+ KOBO_OLD_BASE: oldBase,
120
+ KOBO_WORKING_BRANCH: workspace.workingBranch,
121
+ KOBO_WORKTREE_PATH: workspace.worktreePath,
122
+ KOBO_PROJECT_PATH: workspace.projectPath,
123
+ KOBO_PROJECT_NAME: projectName,
124
+ KOBO_WORKSPACE_ID: workspace.id,
125
+ KOBO_WORKSPACE_NAME: workspace.name,
126
+ KOBO_FORGE: forgeId,
127
+ KOBO_PR_NUMBER: prNumber,
128
+ },
129
+ timeout: SCRIPT_TIMEOUT_MS,
130
+ });
131
+ let stderrBuf = '';
132
+ child.stderr?.on('data', (chunk) => {
133
+ stderrBuf += chunk.toString();
134
+ if (stderrBuf.length > 8 * 1024)
135
+ stderrBuf = stderrBuf.slice(-8 * 1024);
136
+ });
137
+ child.on('error', (err) => {
138
+ reject(new Error(`Custom change-source-branch script failed to spawn: ${err.message}`));
139
+ });
140
+ child.on('exit', (code, signal) => {
141
+ if (code === 0) {
142
+ updateWorkspaceSourceBranch(workspace.id, newBase);
143
+ resolve({ status: 'done', forcePushNeeded: false, commitCount: 0 });
144
+ return;
145
+ }
146
+ const detail = stderrBuf.trim().slice(-500) || `exit code ${code ?? signal ?? 'unknown'}`;
147
+ reject(new Error(`Custom change-source-branch script failed: ${detail}`));
148
+ });
149
+ });
150
+ }
@@ -0,0 +1,41 @@
1
+ import { getDb } from '../db/index.js';
2
+ /** Hard cap on entries per workspace. The service trims after every insert. */
3
+ const MAX_HISTORY_ENTRIES = 200;
4
+ /** Returns up to MAX_HISTORY_ENTRIES messages for the workspace, most recent first. */
5
+ export function listChatHistory(workspaceId) {
6
+ const db = getDb();
7
+ const rows = db
8
+ .prepare(`SELECT message FROM workspace_chat_history
9
+ WHERE workspace_id = ?
10
+ ORDER BY id DESC
11
+ LIMIT ${MAX_HISTORY_ENTRIES}`)
12
+ .all(workspaceId);
13
+ return rows.map((r) => r.message);
14
+ }
15
+ /**
16
+ * Insert a message into the workspace's history, dedup against the latest
17
+ * entry, and trim to MAX_HISTORY_ENTRIES. Whitespace-only messages are
18
+ * ignored. Atomic via a transaction so the insert + trim are observed
19
+ * together.
20
+ */
21
+ export function pushChatHistory(workspaceId, message) {
22
+ if (!message?.trim())
23
+ return;
24
+ const db = getDb();
25
+ db.transaction(() => {
26
+ const latest = db
27
+ .prepare('SELECT message FROM workspace_chat_history WHERE workspace_id = ? ORDER BY id DESC LIMIT 1')
28
+ .get(workspaceId);
29
+ if (latest?.message === message)
30
+ return;
31
+ db.prepare('INSERT INTO workspace_chat_history (workspace_id, message) VALUES (?, ?)').run(workspaceId, message);
32
+ db.prepare(`DELETE FROM workspace_chat_history
33
+ WHERE workspace_id = ?
34
+ AND id NOT IN (
35
+ SELECT id FROM workspace_chat_history
36
+ WHERE workspace_id = ?
37
+ ORDER BY id DESC
38
+ LIMIT ${MAX_HISTORY_ENTRIES}
39
+ )`).run(workspaceId, workspaceId);
40
+ })();
41
+ }
@@ -0,0 +1,59 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { getFileContent, writeFileInWorktree } from '../utils/git-ops.js';
5
+ const MAX_FILE_BYTES = 1024 * 1024;
6
+ export function shaOf(content) {
7
+ return createHash('sha256').update(content, 'utf8').digest('hex');
8
+ }
9
+ /**
10
+ * Persist `content` to `relativePath` inside `worktreePath`. Refuses when the
11
+ * current file's sha differs from `baseSha` (412 semantics), when the path
12
+ * escapes the worktree (including via symlinks), or when content exceeds 1 MB.
13
+ */
14
+ export function saveWorkspaceFile(worktreePath, relativePath, content, baseSha) {
15
+ const absPath = resolveSafe(worktreePath, relativePath);
16
+ if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
17
+ throw new Error(`File too large (max ${MAX_FILE_BYTES / 1024 / 1024} MB)`);
18
+ }
19
+ const current = getFileContent(worktreePath, relativePath) ?? '';
20
+ const currentSha = shaOf(current);
21
+ if (currentSha !== baseSha) {
22
+ return { status: 'conflict', currentSha };
23
+ }
24
+ writeFileInWorktree(absPath, content);
25
+ return { status: 'saved' };
26
+ }
27
+ function resolveSafe(worktreePath, relativePath) {
28
+ const abs = path.resolve(worktreePath, relativePath);
29
+ const root = realpathSync(path.resolve(worktreePath));
30
+ const rootWithSep = root + path.sep;
31
+ // Resolve the parent's realpath (parent must exist; if it doesn't, the path
32
+ // is invalid anyway and we surface that). Then join the lexical basename so
33
+ // a non-existent leaf doesn't trigger ENOENT.
34
+ let realParent;
35
+ try {
36
+ realParent = realpathSync(path.dirname(abs));
37
+ }
38
+ catch {
39
+ throw new Error(`Path '${relativePath}' is invalid (parent directory does not exist)`);
40
+ }
41
+ const realAbs = path.join(realParent, path.basename(abs));
42
+ if (realAbs !== root && !realAbs.startsWith(rootWithSep)) {
43
+ throw new Error(`Path '${relativePath}' escapes the worktree`);
44
+ }
45
+ // If the leaf exists and is itself a symlink, follow it and re-check
46
+ // containment so we never write through a symlink that escapes the worktree.
47
+ try {
48
+ const leafReal = realpathSync(realAbs);
49
+ if (leafReal !== root && !leafReal.startsWith(rootWithSep)) {
50
+ throw new Error(`Path '${relativePath}' escapes the worktree`);
51
+ }
52
+ return leafReal;
53
+ }
54
+ catch (err) {
55
+ if (err.code === 'ENOENT')
56
+ return realAbs;
57
+ throw err;
58
+ }
59
+ }
@@ -0,0 +1,121 @@
1
+ // src/server/services/forge/github/provider.ts
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ const execFileAsync = promisify(execFile);
5
+ // NOTE: `reviewThreads` is intentionally NOT in this list.
6
+ // `gh pr view --json reviewThreads` is rejected with `Unknown JSON field`
7
+ // — there is no stable `gh` version that exposes it. Until upstream adds it,
8
+ // `unresolvedReviewThreadsCount` stays at 0. `reviewThreads` is kept in the
9
+ // `RawGhPr` shape only so the mapper code stays forward-compatible.
10
+ const GH_PR_FIELDS = [
11
+ 'number',
12
+ 'title',
13
+ 'url',
14
+ 'state',
15
+ 'baseRefName',
16
+ 'reviewDecision',
17
+ 'author',
18
+ 'assignees',
19
+ 'labels',
20
+ 'latestReviews',
21
+ 'reviewRequests',
22
+ 'statusCheckRollup',
23
+ 'updatedAt',
24
+ ].join(',');
25
+ function mapGhPrToSnapshot(raw) {
26
+ const reviewers = [];
27
+ const seen = new Set();
28
+ for (const r of raw.latestReviews ?? []) {
29
+ const login = r.author?.login;
30
+ if (!login || seen.has(login))
31
+ continue;
32
+ seen.add(login);
33
+ reviewers.push({ login, state: r.state ?? 'COMMENTED' });
34
+ }
35
+ for (const r of raw.reviewRequests ?? []) {
36
+ if (!r.login || seen.has(r.login))
37
+ continue;
38
+ seen.add(r.login);
39
+ reviewers.push({ login: r.login, state: 'PENDING' });
40
+ }
41
+ const checks = (raw.statusCheckRollup ?? []).map((c) => ({
42
+ name: c.name,
43
+ conclusion: c.conclusion ?? null,
44
+ status: c.status,
45
+ detailsUrl: c.detailsUrl ?? null,
46
+ }));
47
+ let rollup = null;
48
+ if (checks.length > 0) {
49
+ if (checks.some((c) => c.conclusion === 'FAILURE'))
50
+ rollup = 'FAILURE';
51
+ else if (checks.some((c) => c.status !== 'COMPLETED'))
52
+ rollup = 'PENDING';
53
+ else
54
+ rollup = 'SUCCESS';
55
+ }
56
+ const unresolvedReviewThreadsCount = (raw.reviewThreads ?? []).reduce((a, t) => a + (t.isResolved ? 0 : 1), 0);
57
+ return {
58
+ number: raw.number,
59
+ title: raw.title,
60
+ url: raw.url,
61
+ state: raw.state,
62
+ base: raw.baseRefName ?? '',
63
+ reviewDecision: raw.reviewDecision ?? null,
64
+ author: { login: raw.author?.login ?? '' },
65
+ assignees: (raw.assignees ?? []).map((a) => ({ login: a.login })),
66
+ reviewers,
67
+ labels: (raw.labels ?? []).map((l) => ({ name: l.name, color: l.color })),
68
+ ci: { rollup, checks },
69
+ updatedAt: raw.updatedAt ?? '',
70
+ unresolvedReviewThreadsCount,
71
+ };
72
+ }
73
+ /** Map an execFile rejection to a ForgeAvailability reason. */
74
+ function availabilityFromError(err) {
75
+ const code = err.code;
76
+ if (code === 'ENOENT')
77
+ return { available: false, reason: 'cli_missing' };
78
+ const msg = err.message?.toLowerCase() ?? '';
79
+ if (msg.includes('not logged in') || msg.includes('gh auth login')) {
80
+ return { available: false, reason: 'not_authenticated' };
81
+ }
82
+ return { available: false };
83
+ }
84
+ export const githubProvider = {
85
+ id: 'github',
86
+ capabilities: { canCreatePr: true, canChangePrBase: true, requestTermShort: 'PR' },
87
+ async isAvailable(repoPath) {
88
+ try {
89
+ await execFileAsync('gh', ['auth', 'status'], { cwd: repoPath, encoding: 'utf-8' });
90
+ return { available: true };
91
+ }
92
+ catch (err) {
93
+ return availabilityFromError(err);
94
+ }
95
+ },
96
+ async getPrStatus(repoPath, branch) {
97
+ try {
98
+ const { stdout } = await execFileAsync('gh', ['pr', 'view', branch, '--json', GH_PR_FIELDS], {
99
+ cwd: repoPath,
100
+ encoding: 'utf-8',
101
+ });
102
+ const raw = stdout.trim();
103
+ if (!raw)
104
+ return null;
105
+ return mapGhPrToSnapshot(JSON.parse(raw));
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ },
111
+ async createPr(repoPath, opts) {
112
+ const { stdout } = await execFileAsync('gh', ['pr', 'create', '--base', opts.base, '--head', opts.head, '--title', opts.title, '--body', opts.body], { cwd: repoPath, encoding: 'utf-8' });
113
+ const match = stdout.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
114
+ if (!match)
115
+ throw new Error('Could not parse PR URL from gh output');
116
+ return { url: match[0], number: Number.parseInt(match[1], 10) };
117
+ },
118
+ async changePrBase(repoPath, base) {
119
+ await execFileAsync('gh', ['pr', 'edit', '--base', base], { cwd: repoPath, encoding: 'utf-8' });
120
+ },
121
+ };
@@ -0,0 +1,178 @@
1
+ // src/server/services/forge/gitlab/provider.ts
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ const execFileAsync = promisify(execFile);
5
+ /** Map GitLab MR state to the normalised PrSnapshot state. */
6
+ function mapState(state) {
7
+ if (state === 'merged')
8
+ return 'MERGED';
9
+ if (state === 'closed')
10
+ return 'CLOSED';
11
+ return 'OPEN';
12
+ }
13
+ function mapGlabMrToSnapshot(raw) {
14
+ return {
15
+ number: raw.iid,
16
+ title: raw.title,
17
+ url: raw.web_url,
18
+ state: mapState(raw.state),
19
+ base: raw.target_branch ?? '',
20
+ // GitLab's approval model differs from GitHub review decisions; left null.
21
+ reviewDecision: null,
22
+ author: { login: raw.author?.username ?? '' },
23
+ assignees: (raw.assignees ?? []).map((a) => ({ login: a.username })),
24
+ reviewers: (raw.reviewers ?? []).map((r) => ({ login: r.username, state: 'PENDING' })),
25
+ // glab does not expose label colours in the MR view; default to ''.
26
+ labels: (raw.labels ?? []).map((name) => ({ name, color: '' })),
27
+ // ci is enriched by getPrStatus via fetchGlabCi (rollup + per-job checks);
28
+ // this default applies only if that enrichment call fails.
29
+ ci: { rollup: null, checks: [] },
30
+ updatedAt: raw.updated_at ?? '',
31
+ unresolvedReviewThreadsCount: 0,
32
+ };
33
+ }
34
+ /** Map a GitLab pipeline status string to the normalised CI rollup. */
35
+ function mapPipelineRollup(status) {
36
+ switch (status) {
37
+ case 'failed':
38
+ return 'FAILURE';
39
+ case 'success':
40
+ return 'SUCCESS';
41
+ case 'canceled':
42
+ return 'CANCELLED';
43
+ case 'running':
44
+ case 'pending':
45
+ case 'created':
46
+ case 'preparing':
47
+ case 'scheduled':
48
+ case 'waiting_for_resource':
49
+ return 'PENDING';
50
+ default:
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * Map a GitLab pipeline job to a normalised PrCiCheck. The `status` /
56
+ * `conclusion` pair is chosen so `PrPanel.vue` groups the job correctly:
57
+ * non-`COMPLETED` → pending; `FAILURE`/`CANCELLED` → failed; `SUCCESS` →
58
+ * passed; `SKIPPED`/`NEUTRAL` → skipped.
59
+ */
60
+ function mapGlabJobToCheck(job) {
61
+ let status = 'IN_PROGRESS';
62
+ let conclusion = null;
63
+ switch (job.status) {
64
+ case 'failed':
65
+ status = 'COMPLETED';
66
+ conclusion = 'FAILURE';
67
+ break;
68
+ case 'canceled':
69
+ status = 'COMPLETED';
70
+ conclusion = 'CANCELLED';
71
+ break;
72
+ case 'success':
73
+ status = 'COMPLETED';
74
+ conclusion = 'SUCCESS';
75
+ break;
76
+ case 'skipped':
77
+ status = 'COMPLETED';
78
+ conclusion = 'SKIPPED';
79
+ break;
80
+ case 'manual':
81
+ status = 'COMPLETED';
82
+ conclusion = 'NEUTRAL';
83
+ break;
84
+ default:
85
+ // created / pending / running / preparing / scheduled /
86
+ // waiting_for_resource / unknown — still in flight.
87
+ break;
88
+ }
89
+ return { name: job.name ?? '', conclusion, status, detailsUrl: job.web_url ?? null };
90
+ }
91
+ /**
92
+ * Best-effort latest-pipeline CI summary for a branch via `glab ci get`. The
93
+ * single JSON response carries both the pipeline `status` (the rollup) and a
94
+ * `jobs` array (the per-check detail). Never throws — a branch with no
95
+ * pipeline (or any glab error) yields `{ rollup: null, checks: [] }` so the
96
+ * caller can still return the MR snapshot.
97
+ */
98
+ async function fetchGlabCi(repoPath, branch) {
99
+ try {
100
+ const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'], {
101
+ cwd: repoPath,
102
+ encoding: 'utf-8',
103
+ });
104
+ const raw = stdout.trim();
105
+ if (!raw)
106
+ return { rollup: null, checks: [] };
107
+ const pipeline = JSON.parse(raw);
108
+ return {
109
+ rollup: mapPipelineRollup(pipeline.status),
110
+ checks: (pipeline.jobs ?? []).map(mapGlabJobToCheck),
111
+ };
112
+ }
113
+ catch {
114
+ return { rollup: null, checks: [] };
115
+ }
116
+ }
117
+ function availabilityFromError(err) {
118
+ const code = err.code;
119
+ if (code === 'ENOENT')
120
+ return { available: false, reason: 'cli_missing' };
121
+ const msg = err.message?.toLowerCase() ?? '';
122
+ if (msg.includes('not authenticated') || msg.includes('glab auth login')) {
123
+ return { available: false, reason: 'not_authenticated' };
124
+ }
125
+ return { available: false };
126
+ }
127
+ export const gitlabProvider = {
128
+ id: 'gitlab',
129
+ capabilities: { canCreatePr: true, canChangePrBase: true, requestTermShort: 'MR' },
130
+ async isAvailable(repoPath) {
131
+ try {
132
+ await execFileAsync('glab', ['auth', 'status'], { cwd: repoPath, encoding: 'utf-8' });
133
+ return { available: true };
134
+ }
135
+ catch (err) {
136
+ return availabilityFromError(err);
137
+ }
138
+ },
139
+ async getPrStatus(repoPath, branch) {
140
+ try {
141
+ const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'], {
142
+ cwd: repoPath,
143
+ encoding: 'utf-8',
144
+ });
145
+ const raw = stdout.trim();
146
+ if (!raw)
147
+ return null;
148
+ const snapshot = mapGlabMrToSnapshot(JSON.parse(raw));
149
+ snapshot.ci = await fetchGlabCi(repoPath, branch);
150
+ return snapshot;
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ },
156
+ async createPr(repoPath, opts) {
157
+ const { stdout } = await execFileAsync('glab', [
158
+ 'mr',
159
+ 'create',
160
+ '--source-branch',
161
+ opts.head,
162
+ '--target-branch',
163
+ opts.base,
164
+ '--title',
165
+ opts.title,
166
+ '--description',
167
+ opts.body,
168
+ '--yes',
169
+ ], { cwd: repoPath, encoding: 'utf-8' });
170
+ const match = stdout.match(/https?:\/\/[^\s]+\/-\/merge_requests\/(\d+)/);
171
+ if (!match)
172
+ throw new Error('Could not parse MR URL from glab output');
173
+ return { url: match[0], number: Number.parseInt(match[1], 10) };
174
+ },
175
+ async changePrBase(repoPath, base) {
176
+ await execFileAsync('glab', ['mr', 'update', '--target-branch', base], { cwd: repoPath, encoding: 'utf-8' });
177
+ },
178
+ };
@@ -0,0 +1,23 @@
1
+ // src/server/services/forge/none.ts
2
+ import { ForgeUnavailableError } from './types.js';
3
+ /**
4
+ * Provider for projects with no supported forge. Read operations return
5
+ * `null` (no PR is a valid state); write operations throw so the route
6
+ * layer surfaces a clear message instead of attempting a CLI call.
7
+ */
8
+ export const noneProvider = {
9
+ id: 'none',
10
+ capabilities: { canCreatePr: false, canChangePrBase: false, requestTermShort: 'PR' },
11
+ async isAvailable() {
12
+ return { available: false };
13
+ },
14
+ async getPrStatus() {
15
+ return null;
16
+ },
17
+ async createPr() {
18
+ throw new ForgeUnavailableError('This project has no supported forge configured');
19
+ },
20
+ async changePrBase() {
21
+ throw new ForgeUnavailableError('This project has no supported forge configured');
22
+ },
23
+ };
@@ -0,0 +1,17 @@
1
+ // src/server/services/forge/registry.ts
2
+ import { githubProvider } from './github/provider.js';
3
+ import { gitlabProvider } from './gitlab/provider.js';
4
+ import { noneProvider } from './none.js';
5
+ const PROVIDERS = {
6
+ github: githubProvider,
7
+ gitlab: gitlabProvider,
8
+ none: noneProvider,
9
+ };
10
+ /** Resolve a provider by id. Unknown ids fall back to the none provider. */
11
+ export function getForgeProvider(id) {
12
+ return PROVIDERS[id] ?? noneProvider;
13
+ }
14
+ /** The selectable forge ids, in display order. */
15
+ export function listForges() {
16
+ return ['github', 'gitlab', 'none'];
17
+ }
@@ -0,0 +1,34 @@
1
+ // src/server/services/forge/resolve.ts
2
+ import { execFileSync } from 'node:child_process';
3
+ import { getProjectSettings } from '../settings-service.js';
4
+ /** Classify a git remote URL into a forge id. Exported for testing. */
5
+ export function forgeFromRemoteUrl(url) {
6
+ const lower = url.toLowerCase();
7
+ if (lower.includes('github.com'))
8
+ return 'github';
9
+ if (lower.includes('gitlab'))
10
+ return 'gitlab';
11
+ return 'none';
12
+ }
13
+ /** Read the `origin` remote URL, or '' when there is no remote. */
14
+ function readRemoteUrl(projectPath) {
15
+ try {
16
+ return execFileSync('git', ['remote', 'get-url', 'origin'], {
17
+ cwd: projectPath,
18
+ encoding: 'utf-8',
19
+ }).trim();
20
+ }
21
+ catch {
22
+ return '';
23
+ }
24
+ }
25
+ /**
26
+ * Resolve the forge for a project: the explicit per-project setting wins;
27
+ * `auto` (the default) classifies the origin remote URL.
28
+ */
29
+ export function resolveForge(projectPath) {
30
+ const setting = getProjectSettings(projectPath)?.forge ?? 'auto';
31
+ if (setting === 'github' || setting === 'gitlab' || setting === 'none')
32
+ return setting;
33
+ return forgeFromRemoteUrl(readRemoteUrl(projectPath));
34
+ }
@@ -0,0 +1,9 @@
1
+ // src/server/services/forge/types.ts
2
+ /** Thrown by write operations when the forge cannot service them. */
3
+ export class ForgeUnavailableError extends Error {
4
+ code = 'forge_unavailable';
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'ForgeUnavailableError';
8
+ }
9
+ }
@@ -0,0 +1,32 @@
1
+ // src/server/services/git-stats-service.ts
2
+ import * as gitOps from '../utils/git-ops.js';
3
+ import { getForgeProvider } from './forge/registry.js';
4
+ import { resolveForge } from './forge/resolve.js';
5
+ /**
6
+ * Compute the git + forge stats for a workspace branch. Pure read — does NOT
7
+ * run `fetchSourceBranchAsync`; the caller decides whether to refresh the
8
+ * local source ref first. `prUrl`/`prState` come from the supplied PR snapshot
9
+ * so this never issues its own `getPrStatus` call.
10
+ */
11
+ export async function computeGitStats(workspace, prSnapshot) {
12
+ const { worktreePath, sourceBranch, workingBranch, projectPath } = workspace;
13
+ const commitCount = gitOps.getCommitCount(worktreePath, sourceBranch, workingBranch);
14
+ const behindCount = gitOps.getCommitsBehind(worktreePath, sourceBranch, workingBranch);
15
+ const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, sourceBranch, workingBranch);
16
+ const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath, workingBranch);
17
+ const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
18
+ const forgeProvider = getForgeProvider(resolveForge(projectPath));
19
+ const availability = await forgeProvider.isAvailable(worktreePath);
20
+ return {
21
+ commitCount,
22
+ behindCount,
23
+ filesChanged: diffStats.filesChanged,
24
+ insertions: diffStats.insertions,
25
+ deletions: diffStats.deletions,
26
+ prUrl: prSnapshot?.url ?? null,
27
+ prState: prSnapshot?.state ?? null,
28
+ unpushedCount,
29
+ workingTree,
30
+ forge: { id: forgeProvider.id, capabilities: forgeProvider.capabilities, availability },
31
+ };
32
+ }