@loicngr/kobo 1.11.21 → 1.11.24
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/README.md +40 -96
- 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.24
|
|
8
|
+
|
|
9
|
+
- docs: redesign README as a landing page, document PR/MR import
|
|
10
|
+
|
|
11
|
+
## 1.11.23
|
|
12
|
+
|
|
13
|
+
- fix(test): configure git identity on the pr-checkout temp repo
|
|
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
|
package/README.md
CHANGED
|
@@ -1,43 +1,26 @@
|
|
|
1
1
|
# Kōbō
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Run a fleet of Claude Code and Codex agents in parallel — each in its own git worktree, all from one dashboard.**
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@loicngr/kobo)
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
[](https://nodejs.org/)
|
|
8
8
|
|
|
9
|
-
Kōbō
|
|
10
|
-
|
|
11
|
-

|
|
9
|
+
Kōbō (工房, "workshop") turns *one agent, one terminal* into a real workflow. Spin up as many isolated workspaces as you need, watch every agent work live, review and merge their diffs from a Monaco-based Git panel, and let auto-loop or scheduled wakeups keep them going while you're away.
|
|
12
10
|
|
|
13
11
|
> [!NOTE]
|
|
14
12
|
> Active development on `develop`. Forward-only migrations and timestamped pre-migration backups keep upgrades safe.
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- **Isolated worktrees**: each workspace is a dedicated git worktree on its own branch, so parallel sessions never collide.
|
|
19
|
-
- **Two agent engines**: Claude Code (via `@anthropic-ai/claude-agent-sdk`) and OpenAI Codex (via `codex app-server`). Switch between them in the same workspace without creating another worktree: Kōbō starts a fresh session for the target engine and provides an editable handoff with tasks, Git state, diff summary, and recent context.
|
|
20
|
-
- **Live chat**: streaming text, inline Edit/Write diffs, per-turn cards, a compaction-in-progress indicator, infinite scrollback, and a reasoning panel immediately above the message input while Claude or Codex is actively thinking. `/` autocompletes skills and commands, `@` fuzzy-autocompletes worktree file paths, and `Ctrl+F` searches readable messages in the current workspace: selecting a result loads its session and scrolls to it. `Ctrl+K` opens a command palette for common workspace actions. The global search page deep-links to the same location. Sessions retain their engine and can be renamed or permanently deleted with only their linked history. Messages queued while an agent is busy stay scoped to their workspace and session. **Send now** delivers a queued message to an active Claude Code stream or steers the active Codex turn; it remains queued until the engine confirms acceptance. Use `Enter` to send, `Ctrl+Enter` to force delivery while an agent is busy, and `Shift+Enter` or `Ctrl+J` for a new line.
|
|
21
|
-
- **Full MCP toolset (`kobo-tasks`)**: a per-workspace MCP server the agent uses for far more than tasks — task/acceptance-criteria CRUD, starting/stopping the dev server and reading its logs, a unified `get_ticket` (Notion or Sentry), searching past conversations across every workspace, per-session token/cost usage, and a `.ai/thoughts` decision log. Native Claude Code Task tools complement it for lightweight sub-agent coordination. See [`AGENTS.md`](./AGENTS.md) for the full tool list.
|
|
22
|
-
|
|
23
|
-

|
|
24
|
-
- **Git panel**: a Monaco-based diff viewer with **inline file editing** (edit the right-hand panel directly, save with `Ctrl/Cmd+S`, conflict-guarded via sha precondition) and syntax highlighting for PHP, Vue SFCs, JavaScript, TypeScript, and the existing web/config languages. It also provides inline conflict resolution and `Sync` / `Push` / `Commit` / `Open PR` / `Merge ready PR` / `Change PR base` / `Change source branch`. You can write the commit message yourself or ask the active engine to prepare the commit; PR/MR merging always requires a confirmation, then Kōbō can offer to delete the merged remote branch. Edit/Write cards in chat open their exact changed file in the diff viewer. Multi-forge: GitHub (`gh`), GitLab (`glab`), Bitbucket Community (`bkt`), or no forge, auto-detected from the remote and overridable per project.
|
|
14
|
+

|
|
25
15
|
|
|
26
|
-
|
|
27
|
-
- **Dev server panel**: start, stop, and tail logs for a workspace's dev server (Docker or npm) straight from the Tools panel — no need to leave the UI. When a project has no configured server, the panel and MCP tool report that explicitly instead of implying one is running.
|
|
28
|
-
- **Attention indicators**: workspace cards surface CI failures, review-requested changes, and a conflict-aware **ready-to-merge** badge, plus a one-click **Fix CI** button that spawns a fix workspace automatically. Seven PR/MR and CI transitions each have their own audio toggle, sound, and volume; simultaneous transitions play sequentially.
|
|
29
|
-
- **Interactive Q&A**: an agent can pause mid-session to ask a clarifying question through the UI; the workspace surfaces under "Needs Attention" until you answer. Choosing **Other** opens an inline free-form field, and option previews are available on hover. Codex native interactive questions require its `plan` permission mode.
|
|
16
|
+
## Why Kōbō
|
|
30
17
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
- **
|
|
36
|
-
|
|
37
|
-
- **Disk-space purge**: free a merged workspace's disk space without losing its chat history — see [below](#disk-space-purge).
|
|
38
|
-
- **Observability**: a compact session timeline in the right panel shows each session’s duration, tools, tokens, and errors; download a redacted workspace diagnostic JSON for troubleshooting.
|
|
39
|
-
- **Optional integrations**: independently enable or disable Notion (import missions) and Sentry (fix from issue URL) in Settings, with a **Test connection** action for each; local voice transcription uses whisper.cpp.
|
|
40
|
-
- **Installable PWA**: production builds work as a Progressive Web App. Install Kōbō from your browser on desktop or mobile for an app-like window; it signals offline state, WebSocket reconnection, and offers an explicit reload when an update is ready. Live agent activity still requires a connection to the local Kōbō server.
|
|
18
|
+
| | |
|
|
19
|
+
|---|---|
|
|
20
|
+
| **🗂️ Isolated worktrees, two engines** | Every workspace is its own git worktree and branch — parallel sessions never collide. Pick Claude Code or OpenAI Codex per workspace, and switch engines later without losing the worktree: Kōbō hands off tasks, git state, and recent context to a fresh session. |
|
|
21
|
+
| **💬 Live chat, real diff review** | Streaming responses, inline Edit/Write diffs, a reasoning panel, and a Monaco diff viewer with **inline file editing**, conflict resolution, and one-click `Sync` / `Push` / `Open PR` / `Merge`. |
|
|
22
|
+
| **🔁 Auto-loop, cron, wakeups** | Turn an agent loose on the task list: it works through tasks, retries on rate limits, and stops itself when there's nothing left, progress stalls, or it needs you. Cron schedules and one-shot wakeups keep workspaces moving on their own timeline. |
|
|
23
|
+
| **🔀 Create from a PR/MR, or from a ticket** | Pick an open pull/merge request from GitHub, GitLab, or Bitbucket and Kōbō resolves every local conflict for you before spinning up the workspace. Or start straight from a Notion page or a Sentry issue URL. |
|
|
41
24
|
|
|
42
25
|
## Quick start
|
|
43
26
|
|
|
@@ -47,29 +30,35 @@ Requires Node.js ≥ 24.15 and a logged-in Claude Code **or** Codex CLI.
|
|
|
47
30
|
npx @loicngr/kobo@latest
|
|
48
31
|
```
|
|
49
32
|
|
|
50
|
-
|
|
33
|
+
Open <http://localhost:3000>. Data is persisted under `~/.config/kobo/` (override via `KOBO_HOME`).
|
|
34
|
+
|
|
35
|
+
Default port is `3000`; if it's taken, `SERVER_PORT` (checked first) or `PORT` picks another:
|
|
51
36
|
|
|
52
37
|
```bash
|
|
53
38
|
SERVER_PORT=9997 PORT=9998 npx @loicngr/kobo@latest
|
|
54
39
|
```
|
|
55
40
|
|
|
56
|
-
|
|
41
|
+
Kōbō's production build is an installable PWA — use your browser's **Install app** action. Want to run from source or contribute instead? See [`CONTRIBUTING.md`](./CONTRIBUTING.md). Prefer Docker? Jump to [Docker deployment](#docker-deployment).
|
|
57
42
|
|
|
58
|
-
|
|
43
|
+

|
|
59
44
|
|
|
60
|
-
|
|
45
|
+
## Everything else Kōbō does
|
|
61
46
|
|
|
62
|
-
|
|
47
|
+
- **Command palette & search**: `/` autocompletes skills and commands, `@` fuzzy-completes worktree file paths, `Ctrl+F` searches readable messages, `Ctrl+K` opens a command palette. The global search page deep-links to the same spot.
|
|
48
|
+
- **Full MCP toolset (`kobo-tasks`)**: task/acceptance-criteria CRUD, dev server control, a unified `get_ticket` (Notion or Sentry), cross-workspace conversation search, per-session usage, and a `.ai/thoughts` decision log — see [`AGENTS.md`](./AGENTS.md) for the full tool list.
|
|
63
49
|
|
|
64
|
-
|
|
50
|
+

|
|
51
|
+
- **Multi-forge Git panel**: GitHub (`gh`), GitLab (`glab`), or Bitbucket Community (`bkt`), auto-detected from the remote — `Open PR`, `Merge ready PR`, `Change PR base`, `Change source branch`, all from the UI.
|
|
52
|
+
- **Dev server panel**: start, stop, and tail a workspace's dev server (Docker or npm) from the Tools panel.
|
|
53
|
+
- **Attention indicators**: CI failures, review-requested changes, and a conflict-aware **ready-to-merge** badge on every workspace card, plus a one-click **Fix CI** button.
|
|
54
|
+
- **Interactive Q&A**: an agent can pause mid-session to ask you a question through the UI instead of guessing.
|
|
65
55
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
no manual step.
|
|
56
|
+

|
|
57
|
+
- **Quota-aware**: 5-hour / 7-day Claude usage and Codex rate-limit buckets live in the footer; sessions auto-resume after a reset.
|
|
58
|
+
- **Disk-space purge**: reclaim a merged workspace's `node_modules`/`vendor` weight without losing its chat history — see [`CONFIGURATION.md`](./CONFIGURATION.md#auto-purge-worktree-on-pr-merged).
|
|
59
|
+
- **Lifecycle scripts**: shell scripts run automatically on setup, cleanup, and archive, with output streamed into the chat.
|
|
60
|
+
- **Observability**: a per-session timeline (duration, tools, tokens, errors) and a downloadable redacted diagnostic JSON.
|
|
61
|
+
- **Optional integrations**: Notion (import missions) and Sentry (fix from issue URL), each independently toggled with a **Test connection** action; local voice transcription via `whisper.cpp`.
|
|
73
62
|
|
|
74
63
|
## Configuration
|
|
75
64
|
|
|
@@ -77,77 +66,32 @@ The most common knobs:
|
|
|
77
66
|
|
|
78
67
|
| Env var | Default | Purpose |
|
|
79
68
|
|---|---|---|
|
|
80
|
-
| `PORT` | `3000` | HTTP / WebSocket server port (
|
|
81
|
-
| `SERVER_PORT` | none | Preferred override for the server port; takes precedence over `PORT` |
|
|
69
|
+
| `PORT` / `SERVER_PORT` | `3000` | HTTP / WebSocket server port (`SERVER_PORT` takes precedence) |
|
|
82
70
|
| `KOBO_HOME` | `~/.config/kobo` | Data directory (SQLite, settings, voice models) |
|
|
83
71
|
| `NOTION_API_TOKEN` | none | Notion integration token |
|
|
84
72
|
| `ANTHROPIC_API_KEY` | none | Claude Code engine credential (alternative to `claude /login`) |
|
|
85
73
|
| `OPENAI_API_KEY` | none | Codex engine credential (alternative to `codex login`) |
|
|
86
74
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
The full reference (every env var, every setting key, MCP server registration, Notion / Sentry / Voice setup) is in [`CONFIGURATION.md`](./CONFIGURATION.md).
|
|
90
|
-
|
|
91
|
-
## Agent runtimes
|
|
92
|
-
|
|
93
|
-
- **Claude Code.** Authenticate once with `claude /login`. Kōbō calls the embedded SDK directly, so no `claude` binary is required at runtime.
|
|
94
|
-
- **OpenAI Codex.** Run `codex login` or export `OPENAI_API_KEY`. Kōbō spawns a long-lived `codex app-server` subprocess per workspace and bridges its JSON-RPC stream to the same UI.
|
|
95
|
-
|
|
96
|
-
You pick the engine at workspace creation and can switch it later from the workspace header. A switch never attempts to resume a Claude conversation in Codex (or the reverse): it stops the active agent, starts a fresh target-engine session in the same worktree, and lets you review the handoff before launch. The new agent can retrieve further context from prior sessions through the read-only `kobo__read_workspace_events_csv` and `kobo__search_codebase` MCP tools. Both engines share the same task tracking, permission modes, sub-agent panel, and quota footer. The mapping of Kōbō's four permission modes (`plan` / `bypass` / `strict` / `interactive`) to each engine's native sandbox + approval semantics is in [`CONFIGURATION.md`](./CONFIGURATION.md#permission-modes).
|
|
97
|
-
|
|
98
|
-
## Disk-space purge
|
|
99
|
-
|
|
100
|
-
A merged workspace is automatically archived, but its worktree folder usually carries a lot of weight (`node_modules`, `vendor`, build artefacts…). Kōbō frees that space without losing anything queryable:
|
|
101
|
-
|
|
102
|
-
- **Manual**: workspace context menu → *Free disk space (delete worktree)*. The worktree is removed; the chat history and PR metadata stay in the database.
|
|
103
|
-
- **Automatic**: **Settings → Worktrees → Auto-purge worktree on PR merged**. When the pr-watcher sees the OPEN → MERGED transition, it archives **and** purges.
|
|
104
|
-
- **Restore**: recreate the folder yourself (`gh pr checkout <pr>` or `git worktree add <path> <branch>`). The pr-watcher detects the directory reappearing within 30 seconds and re-activates the workspace automatically. No UI action needed.
|
|
105
|
-
- **Permission errors**: if removal hits `EACCES`/`EPERM` (common with Docker containers writing as `root`), Kōbō first tries to auto-recover by `chown`-ing the worktree from a throwaway Docker container. If that isn't possible, it shows a toast with a copy-pasteable manual recovery command. Full troubleshooting (ACL setup, Docker `USER` directive, manual `chown`) is in [`CONFIGURATION.md`](./CONFIGURATION.md#permission-errors-during-purge).
|
|
106
|
-
|
|
107
|
-
## Optional integrations
|
|
75
|
+
Everything else — worktree paths, dev server commands, prompt templates, git conventions, lifecycle scripts, forge selection, permission modes — lives in **Settings**, with per-project values inheriting from global ones. The full reference (every env var, every setting key, MCP server registration, forge/Notion/Sentry/voice setup) is in [`CONFIGURATION.md`](./CONFIGURATION.md).
|
|
108
76
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
- **Notion**: import missions, tasks, and acceptance criteria from a Notion page. Enable it in **Settings → Notion** when you want its creation/import flow, then use **Test connection** to validate its MCP credentials.
|
|
112
|
-
- **Sentry**: paste an issue URL to spawn a fix workspace with the stacktrace, tags, and a TDD workflow. Enable it independently in **Settings → Sentry**; **Test connection** validates its MCP credentials and authenticated identity.
|
|
113
|
-
- **Voice transcription**: local push-to-talk via [`whisper.cpp`](https://github.com/ggml-org/whisper.cpp).
|
|
77
|
+
### Docker
|
|
114
78
|
|
|
115
|
-
See [`CONFIGURATION.md`](./CONFIGURATION.md) for
|
|
79
|
+
An official `Dockerfile` and three ready-to-use Compose files ship in this repository: a quick local test stack, a Traefik-fronted local rehearsal (no domain needed), and a full VPS reference (Traefik + Let's Encrypt, SSH access, optional Docker-socket passthrough). See [`CONFIGURATION.md`](./CONFIGURATION.md#docker-deployment) for every compose file, env var, and volume mount.
|
|
116
80
|
|
|
117
|
-
|
|
81
|
+
### Network access
|
|
118
82
|
|
|
119
|
-
|
|
120
|
-
another device on the same Wi-Fi or LAN:
|
|
83
|
+
Kōbō binds to `127.0.0.1` only by default. Enabling **Settings → Global → Network access** re-binds to the LAN behind a shared token (a QR code makes pairing a phone easy). Plain HTTP — keep it to trusted networks, or front it with HTTPS/a VPN for anything further. Details in [`CONFIGURATION.md`](./CONFIGURATION.md#network-access).
|
|
121
84
|
|
|
122
|
-
|
|
123
|
-
2. Restart Kōbō when prompted, since the server must re-bind to apply the change.
|
|
124
|
-
3. Scan the QR code shown in the Settings panel from your phone, or copy a LAN
|
|
125
|
-
URL and paste the token in the login dialog on the remote device.
|
|
85
|
+
## Agent runtimes
|
|
126
86
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
> put a terminating HTTPS proxy or a VPN (e.g. Tailscale) in front of Kōbō.
|
|
130
|
-
>
|
|
131
|
-
> **Production only.** This protection applies when running a built Kōbō
|
|
132
|
-
> (`npm start` / `npx @loicngr/kobo`). In development (`npm run dev:all`) the Vite
|
|
133
|
-
> dev server is always exposed on the LAN and bypasses the token. See
|
|
134
|
-
> [`CONFIGURATION.md`](./CONFIGURATION.md#production-vs-development-mode-important).
|
|
87
|
+
- **Claude Code**: authenticate once with `claude /login`. Kōbō calls the embedded SDK directly — no `claude` binary needed at runtime.
|
|
88
|
+
- **OpenAI Codex**: run `codex login` or export `OPENAI_API_KEY`. Kōbō spawns a long-lived `codex app-server` subprocess per workspace.
|
|
135
89
|
|
|
136
|
-
|
|
137
|
-
QR code details, and all security caveats.
|
|
90
|
+
Both engines share task tracking, permission modes, the sub-agent panel, and the quota footer. The mapping of Kōbō's four permission modes (`plan` / `bypass` / `strict` / `interactive`) to each engine's native sandbox semantics is in [`CONFIGURATION.md`](./CONFIGURATION.md#permission-modes).
|
|
138
91
|
|
|
139
92
|
## Skill suites
|
|
140
93
|
|
|
141
|
-
Kōbō's auto-generated prompts (review, auto-loop grooming, QA, brainstorming) can target
|
|
142
|
-
|
|
143
|
-
- **[superpowers](https://github.com/obra/superpowers)** (default): a plugin for Claude Code with the brainstorm → spec → plan → execute discipline, TDD, debugging, code review.
|
|
144
|
-
- **[gstack](https://github.com/garrytan/gstack)**: CLI slash commands for navigation, QA, design review, ship pipeline, second-opinion via Codex.
|
|
145
|
-
- **superpowers + gstack**: both, with each used for what it does best.
|
|
146
|
-
- **custom**: write your own prompts.
|
|
147
|
-
|
|
148
|
-
Optionally pair with **[gbrain](https://github.com/garrytan/gbrain)**, a per-project knowledge graph + semantic search exposed as an MCP server. It is inherited automatically from your `~/.claude.json` config.
|
|
149
|
-
|
|
150
|
-
Full install instructions and the prompt-suite differences are in [`CONFIGURATION.md`](./CONFIGURATION.md#skill-suites).
|
|
94
|
+
Kōbō's auto-generated prompts (review, auto-loop grooming, QA, brainstorming) can target **[superpowers](https://github.com/obra/superpowers)** (brainstorm → spec → plan → execute, TDD, debugging), **[gstack](https://github.com/garrytan/gstack)** (slash-command workflows for QA, design review, ship pipelines), both together, or your own custom prompts — selectable in **Settings → Skills**. Pairs optionally with **[gbrain](https://github.com/garrytan/gbrain)** for per-project semantic search. Full setup in [`CONFIGURATION.md`](./CONFIGURATION.md#skill-suites).
|
|
151
95
|
|
|
152
96
|
## Architecture
|
|
153
97
|
|
|
@@ -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
|
};
|