@loicngr/kobo 1.4.5 → 1.4.7
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/AGENTS.md +1 -1
- package/README.md +2 -2
- package/dist/mcp-server/kobo-tasks-handlers.js +12 -1
- package/dist/mcp-server/kobo-tasks-server.js +37 -2
- package/dist/server/db/index.js +5 -0
- package/dist/server/db/migrations.js +9 -0
- package/dist/server/db/schema.js +2 -0
- package/dist/server/index.js +17 -18
- package/dist/server/routes/dev-server.js +1 -0
- package/dist/server/routes/git.js +1 -0
- package/dist/server/routes/images.js +3 -0
- package/dist/server/routes/notion.js +1 -0
- package/dist/server/routes/settings.js +1 -0
- package/dist/server/routes/workspaces.js +166 -51
- package/dist/server/services/agent-manager.js +35 -9
- package/dist/server/services/image-service.js +2 -1
- package/dist/server/services/notion-service.js +14 -18
- package/dist/server/services/pr-watcher-service.js +2 -0
- package/dist/server/services/settings-service.js +33 -6
- package/dist/server/services/setup-script-service.js +1 -0
- package/dist/server/services/websocket-service.js +8 -9
- package/dist/server/services/workspace-service.js +33 -2
- package/dist/server/services/worktree-service.js +4 -2
- package/dist/server/utils/git-ops.js +19 -5
- package/dist/server/utils/process-tracker.js +7 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-BXrsWU-N.css +1 -0
- package/src/client/dist/spa/assets/ActivityFeed-Zg4aFWHr.js +68 -0
- package/src/client/dist/spa/assets/CreatePage-B4NEzk8h.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-Cb9tgQ57.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-CSwamnmH.js +2 -0
- package/src/client/dist/spa/assets/DiffViewer-DiHFLSk4.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-DYu8fNdb.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-To97f8bb.js +2 -0
- package/src/client/dist/spa/assets/QBadge-BBHYbjmH.js +1 -0
- package/src/client/dist/spa/assets/QCheckbox-C3OW_sBe.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem--CUnMqsJ.js +1 -0
- package/src/client/dist/spa/assets/QPage-DfVYLit9.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-DxFX2A_Y.js +1 -0
- package/src/client/dist/spa/assets/QTabPanels-CQiX55Zs.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-DDQMcKoX.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-CEJA3OTN.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-DHVfZP9g.js +1 -0
- package/src/client/dist/spa/assets/TouchPan-DmgViFM9.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BV77y-1V.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BfUdWLME.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-D5gezOWD.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-Dsa4Lydc.js → cssMode-4AF4Bwjq.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-qmVdKoc0.js → editor.api-Ctxw4rqS.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-D2fZAQLs.js → editor.main-BZM2ZVF-.js} +3 -3
- package/src/client/dist/spa/assets/format-DhM1gNfW.js +1 -0
- package/src/client/dist/spa/assets/formatters-BpjOVbqs.js +6 -0
- package/src/client/dist/spa/assets/{freemarker2-e-FYsZTq.js → freemarker2-nlLLg2wF.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CAwfoT2m.js → handlebars-D1p1nhG6.js} +1 -1
- package/src/client/dist/spa/assets/{html-BTRUpMfA.js → html-B6WFBXSs.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-mzgQeoHf.js → htmlMode-DLQXzxD7.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CrbCJQHz.js +1 -0
- package/src/client/dist/spa/assets/i18n-uUJIn7l1.js +1 -0
- package/src/client/dist/spa/assets/index-CkkvRhkB.js +5 -0
- package/src/client/dist/spa/assets/{javascript-DDeQuxhB.js → javascript-Cuk4WgU2.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-Ch5vu2Iw.js → jsonMode-CvIzW7YR.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-qVj3a9kh.js → liquid-BESgd7r_.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-Cb_a7RXe.js → mdx-CjeTpIfQ.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Rz70-mfd.js → monaco.contribution-BdsaXuzN.js} +2 -2
- package/src/client/dist/spa/assets/nodes-Bj1I9JfN.js +1 -0
- package/src/client/dist/spa/assets/{python-Cz0peIkX.js → python-DjonvvLh.js} +1 -1
- package/src/client/dist/spa/assets/{razor-CU8Xe-4p.js → razor-CYj_TCQ0.js} +1 -1
- package/src/client/dist/spa/assets/settings-DQXlzOR-.js +1 -0
- package/src/client/dist/spa/assets/touch-Dm5n4n0E.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-CP1svEaN.js → tsMode-C8aV_UsX.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-aPJGGIsI.js → typescript-Clp9vIWN.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-Cfet_Yar.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-Da_Py0Ib.js +1 -0
- package/src/client/dist/spa/assets/vue-i18n-nv59vAyH.js +3 -0
- package/src/client/dist/spa/assets/{xml-CZM1zQhV.js → xml-6gY0RxjJ.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-B8qPirw0.js → yaml-IQGDhYaJ.js} +1 -1
- package/src/client/dist/spa/index.html +5 -4
- package/src/client/dist/spa/notification.mp3 +0 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +21 -5
- package/src/mcp-server/kobo-tasks-server.ts +39 -2
- package/src/client/dist/spa/assets/ActivityFeed-Bx7maW4r.css +0 -1
- package/src/client/dist/spa/assets/ActivityFeed-DoQIjq5C.js +0 -60
- package/src/client/dist/spa/assets/CreatePage-6J0aDFtf.js +0 -2
- package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-dvGJaxu-.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-DGzPKBi9.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +0 -1
- package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +0 -1
- package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-D5gm2xc8.js +0 -1
- package/src/client/dist/spa/assets/QPage-Bg3Rohl6.js +0 -1
- package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +0 -1
- package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BNA4jJYf.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BOkWnRl2.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-9gRnhdjv.js +0 -2
- package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +0 -1
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +0 -1
- package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +0 -1
- package/src/client/dist/spa/assets/i18n-B1eQvEGk.js +0 -1
- package/src/client/dist/spa/assets/index-C0u2YcnZ.js +0 -5
- package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +0 -1
- package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +0 -1
- package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +0 -1
- package/src/client/dist/spa/assets/use-checkbox-B8W131xl.js +0 -1
- package/src/client/dist/spa/assets/use-quasar-sc8fDqi0.js +0 -1
- package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +0 -3
- /package/src/client/dist/spa/assets/{abap-Co3wj02O.js → abap-DVDKJwpW.js} +0 -0
- /package/src/client/dist/spa/assets/{apex-CUKwGs62.js → apex-DjF5nFAs.js} +0 -0
- /package/src/client/dist/spa/assets/{azcli-DMImymmY.js → azcli-HEPMaiDV.js} +0 -0
- /package/src/client/dist/spa/assets/{bat--P_y70-E.js → bat-D6epFECU.js} +0 -0
- /package/src/client/dist/spa/assets/{bicep-C3w6oSfK.js → bicep-BspG10fo.js} +0 -0
- /package/src/client/dist/spa/assets/{cameligo-D9NSR4Rj.js → cameligo-DM9kSiq7.js} +0 -0
- /package/src/client/dist/spa/assets/{clojure-BMcQme0t.js → clojure-CZn9pzfW.js} +0 -0
- /package/src/client/dist/spa/assets/{coffee-BbMZaWx7.js → coffee-Bu_NglwI.js} +0 -0
- /package/src/client/dist/spa/assets/{cpp-CbrtEGgw.js → cpp-0KJLHDue.js} +0 -0
- /package/src/client/dist/spa/assets/{csharp-Bc0fjUxA.js → csharp-DNzOiVZu.js} +0 -0
- /package/src/client/dist/spa/assets/{csp-DmbXuMT0.js → csp-CUkzJlR0.js} +0 -0
- /package/src/client/dist/spa/assets/{css-gdwCt5by.js → css-Uav73wXk.js} +0 -0
- /package/src/client/dist/spa/assets/{cypher-ocmmfoQr.js → cypher-CP6eoQBS.js} +0 -0
- /package/src/client/dist/spa/assets/{dart-DbZ5eklb.js → dart-Bdl32fSd.js} +0 -0
- /package/src/client/dist/spa/assets/{dockerfile-BLaMayDc.js → dockerfile-BIRJ7ZNM.js} +0 -0
- /package/src/client/dist/spa/assets/{ecl-LxXpHirr.js → ecl-Di24nx2U.js} +0 -0
- /package/src/client/dist/spa/assets/{elixir-C_geKt5o.js → elixir-DzFG1iYF.js} +0 -0
- /package/src/client/dist/spa/assets/{flow9-DE2fI2ca.js → flow9-DNgNh1TU.js} +0 -0
- /package/src/client/dist/spa/assets/{fsharp-CJD6fImD.js → fsharp-CuO6_Oy9.js} +0 -0
- /package/src/client/dist/spa/assets/{go-jUCqQ7bD.js → go-Pj7ToRvM.js} +0 -0
- /package/src/client/dist/spa/assets/{graphql-rw7g9h7D.js → graphql-BoaedU4s.js} +0 -0
- /package/src/client/dist/spa/assets/{hcl-BKX27Mn7.js → hcl-olXtyJcc.js} +0 -0
- /package/src/client/dist/spa/assets/{ini-CrXjga2H.js → ini-BvGNUo-D.js} +0 -0
- /package/src/client/dist/spa/assets/{java-D4jksGBb.js → java-Z9-7Isu7.js} +0 -0
- /package/src/client/dist/spa/assets/{julia-CbWxfkeS.js → julia-Bdcb8Lkm.js} +0 -0
- /package/src/client/dist/spa/assets/{kotlin-B26Yx80V.js → kotlin-DR_I1UW_.js} +0 -0
- /package/src/client/dist/spa/assets/{less-DFzn-zC9.js → less-DZxcoWKd.js} +0 -0
- /package/src/client/dist/spa/assets/{lexon-C-w-W8Yv.js → lexon-s17AK9YH.js} +0 -0
- /package/src/client/dist/spa/assets/{lua-CHuE_HoG.js → lua-BzLfjAeg.js} +0 -0
- /package/src/client/dist/spa/assets/{m3-DEFZN2qS.js → m3-DN3Xgolo.js} +0 -0
- /package/src/client/dist/spa/assets/{markdown-Cbt4TlFt.js → markdown-DCCTbSQf.js} +0 -0
- /package/src/client/dist/spa/assets/{mips-C6m4XECw.js → mips-lh5qv6lw.js} +0 -0
- /package/src/client/dist/spa/assets/{msdax-un0CFb_S.js → msdax-ikHtaqdR.js} +0 -0
- /package/src/client/dist/spa/assets/{mysql-CuAPeiOV.js → mysql-yyeeFEN0.js} +0 -0
- /package/src/client/dist/spa/assets/{objective-c-DLVMdxAC.js → objective-c-BCpwk8Ct.js} +0 -0
- /package/src/client/dist/spa/assets/{pascal-BGCThuPY.js → pascal-PL6H1Gn7.js} +0 -0
- /package/src/client/dist/spa/assets/{pascaligo-DfxSVpdo.js → pascaligo-BCtUX6M4.js} +0 -0
- /package/src/client/dist/spa/assets/{perl-BOE6y94t.js → perl-DuluA5AL.js} +0 -0
- /package/src/client/dist/spa/assets/{pgsql-Dn7JkY4F.js → pgsql-B4LkSOFV.js} +0 -0
- /package/src/client/dist/spa/assets/{php-r1gD0KyT.js → php-B3Ske963.js} +0 -0
- /package/src/client/dist/spa/assets/{pla-CgXknhb0.js → pla-B0vdKSVA.js} +0 -0
- /package/src/client/dist/spa/assets/{postiats-CsIEtnRB.js → postiats-lYIY9h0z.js} +0 -0
- /package/src/client/dist/spa/assets/{powerquery-yNJCmC_6.js → powerquery-e_CNZlRH.js} +0 -0
- /package/src/client/dist/spa/assets/{powershell-CQcz1SqH.js → powershell-B7ny7eNr.js} +0 -0
- /package/src/client/dist/spa/assets/{protobuf-BmC34uvO.js → protobuf-T5b6INIm.js} +0 -0
- /package/src/client/dist/spa/assets/{pug-C20znvWM.js → pug-DB5iDfTd.js} +0 -0
- /package/src/client/dist/spa/assets/{qsharp-B7bnARMS.js → qsharp-DwB29woK.js} +0 -0
- /package/src/client/dist/spa/assets/{r-ClvcLdqC.js → r-DGAconUr.js} +0 -0
- /package/src/client/dist/spa/assets/{redis-DCyda7_S.js → redis-CBlUxt29.js} +0 -0
- /package/src/client/dist/spa/assets/{redshift-BtWDr4pb.js → redshift-DUdydw8b.js} +0 -0
- /package/src/client/dist/spa/assets/{restructuredtext-CLcnlkhl.js → restructuredtext-CpFidj3o.js} +0 -0
- /package/src/client/dist/spa/assets/{ruby-DY0SOSSZ.js → ruby-DSI1pDHV.js} +0 -0
- /package/src/client/dist/spa/assets/{rust-JQd-fJZI.js → rust-DuAjdlB2.js} +0 -0
- /package/src/client/dist/spa/assets/{sb-BV2j8yFF.js → sb-DHK18yUj.js} +0 -0
- /package/src/client/dist/spa/assets/{scala-DwbnREDs.js → scala-OUOqN_hV.js} +0 -0
- /package/src/client/dist/spa/assets/{scheme-CrtA-vei.js → scheme-1CW-bJnQ.js} +0 -0
- /package/src/client/dist/spa/assets/{scss-VxQz3zmI.js → scss-C5o7X-EM.js} +0 -0
- /package/src/client/dist/spa/assets/{shell-CP9faqFI.js → shell-CtGzMV7Q.js} +0 -0
- /package/src/client/dist/spa/assets/{solidity-9IIb0b89.js → solidity-DPkz2VqZ.js} +0 -0
- /package/src/client/dist/spa/assets/{sophia-D2LQU2AD.js → sophia-nnptfdLN.js} +0 -0
- /package/src/client/dist/spa/assets/{sparql-DONCa5dy.js → sparql-C6bexdnc.js} +0 -0
- /package/src/client/dist/spa/assets/{sql-DaAAHGEt.js → sql-CcLlvkTm.js} +0 -0
- /package/src/client/dist/spa/assets/{st-CRY2V-j3.js → st-C7Y7CLp_.js} +0 -0
- /package/src/client/dist/spa/assets/{swift-BlKbfloF.js → swift-7Jj4IMNp.js} +0 -0
- /package/src/client/dist/spa/assets/{systemverilog-B_h9Q_T_.js → systemverilog-BBh4Cn1v.js} +0 -0
- /package/src/client/dist/spa/assets/{tcl-C4wN3A6M.js → tcl-BivXXY1v.js} +0 -0
- /package/src/client/dist/spa/assets/{twig-DDdaBLC9.js → twig-CuImSpsA.js} +0 -0
- /package/src/client/dist/spa/assets/{typespec-Dc1ipt8A.js → typespec-s1tz7uxH.js} +0 -0
- /package/src/client/dist/spa/assets/{vb-C4BXIvrh.js → vb-AURJL6ia.js} +0 -0
- /package/src/client/dist/spa/assets/{wgsl-XVg3Pi-r.js → wgsl-Dq1-vM4I.js} +0 -0
|
@@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { getPackageVersion } from '../utils/paths.js';
|
|
4
4
|
// Gherkin keywords (French and English)
|
|
5
5
|
const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
|
|
6
|
-
// C2: rpcIdCounter encapsulated in a closure to avoid module-level mutable state
|
|
7
6
|
const nextRpcId = (() => {
|
|
8
7
|
let counter = 1;
|
|
9
8
|
return () => counter++;
|
|
@@ -32,11 +31,7 @@ export function parseNotionUrl(url) {
|
|
|
32
31
|
// Convert 32 hex chars to UUID format: 8-4-4-4-12
|
|
33
32
|
return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
|
|
34
33
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Send a JSON-RPC request to the MCP process and read the response.
|
|
37
|
-
* M4: parameter renamed from `process` to `mcpProcess` to avoid shadowing global `process`.
|
|
38
|
-
* C1: 30s timeout added to prevent hanging indefinitely.
|
|
39
|
-
*/
|
|
34
|
+
/** Send a JSON-RPC request to the MCP process and read the response (30s timeout). */
|
|
40
35
|
export async function callMcpTool(mcpProcess, toolName, args) {
|
|
41
36
|
const id = nextRpcId();
|
|
42
37
|
const request = JSON.stringify({
|
|
@@ -54,7 +49,6 @@ export async function callMcpTool(mcpProcess, toolName, args) {
|
|
|
54
49
|
return;
|
|
55
50
|
}
|
|
56
51
|
let buffer = '';
|
|
57
|
-
// C1: 30s timeout — I7: kill the MCP process on timeout to avoid zombie
|
|
58
52
|
const timeout = setTimeout(() => {
|
|
59
53
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
60
54
|
mcpProcess.stdout?.removeListener('error', onError);
|
|
@@ -142,12 +136,7 @@ function spawnMcpProcess() {
|
|
|
142
136
|
});
|
|
143
137
|
return mcpProcess;
|
|
144
138
|
}
|
|
145
|
-
/**
|
|
146
|
-
* Initialize the MCP server by sending an initialize request.
|
|
147
|
-
* I1: notifications/initialized is sent after receiving the initialize response.
|
|
148
|
-
* I4: onData listener is removed in the reject path.
|
|
149
|
-
* C1: 10s timeout added.
|
|
150
|
-
*/
|
|
139
|
+
/** Initialize the MCP server by sending an initialize handshake (10s timeout). */
|
|
151
140
|
async function initializeMcp(mcpProcess) {
|
|
152
141
|
const id = nextRpcId();
|
|
153
142
|
const request = JSON.stringify({
|
|
@@ -166,7 +155,6 @@ async function initializeMcp(mcpProcess) {
|
|
|
166
155
|
return;
|
|
167
156
|
}
|
|
168
157
|
let buffer = '';
|
|
169
|
-
// C1: 10s timeout for initialization — I7: kill the MCP process on timeout
|
|
170
158
|
const timeout = setTimeout(() => {
|
|
171
159
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
172
160
|
mcpProcess.kill();
|
|
@@ -185,7 +173,6 @@ async function initializeMcp(mcpProcess) {
|
|
|
185
173
|
if (parsed.id === id) {
|
|
186
174
|
clearTimeout(timeout);
|
|
187
175
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
188
|
-
// I1: Send notifications/initialized AFTER receiving the initialize response
|
|
189
176
|
const initialized = JSON.stringify({
|
|
190
177
|
jsonrpc: '2.0',
|
|
191
178
|
method: 'notifications/initialized',
|
|
@@ -199,7 +186,6 @@ async function initializeMcp(mcpProcess) {
|
|
|
199
186
|
}
|
|
200
187
|
}
|
|
201
188
|
};
|
|
202
|
-
// I4: onError handler to clean up listener on error
|
|
203
189
|
const onError = (err) => {
|
|
204
190
|
clearTimeout(timeout);
|
|
205
191
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
@@ -244,6 +230,7 @@ function extractTextFromRichText(richText) {
|
|
|
244
230
|
})
|
|
245
231
|
.join('');
|
|
246
232
|
}
|
|
233
|
+
/** Parse Notion block children into structured goal, todos, and Gherkin features. */
|
|
247
234
|
export function parseBlocks(blocks) {
|
|
248
235
|
const todos = [];
|
|
249
236
|
const gherkinFeatures = [];
|
|
@@ -336,6 +323,7 @@ export async function extractNotionPage(notionUrl) {
|
|
|
336
323
|
const pageRaw = await callMcpTool(mcpProcess, 'API-retrieve-a-page', { page_id: pageId });
|
|
337
324
|
const pageResult = unwrapMcpResult(pageRaw);
|
|
338
325
|
let title = '';
|
|
326
|
+
let ticketId = '';
|
|
339
327
|
if (pageResult && typeof pageResult === 'object') {
|
|
340
328
|
const result = pageResult;
|
|
341
329
|
const properties = result.properties;
|
|
@@ -344,7 +332,15 @@ export async function extractNotionPage(notionUrl) {
|
|
|
344
332
|
const propObj = prop;
|
|
345
333
|
if (propObj.type === 'title' && Array.isArray(propObj.title)) {
|
|
346
334
|
title = extractTextFromRichText(propObj.title);
|
|
347
|
-
|
|
335
|
+
}
|
|
336
|
+
// Extract unique_id (e.g., "TK-1120") from Notion database properties
|
|
337
|
+
if (propObj.type === 'unique_id' && propObj.unique_id) {
|
|
338
|
+
const uid = propObj.unique_id;
|
|
339
|
+
const prefix = uid.prefix ?? '';
|
|
340
|
+
const number = uid.number;
|
|
341
|
+
if (number !== undefined) {
|
|
342
|
+
ticketId = prefix ? `${prefix}-${number}` : String(number);
|
|
343
|
+
}
|
|
348
344
|
}
|
|
349
345
|
}
|
|
350
346
|
}
|
|
@@ -362,7 +358,7 @@ export async function extractNotionPage(notionUrl) {
|
|
|
362
358
|
}
|
|
363
359
|
}
|
|
364
360
|
const { goal, todos, gherkinFeatures } = parseBlocks(blocks);
|
|
365
|
-
return { title, goal, todos, gherkinFeatures };
|
|
361
|
+
return { title, ticketId, goal, todos, gherkinFeatures };
|
|
366
362
|
}
|
|
367
363
|
finally {
|
|
368
364
|
// Ensure the MCP process is terminated
|
|
@@ -69,11 +69,13 @@ function scheduleNext() {
|
|
|
69
69
|
}, POLL_INTERVAL_MS);
|
|
70
70
|
timer.unref?.();
|
|
71
71
|
}
|
|
72
|
+
/** Start polling GitHub for merged/closed PRs to auto-archive workspaces. */
|
|
72
73
|
export function startPrWatcher() {
|
|
73
74
|
if (timer)
|
|
74
75
|
return;
|
|
75
76
|
scheduleNext();
|
|
76
77
|
}
|
|
78
|
+
/** Stop the PR watcher polling loop. */
|
|
77
79
|
export function stopPrWatcher() {
|
|
78
80
|
if (timer) {
|
|
79
81
|
clearTimeout(timer);
|
|
@@ -89,6 +89,18 @@ const settingsMigrations = [
|
|
|
89
89
|
}
|
|
90
90
|
},
|
|
91
91
|
},
|
|
92
|
+
{
|
|
93
|
+
version: 4,
|
|
94
|
+
name: 'add-editor-and-notifications',
|
|
95
|
+
migrate({ global }) {
|
|
96
|
+
if (typeof global.editorCommand !== 'string')
|
|
97
|
+
global.editorCommand = '';
|
|
98
|
+
if (typeof global.browserNotifications !== 'boolean')
|
|
99
|
+
global.browserNotifications = true;
|
|
100
|
+
if (typeof global.audioNotifications !== 'boolean')
|
|
101
|
+
global.audioNotifications = true;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
92
104
|
];
|
|
93
105
|
/** Current settings schema version — always equals the highest migration version. */
|
|
94
106
|
export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
|
|
@@ -105,6 +117,9 @@ function defaultSettings() {
|
|
|
105
117
|
dangerouslySkipPermissions: true,
|
|
106
118
|
prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
|
|
107
119
|
gitConventions: DEFAULT_GIT_CONVENTIONS,
|
|
120
|
+
editorCommand: '',
|
|
121
|
+
browserNotifications: true,
|
|
122
|
+
audioNotifications: true,
|
|
108
123
|
},
|
|
109
124
|
projects: [],
|
|
110
125
|
};
|
|
@@ -128,11 +143,7 @@ function defaultProjectSettings(projectPath) {
|
|
|
128
143
|
function pickKnownKeys(data, allowedKeys) {
|
|
129
144
|
return Object.fromEntries(Object.entries(data).filter(([key]) => allowedKeys.includes(key)));
|
|
130
145
|
}
|
|
131
|
-
/**
|
|
132
|
-
* Apply migrations sequentially to bring an older settings object up to
|
|
133
|
-
* SETTINGS_SCHEMA_VERSION. Append-only — never edit or reorder shipped entries.
|
|
134
|
-
* The returned object carries the bumped schemaVersion; callers persist it.
|
|
135
|
-
*/
|
|
146
|
+
/** Apply settings migrations sequentially up to SETTINGS_SCHEMA_VERSION. Append-only. */
|
|
136
147
|
export function runSettingsMigrations(raw) {
|
|
137
148
|
const current = raw;
|
|
138
149
|
if (!current.global || typeof current.global !== 'object') {
|
|
@@ -190,16 +201,20 @@ function writeSettings(settings) {
|
|
|
190
201
|
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
191
202
|
fs.renameSync(tmpPath, settingsFilePath);
|
|
192
203
|
}
|
|
204
|
+
/** Read and return the full settings object, creating defaults if missing. */
|
|
193
205
|
export function getSettings() {
|
|
194
206
|
return readSettings();
|
|
195
207
|
}
|
|
208
|
+
/** Return only the global settings section. */
|
|
196
209
|
export function getGlobalSettings() {
|
|
197
210
|
return readSettings().global;
|
|
198
211
|
}
|
|
212
|
+
/** Return project-specific settings, or null if the project is not configured. */
|
|
199
213
|
export function getProjectSettings(projectPath) {
|
|
200
214
|
const settings = readSettings();
|
|
201
215
|
return settings.projects.find((p) => p.path === projectPath) ?? null;
|
|
202
216
|
}
|
|
217
|
+
/** Compute effective settings for a project (project overrides merged with global defaults). */
|
|
203
218
|
export function getEffectiveSettings(projectPath) {
|
|
204
219
|
const settings = readSettings();
|
|
205
220
|
const project = settings.projects.find((p) => p.path === projectPath) ?? null;
|
|
@@ -224,14 +239,24 @@ export function getEffectiveSettings(projectPath) {
|
|
|
224
239
|
setupScript: project.setupScript || '',
|
|
225
240
|
};
|
|
226
241
|
}
|
|
242
|
+
/** Merge partial updates into global settings and persist. */
|
|
227
243
|
export function updateGlobalSettings(data) {
|
|
228
244
|
const settings = readSettings();
|
|
229
|
-
const allowedGlobalKeys = [
|
|
245
|
+
const allowedGlobalKeys = [
|
|
246
|
+
'defaultModel',
|
|
247
|
+
'dangerouslySkipPermissions',
|
|
248
|
+
'prPromptTemplate',
|
|
249
|
+
'gitConventions',
|
|
250
|
+
'editorCommand',
|
|
251
|
+
'browserNotifications',
|
|
252
|
+
'audioNotifications',
|
|
253
|
+
];
|
|
230
254
|
const filtered = pickKnownKeys(data, allowedGlobalKeys);
|
|
231
255
|
settings.global = { ...settings.global, ...filtered };
|
|
232
256
|
writeSettings(settings);
|
|
233
257
|
return settings.global;
|
|
234
258
|
}
|
|
259
|
+
/** Create or update project-specific settings. Merges devServer fields on update. */
|
|
235
260
|
export function upsertProject(projectPath, data) {
|
|
236
261
|
const allowedProjectKeys = [
|
|
237
262
|
'displayName',
|
|
@@ -276,11 +301,13 @@ export function upsertProject(projectPath, data) {
|
|
|
276
301
|
writeSettings(settings);
|
|
277
302
|
return settings.projects.find((p) => p.path === projectPath);
|
|
278
303
|
}
|
|
304
|
+
/** Remove a project from the settings file. */
|
|
279
305
|
export function deleteProject(projectPath) {
|
|
280
306
|
const settings = readSettings();
|
|
281
307
|
settings.projects = settings.projects.filter((p) => p.path !== projectPath);
|
|
282
308
|
writeSettings(settings);
|
|
283
309
|
}
|
|
310
|
+
/** List all configured projects. */
|
|
284
311
|
export function listProjects() {
|
|
285
312
|
return readSettings().projects;
|
|
286
313
|
}
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as wsService from './websocket-service.js';
|
|
5
5
|
const SETUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
/** Execute a setup script in a worktree, streaming output via WebSocket. Resolves with the exit code. */
|
|
6
7
|
export function runSetupScript(workspaceId, worktreePath, script, env, timeoutMs = SETUP_TIMEOUT_MS) {
|
|
7
8
|
return new Promise((resolve) => {
|
|
8
9
|
const scriptPath = path.join(worktreePath, '.ai', '.setup-script.tmp');
|
|
@@ -3,14 +3,16 @@ import { getDb } from '../db/index.js';
|
|
|
3
3
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
4
4
|
/** Maps each WS client to the set of workspaceIds they are subscribed to */
|
|
5
5
|
const clients = new Map();
|
|
6
|
-
/**
|
|
6
|
+
/** Per-workspace emit counter for periodic cleanup. */
|
|
7
7
|
const emitCounters = new Map();
|
|
8
8
|
const EMIT_CLEANUP_THRESHOLD = 2000;
|
|
9
9
|
let messageHandler = null;
|
|
10
|
+
/** Register the handler that processes routed WS messages (e.g. chat:message, workspace:start). */
|
|
10
11
|
export function setMessageHandler(handler) {
|
|
11
12
|
messageHandler = handler;
|
|
12
13
|
}
|
|
13
14
|
// ── Connection handling ────────────────────────────────────────────────────────
|
|
15
|
+
/** Handle a new WebSocket connection: register, dispatch messages, ping keepalive. */
|
|
14
16
|
export function handleConnection(ws) {
|
|
15
17
|
// Register client with empty subscription set
|
|
16
18
|
clients.set(ws, new Set());
|
|
@@ -50,7 +52,6 @@ export function handleConnection(ws) {
|
|
|
50
52
|
case 'sync:request': {
|
|
51
53
|
const p = payload;
|
|
52
54
|
const lastEventId = p?.lastEventId ?? '';
|
|
53
|
-
// I2: Accept optional workspaceIds so the client can sync even before re-subscribing
|
|
54
55
|
const workspaceIds = p?.workspaceIds;
|
|
55
56
|
handleSyncRequest(ws, lastEventId, workspaceIds);
|
|
56
57
|
break;
|
|
@@ -97,11 +98,11 @@ export function handleConnection(ws) {
|
|
|
97
98
|
export function emit(workspaceId, type, payload, sessionId) {
|
|
98
99
|
const id = nanoid();
|
|
99
100
|
const createdAt = new Date().toISOString();
|
|
100
|
-
//
|
|
101
|
+
// Best-effort persist — don't let FK violation (deleted workspace) break the broadcast
|
|
101
102
|
try {
|
|
102
103
|
const db = getDb();
|
|
103
104
|
db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
|
|
104
|
-
//
|
|
105
|
+
// Periodic cleanup — trigger when emit threshold is reached
|
|
105
106
|
const count = (emitCounters.get(workspaceId) ?? 0) + 1;
|
|
106
107
|
if (count >= EMIT_CLEANUP_THRESHOLD) {
|
|
107
108
|
cleanupOldEvents(workspaceId);
|
|
@@ -142,12 +143,10 @@ export function emitEphemeral(workspaceId, type, payload) {
|
|
|
142
143
|
}
|
|
143
144
|
// ── Sync (replay missed events) ────────────────────────────────────────────────
|
|
144
145
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* so the client can sync even before re-subscribing (e.g. after a reconnect).
|
|
146
|
+
* Replay all events after lastEventId for workspaces the client is subscribed to.
|
|
147
|
+
* If workspaceIds is provided, uses those instead of the client's current subscriptions.
|
|
148
148
|
*/
|
|
149
149
|
export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
150
|
-
// I2: Use provided workspaceIds first, fall back to current subscriptions
|
|
151
150
|
const resolvedIds = workspaceIds && workspaceIds.length > 0
|
|
152
151
|
? workspaceIds
|
|
153
152
|
: (() => {
|
|
@@ -171,7 +170,7 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
|
171
170
|
.all(...resolvedIds, lastRow.rowid);
|
|
172
171
|
}
|
|
173
172
|
else {
|
|
174
|
-
//
|
|
173
|
+
// lastEventId not found — send events capped to avoid unbounded memory usage
|
|
175
174
|
rows = db
|
|
176
175
|
.prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC LIMIT 10000`)
|
|
177
176
|
.all(...resolvedIds);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid';
|
|
2
2
|
import { getDb } from '../db/index.js';
|
|
3
|
-
|
|
3
|
+
/** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
|
|
4
4
|
const VALID_TRANSITIONS = {
|
|
5
5
|
created: ['extracting', 'brainstorming', 'idle', 'error'],
|
|
6
6
|
extracting: ['extracting', 'brainstorming', 'idle', 'error'],
|
|
@@ -24,6 +24,7 @@ function mapWorkspace(row) {
|
|
|
24
24
|
model: row.model,
|
|
25
25
|
permissionMode: (row.permission_mode ?? 'auto-accept'),
|
|
26
26
|
devServerStatus: row.dev_server_status,
|
|
27
|
+
hasUnread: row.has_unread === 1,
|
|
27
28
|
archivedAt: row.archived_at,
|
|
28
29
|
createdAt: row.created_at,
|
|
29
30
|
updatedAt: row.updated_at,
|
|
@@ -41,6 +42,7 @@ function mapTask(row) {
|
|
|
41
42
|
updatedAt: row.updated_at,
|
|
42
43
|
};
|
|
43
44
|
}
|
|
45
|
+
/** Insert a new workspace into the database and return it. */
|
|
44
46
|
export function createWorkspace(data) {
|
|
45
47
|
const db = getDb();
|
|
46
48
|
const now = new Date().toISOString();
|
|
@@ -51,11 +53,13 @@ export function createWorkspace(data) {
|
|
|
51
53
|
`).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.model ?? 'claude-opus-4-6', now, now);
|
|
52
54
|
return getWorkspace(id);
|
|
53
55
|
}
|
|
56
|
+
/** Fetch a single workspace by ID, or null if not found. */
|
|
54
57
|
export function getWorkspace(id) {
|
|
55
58
|
const db = getDb();
|
|
56
59
|
const row = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id);
|
|
57
60
|
return row ? mapWorkspace(row) : null;
|
|
58
61
|
}
|
|
62
|
+
/** List all workspaces, optionally including archived ones. Ordered by most recently updated. */
|
|
59
63
|
export function listWorkspaces(includeArchived = false) {
|
|
60
64
|
const db = getDb();
|
|
61
65
|
const sql = includeArchived
|
|
@@ -64,6 +68,7 @@ export function listWorkspaces(includeArchived = false) {
|
|
|
64
68
|
const rows = db.prepare(sql).all();
|
|
65
69
|
return rows.map(mapWorkspace);
|
|
66
70
|
}
|
|
71
|
+
/** List only archived workspaces, ordered by archive date descending. */
|
|
67
72
|
export function listArchivedWorkspaces() {
|
|
68
73
|
const db = getDb();
|
|
69
74
|
const rows = db
|
|
@@ -71,6 +76,7 @@ export function listArchivedWorkspaces() {
|
|
|
71
76
|
.all();
|
|
72
77
|
return rows.map(mapWorkspace);
|
|
73
78
|
}
|
|
79
|
+
/** Transition a workspace to a new status, validating against VALID_TRANSITIONS. */
|
|
74
80
|
export function updateWorkspaceStatus(id, status) {
|
|
75
81
|
const db = getDb();
|
|
76
82
|
const workspace = getWorkspace(id);
|
|
@@ -85,6 +91,7 @@ export function updateWorkspaceStatus(id, status) {
|
|
|
85
91
|
db.prepare('UPDATE workspaces SET status = ?, updated_at = ? WHERE id = ?').run(status, now, id);
|
|
86
92
|
return getWorkspace(id);
|
|
87
93
|
}
|
|
94
|
+
/** Update a workspace's display name. */
|
|
88
95
|
export function updateWorkspaceName(id, name) {
|
|
89
96
|
const db = getDb();
|
|
90
97
|
const now = new Date().toISOString();
|
|
@@ -94,6 +101,7 @@ export function updateWorkspaceName(id, name) {
|
|
|
94
101
|
}
|
|
95
102
|
return getWorkspace(id);
|
|
96
103
|
}
|
|
104
|
+
/** Update the Claude model used by a workspace's agent. */
|
|
97
105
|
export function updateWorkspaceModel(id, model) {
|
|
98
106
|
const db = getDb();
|
|
99
107
|
const now = new Date().toISOString();
|
|
@@ -103,6 +111,7 @@ export function updateWorkspaceModel(id, model) {
|
|
|
103
111
|
}
|
|
104
112
|
return getWorkspace(id);
|
|
105
113
|
}
|
|
114
|
+
/** Update the agent's permission mode (auto-accept vs plan/read-only). */
|
|
106
115
|
export function updateWorkspacePermissionMode(id, permissionMode) {
|
|
107
116
|
const db = getDb();
|
|
108
117
|
const now = new Date().toISOString();
|
|
@@ -114,17 +123,29 @@ export function updateWorkspacePermissionMode(id, permissionMode) {
|
|
|
114
123
|
}
|
|
115
124
|
return getWorkspace(id);
|
|
116
125
|
}
|
|
126
|
+
/** Update the dev-server status column for a workspace. */
|
|
117
127
|
export function updateDevServerStatus(id, status) {
|
|
118
128
|
const db = getDb();
|
|
119
129
|
db.prepare('UPDATE workspaces SET dev_server_status = ? WHERE id = ?').run(status, id);
|
|
120
130
|
}
|
|
131
|
+
/** Mark a workspace as read (has_unread = 0). */
|
|
132
|
+
export function markWorkspaceRead(id) {
|
|
133
|
+
const db = getDb();
|
|
134
|
+
db.prepare('UPDATE workspaces SET has_unread = 0 WHERE id = ?').run(id);
|
|
135
|
+
}
|
|
136
|
+
/** Mark a workspace as unread (has_unread = 1). */
|
|
137
|
+
export function markWorkspaceUnread(id) {
|
|
138
|
+
const db = getDb();
|
|
139
|
+
db.prepare('UPDATE workspaces SET has_unread = 1 WHERE id = ?').run(id);
|
|
140
|
+
}
|
|
141
|
+
/** Delete a workspace and cascade-delete its tasks. */
|
|
121
142
|
export function deleteWorkspace(id) {
|
|
122
143
|
const db = getDb();
|
|
123
144
|
db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
|
|
124
145
|
}
|
|
146
|
+
/** Create a new task under a workspace. Throws if the workspace does not exist. */
|
|
125
147
|
export function createTask(workspaceId, data) {
|
|
126
148
|
const db = getDb();
|
|
127
|
-
// I2: explicit workspace existence check before INSERT
|
|
128
149
|
const exists = db.prepare('SELECT 1 FROM workspaces WHERE id = ?').get(workspaceId);
|
|
129
150
|
if (!exists) {
|
|
130
151
|
throw new Error(`Workspace not found: '${workspaceId}'`);
|
|
@@ -138,11 +159,13 @@ export function createTask(workspaceId, data) {
|
|
|
138
159
|
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
139
160
|
return mapTask(row);
|
|
140
161
|
}
|
|
162
|
+
/** Fetch a single task by ID scoped to a workspace, or null if not found. */
|
|
141
163
|
export function getTask(taskId, workspaceId) {
|
|
142
164
|
const db = getDb();
|
|
143
165
|
const row = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId);
|
|
144
166
|
return row ? mapTask(row) : null;
|
|
145
167
|
}
|
|
168
|
+
/** List all tasks for a workspace, ordered by sort_order ascending. */
|
|
146
169
|
export function listTasks(workspaceId) {
|
|
147
170
|
const db = getDb();
|
|
148
171
|
const rows = db
|
|
@@ -150,6 +173,7 @@ export function listTasks(workspaceId) {
|
|
|
150
173
|
.all(workspaceId);
|
|
151
174
|
return rows.map(mapTask);
|
|
152
175
|
}
|
|
176
|
+
/** Update a task's status (pending, in_progress, done). */
|
|
153
177
|
export function updateTaskStatus(taskId, status) {
|
|
154
178
|
const db = getDb();
|
|
155
179
|
const now = new Date().toISOString();
|
|
@@ -160,6 +184,7 @@ export function updateTaskStatus(taskId, status) {
|
|
|
160
184
|
}
|
|
161
185
|
return mapTask(row);
|
|
162
186
|
}
|
|
187
|
+
/** Update a task's title. Throws if the title is empty or the task does not exist. */
|
|
163
188
|
export function updateTaskTitle(taskId, title) {
|
|
164
189
|
if (!title?.trim()) {
|
|
165
190
|
throw new Error('Task title cannot be empty');
|
|
@@ -173,10 +198,12 @@ export function updateTaskTitle(taskId, title) {
|
|
|
173
198
|
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
|
174
199
|
return mapTask(row);
|
|
175
200
|
}
|
|
201
|
+
/** Delete a task by ID. */
|
|
176
202
|
export function deleteTask(taskId) {
|
|
177
203
|
const db = getDb();
|
|
178
204
|
db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
|
|
179
205
|
}
|
|
206
|
+
/** Fetch a workspace with all its tasks eagerly loaded. */
|
|
180
207
|
export function getWorkspaceWithTasks(id) {
|
|
181
208
|
const workspace = getWorkspace(id);
|
|
182
209
|
if (!workspace)
|
|
@@ -184,6 +211,7 @@ export function getWorkspaceWithTasks(id) {
|
|
|
184
211
|
const tasks = listTasks(id);
|
|
185
212
|
return { ...workspace, tasks };
|
|
186
213
|
}
|
|
214
|
+
/** Archive a workspace (set archived_at). Throws if already archived. */
|
|
187
215
|
export function archiveWorkspace(id) {
|
|
188
216
|
const db = getDb();
|
|
189
217
|
const workspace = getWorkspace(id);
|
|
@@ -197,6 +225,7 @@ export function archiveWorkspace(id) {
|
|
|
197
225
|
db.prepare('UPDATE workspaces SET archived_at = ?, updated_at = ? WHERE id = ?').run(now, now, id);
|
|
198
226
|
return getWorkspace(id);
|
|
199
227
|
}
|
|
228
|
+
/** Unarchive a workspace (clear archived_at), restoring its previous status. */
|
|
200
229
|
export function unarchiveWorkspace(id) {
|
|
201
230
|
const db = getDb();
|
|
202
231
|
const workspace = getWorkspace(id);
|
|
@@ -221,6 +250,7 @@ function mapSession(row) {
|
|
|
221
250
|
endedAt: row.ended_at,
|
|
222
251
|
};
|
|
223
252
|
}
|
|
253
|
+
/** List all agent sessions for a workspace, most recent first. */
|
|
224
254
|
export function listSessions(workspaceId) {
|
|
225
255
|
const db = getDb();
|
|
226
256
|
const rows = db
|
|
@@ -228,6 +258,7 @@ export function listSessions(workspaceId) {
|
|
|
228
258
|
.all(workspaceId);
|
|
229
259
|
return rows.map(mapSession);
|
|
230
260
|
}
|
|
261
|
+
/** Get the most recent agent session for a workspace, or null if none exist. */
|
|
231
262
|
export function getLatestSession(workspaceId) {
|
|
232
263
|
const db = getDb();
|
|
233
264
|
const row = db
|
|
@@ -35,10 +35,10 @@ function removeFromExclude(projectPath, worktreePath) {
|
|
|
35
35
|
const entry = `/${relativePath}`;
|
|
36
36
|
const lines = fs.readFileSync(excludeFile, 'utf-8').split('\n');
|
|
37
37
|
const filtered = lines.filter((line) => line !== entry);
|
|
38
|
-
// I3: ensure the file ends with exactly one newline and has no trailing empty lines
|
|
39
38
|
const trimmed = filtered.join('\n').replace(/\n+$/, '');
|
|
40
39
|
fs.writeFileSync(excludeFile, trimmed ? `${trimmed}\n` : '', 'utf-8');
|
|
41
40
|
}
|
|
41
|
+
/** Create a git worktree under `.worktrees/` for the given branch. Returns the worktree path. */
|
|
42
42
|
export function createWorktree(projectPath, branchName, sourceBranch) {
|
|
43
43
|
const worktreesDir = path.join(projectPath, '.worktrees');
|
|
44
44
|
if (!fs.existsSync(worktreesDir)) {
|
|
@@ -51,7 +51,6 @@ export function createWorktree(projectPath, branchName, sourceBranch) {
|
|
|
51
51
|
}
|
|
52
52
|
catch (err) {
|
|
53
53
|
const message = err instanceof Error ? err.message : String(err);
|
|
54
|
-
// M3: use shared utility for branch-exists detection
|
|
55
54
|
// If branch already exists, add worktree without creating the branch
|
|
56
55
|
if (isGitBranchExistsError(message)) {
|
|
57
56
|
git(projectPath, ['worktree', 'add', worktreePath, branchName]);
|
|
@@ -63,6 +62,7 @@ export function createWorktree(projectPath, branchName, sourceBranch) {
|
|
|
63
62
|
addToExclude(projectPath, worktreePath);
|
|
64
63
|
return worktreePath;
|
|
65
64
|
}
|
|
65
|
+
/** Remove a git worktree and clean up the .git/info/exclude entry. */
|
|
66
66
|
export function removeWorktree(projectPath, worktreePath) {
|
|
67
67
|
try {
|
|
68
68
|
git(projectPath, ['worktree', 'remove', worktreePath, '--force']);
|
|
@@ -73,6 +73,7 @@ export function removeWorktree(projectPath, worktreePath) {
|
|
|
73
73
|
}
|
|
74
74
|
removeFromExclude(projectPath, worktreePath);
|
|
75
75
|
}
|
|
76
|
+
/** List all git worktrees for a repository by parsing `git worktree list --porcelain`. */
|
|
76
77
|
export function listWorktrees(projectPath) {
|
|
77
78
|
const output = git(projectPath, ['worktree', 'list', '--porcelain']);
|
|
78
79
|
const worktrees = [];
|
|
@@ -106,6 +107,7 @@ export function listWorktrees(projectPath) {
|
|
|
106
107
|
}
|
|
107
108
|
return worktrees;
|
|
108
109
|
}
|
|
110
|
+
/** Check whether a worktree for the given branch already exists. */
|
|
109
111
|
export function worktreeExists(projectPath, branchName) {
|
|
110
112
|
try {
|
|
111
113
|
const worktrees = listWorktrees(projectPath);
|
|
@@ -6,9 +6,11 @@ const execFileAsync = promisify(execFileCb);
|
|
|
6
6
|
function git(repoPath, args) {
|
|
7
7
|
return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim();
|
|
8
8
|
}
|
|
9
|
+
/** Return the name of the currently checked-out branch. */
|
|
9
10
|
export function getCurrentBranch(repoPath) {
|
|
10
11
|
return git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
11
12
|
}
|
|
13
|
+
/** List all local branch names in the repository. */
|
|
12
14
|
export function listBranches(repoPath) {
|
|
13
15
|
const output = git(repoPath, ['branch', '--format=%(refname:short)']);
|
|
14
16
|
return output
|
|
@@ -16,20 +18,19 @@ export function listBranches(repoPath) {
|
|
|
16
18
|
.map((b) => b.trim())
|
|
17
19
|
.filter(Boolean);
|
|
18
20
|
}
|
|
21
|
+
/** Thrown when attempting to create a branch that already exists. */
|
|
19
22
|
export class BranchAlreadyExistsError extends Error {
|
|
20
23
|
constructor(branchName) {
|
|
21
24
|
super(`Branch '${branchName}' already exists`);
|
|
22
25
|
this.name = 'BranchAlreadyExistsError';
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
|
-
/**
|
|
26
|
-
* M3: Shared utility to detect "branch already exists" git error messages
|
|
27
|
-
* across different locales (English, French, Russian).
|
|
28
|
-
*/
|
|
28
|
+
/** Detect "branch already exists" git error messages across locales (EN, FR, RU). */
|
|
29
29
|
export function isGitBranchExistsError(message) {
|
|
30
30
|
const lower = message.toLowerCase();
|
|
31
31
|
return lower.includes('already exists') || lower.includes('existe') || lower.includes('существует');
|
|
32
32
|
}
|
|
33
|
+
/** Create a new local branch from the given source branch. */
|
|
33
34
|
export function createBranch(repoPath, branchName, sourceBranch) {
|
|
34
35
|
try {
|
|
35
36
|
git(repoPath, ['branch', branchName, sourceBranch]);
|
|
@@ -42,6 +43,7 @@ export function createBranch(repoPath, branchName, sourceBranch) {
|
|
|
42
43
|
throw new Error(`Failed to create branch '${branchName}' from '${sourceBranch}': ${message}`);
|
|
43
44
|
}
|
|
44
45
|
}
|
|
46
|
+
/** Return shortstat diff stats for staged (cached) changes. */
|
|
45
47
|
export function getDiffStats(repoPath) {
|
|
46
48
|
try {
|
|
47
49
|
const output = git(repoPath, ['diff', '--cached', '--shortstat']);
|
|
@@ -64,6 +66,7 @@ function parseDiffShortstat(output) {
|
|
|
64
66
|
deletions: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
|
|
65
67
|
};
|
|
66
68
|
}
|
|
69
|
+
/** List remote-tracking branch names. Returns empty array on failure. */
|
|
67
70
|
export function listRemoteBranches(repoPath) {
|
|
68
71
|
try {
|
|
69
72
|
const output = git(repoPath, ['branch', '-r', '--format=%(refname:short)']);
|
|
@@ -76,6 +79,7 @@ export function listRemoteBranches(repoPath) {
|
|
|
76
79
|
return [];
|
|
77
80
|
}
|
|
78
81
|
}
|
|
82
|
+
/** Force-delete a local branch (`git branch -D`). */
|
|
79
83
|
export function deleteLocalBranch(repoPath, branchName) {
|
|
80
84
|
try {
|
|
81
85
|
git(repoPath, ['branch', '-D', branchName]);
|
|
@@ -85,6 +89,7 @@ export function deleteLocalBranch(repoPath, branchName) {
|
|
|
85
89
|
throw new Error(`Failed to delete local branch '${branchName}': ${message}`);
|
|
86
90
|
}
|
|
87
91
|
}
|
|
92
|
+
/** Delete a branch on the remote (`git push --delete`). */
|
|
88
93
|
export function deleteRemoteBranch(repoPath, branchName, remote = 'origin') {
|
|
89
94
|
try {
|
|
90
95
|
git(repoPath, ['push', remote, '--delete', branchName]);
|
|
@@ -94,6 +99,7 @@ export function deleteRemoteBranch(repoPath, branchName, remote = 'origin') {
|
|
|
94
99
|
throw new Error(`Failed to delete remote branch '${remote}/${branchName}': ${message}`);
|
|
95
100
|
}
|
|
96
101
|
}
|
|
102
|
+
/** Push a branch to the remote with upstream tracking (`git push -u`). */
|
|
97
103
|
export function pushBranch(repoPath, branchName, remote = 'origin') {
|
|
98
104
|
try {
|
|
99
105
|
git(repoPath, ['push', '-u', remote, branchName]);
|
|
@@ -119,6 +125,7 @@ function resolveBase(repoPath, base) {
|
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
}
|
|
128
|
+
/** Count commits between base and head (`git rev-list --count`). Returns 0 on failure. */
|
|
122
129
|
export function getCommitCount(repoPath, base, head) {
|
|
123
130
|
try {
|
|
124
131
|
const ref = resolveBase(repoPath, base);
|
|
@@ -129,6 +136,7 @@ export function getCommitCount(repoPath, base, head) {
|
|
|
129
136
|
return 0;
|
|
130
137
|
}
|
|
131
138
|
}
|
|
139
|
+
/** Return structured diff shortstat between two refs (three-dot merge base). */
|
|
132
140
|
export function getStructuredDiffStatsBetween(repoPath, base, head) {
|
|
133
141
|
try {
|
|
134
142
|
const ref = resolveBase(repoPath, base);
|
|
@@ -139,6 +147,7 @@ export function getStructuredDiffStatsBetween(repoPath, base, head) {
|
|
|
139
147
|
return { filesChanged: 0, insertions: 0, deletions: 0 };
|
|
140
148
|
}
|
|
141
149
|
}
|
|
150
|
+
/** Return a formatted list of commit subjects between base and head. */
|
|
142
151
|
export function getCommitsBetween(repoPath, base, head) {
|
|
143
152
|
try {
|
|
144
153
|
const ref = resolveBase(repoPath, base);
|
|
@@ -148,6 +157,7 @@ export function getCommitsBetween(repoPath, base, head) {
|
|
|
148
157
|
return '';
|
|
149
158
|
}
|
|
150
159
|
}
|
|
160
|
+
/** Get the GitHub PR URL for a branch using `gh pr view`. Returns null if no PR exists. */
|
|
151
161
|
export function getPrUrl(repoPath, branchName) {
|
|
152
162
|
try {
|
|
153
163
|
return (execFileSync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
|
|
@@ -159,6 +169,7 @@ export function getPrUrl(repoPath, branchName) {
|
|
|
159
169
|
return null;
|
|
160
170
|
}
|
|
161
171
|
}
|
|
172
|
+
/** Get the state and URL of the PR for a branch. Returns null if no PR exists. */
|
|
162
173
|
export function getPrStatus(repoPath, branchName) {
|
|
163
174
|
try {
|
|
164
175
|
const raw = execFileSync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
|
|
@@ -248,6 +259,7 @@ export function getFileContent(repoPath, filePath) {
|
|
|
248
259
|
return null;
|
|
249
260
|
}
|
|
250
261
|
}
|
|
262
|
+
/** Parse `git status --porcelain` into counts of staged, modified, and untracked files. */
|
|
251
263
|
export function getWorkingTreeStatus(repoPath) {
|
|
252
264
|
try {
|
|
253
265
|
const output = git(repoPath, ['status', '--porcelain']);
|
|
@@ -288,6 +300,7 @@ export function getUnpushedCount(repoPath) {
|
|
|
288
300
|
return -1; // no upstream
|
|
289
301
|
}
|
|
290
302
|
}
|
|
303
|
+
/** Return raw `git diff --shortstat` output between two refs (three-dot). */
|
|
291
304
|
export function getDiffStatsBetween(repoPath, base, head) {
|
|
292
305
|
try {
|
|
293
306
|
return git(repoPath, ['diff', '--shortstat', `${base}...${head}`]);
|
|
@@ -298,7 +311,7 @@ export function getDiffStatsBetween(repoPath, base, head) {
|
|
|
298
311
|
}
|
|
299
312
|
// ── Async versions ───────────────────────────────────────────────────────────
|
|
300
313
|
// Non-blocking alternatives for hot paths (pr-watcher, route handlers).
|
|
301
|
-
|
|
314
|
+
/** Async version of getPrUrl. Returns null if no PR exists. */
|
|
302
315
|
export async function getPrUrlAsync(repoPath, branchName) {
|
|
303
316
|
try {
|
|
304
317
|
const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
|
|
@@ -311,6 +324,7 @@ export async function getPrUrlAsync(repoPath, branchName) {
|
|
|
311
324
|
return null;
|
|
312
325
|
}
|
|
313
326
|
}
|
|
327
|
+
/** Async version of getPrStatus. Returns null if no PR exists. */
|
|
314
328
|
export async function getPrStatusAsync(repoPath, branchName) {
|
|
315
329
|
try {
|
|
316
330
|
const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
|