diff --git a/.husky/pre-commit b/.husky/pre-commit
index ab37b584..edbd8e8a 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,3 +1,10 @@
+#!/bin/sh
+# Set up PATH for pnpm if not already in PATH
+if ! command -v pnpm >/dev/null 2>&1; then
+ export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}"
+ export PATH="$PNPM_HOME:$PATH"
+fi
+
# Run linting
pnpm run lint
diff --git a/.iloom/settings.example.json b/.iloom/settings.example.json
index bcf574af..db4bda03 100644
--- a/.iloom/settings.example.json
+++ b/.iloom/settings.example.json
@@ -1,5 +1,8 @@
{
"mainBranch": "main",
+ "git": {
+ "commitTimeout": 60000
+ },
"workflows": {
"issue": {
"permissionMode": "default",
diff --git a/DEBUGGING_MCP.md b/DEBUGGING_MCP.md
new file mode 100644
index 00000000..24de5a24
--- /dev/null
+++ b/DEBUGGING_MCP.md
@@ -0,0 +1,178 @@
+# Debugging iloom MCP Servers
+
+When you see "issue_management · ✘ failed" in Claude Code, it means the MCP server isn't starting properly. Here's how to debug it:
+
+## 1. Check MCP Server Logs
+
+The MCP server logs errors to stderr. To see them:
+
+```bash
+# Navigate to your project with iloom configured
+cd /path/to/your/project
+
+# Start a loom (this generates the MCP config)
+il start YOUR-ISSUE-123
+
+# The MCP server is launched by Claude Code
+# Check Claude Code's output panel for errors
+```
+
+## 2. Test MCP Server Manually
+
+You can test the MCP server directly:
+
+```bash
+# Set required environment variables for Jira
+export ISSUE_PROVIDER=jira
+export JIRA_HOST="https://yourcompany.atlassian.net"
+export JIRA_USERNAME="your.email@company.com"
+export JIRA_API_TOKEN="your-api-token"
+export JIRA_PROJECT_KEY="PROJ"
+
+# Optional: transition mappings
+export JIRA_TRANSITION_MAPPINGS='{"In Review":"Start Review"}'
+
+# Run the MCP server
+node dist/mcp/issue-management-server.js
+```
+
+The server should start and output:
+```
+Starting Issue Management MCP Server...
+Environment validated
+Issue management provider: jira
+```
+
+## 3. Common Issues
+
+### Missing Environment Variables
+
+**Error:** `Missing required environment variables for Jira provider: ...`
+
+**Solution:** Ensure all required Jira settings are in your `.iloom/settings.local.json`:
+
+```json
+{
+ "issueManagement": {
+ "jira": {
+ "apiToken": "your-api-token-here"
+ }
+ }
+}
+```
+
+And in `.iloom/settings.json`:
+```json
+{
+ "issueManagement": {
+ "provider": "jira",
+ "jira": {
+ "host": "https://yourcompany.atlassian.net",
+ "username": "your.email@company.com",
+ "projectKey": "PROJ"
+ }
+ }
+}
+```
+
+### Invalid Provider
+
+**Error:** `Invalid ISSUE_PROVIDER: ... Must be 'github', 'linear', or 'jira'`
+
+**Solution:** Check that `issueManagement.provider` in your settings is set to one of the supported values.
+
+### API Authentication Failure
+
+**Error:** `Jira API error (401): ...`
+
+**Solution:**
+1. Verify your Jira API token is correct
+2. Generate a new token at: https://id.atlassian.com/manage-profile/security/api-tokens
+3. Ensure the token has proper permissions
+
+**Error:** `Jira API error (403): ...`
+
+**Solution:** Your user account may not have permission to access the Jira project. Contact your Jira administrator.
+
+## 4. Check MCP Configuration
+
+iloom generates MCP configuration that Claude Code uses. You can inspect it:
+
+```bash
+# Check what MCP config iloom would generate
+il start --help # This won't actually start, but shows the config
+
+# Or manually test config generation:
+node -e "
+const { generateIssueManagementMcpConfig } = require('./dist/utils/mcp.js');
+const { loadSettings } = require('./dist/lib/SettingsManager.js');
+
+(async () => {
+ const settings = await loadSettings();
+ const config = await generateIssueManagementMcpConfig(
+ 'issue',
+ null,
+ 'jira',
+ settings
+ );
+ console.log(JSON.stringify(config, null, 2));
+})();
+"
+```
+
+## 5. Verbose Logging
+
+To see more detailed logs, set the DEBUG environment variable before starting Claude Code:
+
+```bash
+# On macOS/Linux
+export DEBUG=iloom:*
+
+# On Windows (PowerShell)
+$env:DEBUG="iloom:*"
+
+# Then start Claude Code
+```
+
+## 6. Test Jira Connection
+
+Test that iloom can connect to Jira:
+
+```bash
+# This will attempt to fetch an issue
+il start PROJ-123
+
+# If successful, you should see:
+# ✓ Issue found: PROJ-123
+```
+
+## 7. Claude Code MCP Settings
+
+Check that Claude Code is configured to use iloom's MCP servers. The config should be in `~/.claude/settings.json` or your project's `.claude/settings.local.json`.
+
+Look for:
+```json
+{
+ "mcpServers": {
+ "issue_management": {
+ "transport": "stdio",
+ "command": "node",
+ "args": ["/path/to/iloom/dist/mcp/issue-management-server.js"],
+ "env": {
+ "ISSUE_PROVIDER": "jira",
+ "JIRA_HOST": "...",
+ // ...other Jira env vars
+ }
+ }
+ }
+}
+```
+
+## 8. Still Having Issues?
+
+If the MCP server still isn't working:
+
+1. **Check iloom version:** Run `il --version` and ensure you have the latest version
+2. **Reinstall dependencies:** `cd /path/to/iloom && pnpm install && pnpm build`
+3. **Check Node version:** MCP servers require Node.js 18 or later
+4. **File an issue:** Include the full error output and your (redacted) settings
diff --git a/README.md b/README.md
index b2b798d1..65725eb0 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,15 @@ Each loom is a fully isolated container for your work:
* **Environment Variables:** Each loom has its own environment files (`.env`, `.env.local`, `.env.development`, `.env.development.local`). Uses `development` by default, override with `DOTENV_FLOW_NODE_ENV`. See [Secret Storage Limitations](#multi-language-project-support) for frameworks with encrypted credentials.
+ When inside a loom shell (`il shell`), the following environment variables are automatically set:
+
+ | Variable | Description | Example |
+ |----------|-------------|---------|
+ | `ILOOM_LOOM` | Loom identifier for PS1 customization | `issue-87` |
+ | `ILOOM_COLOR_HEX` | Hex color assigned to this loom (if available) | `#dcebff` |
+
+ `ILOOM_COLOR_HEX` is useful for downstream tools that want to visually distinguish looms. For example, a Vite app can read it via `import.meta.env.VITE_ILOOM_COLOR_HEX` to tint the UI. See [Vite Integration Guide](docs/vite-iloom-color.md) for details.
+
* **Unique Runtime:**
* **Web Apps:** Runs on a deterministic port (e.g., base port 3000 + issue #25 = 3025).
@@ -387,21 +396,203 @@ Integrations
### Issue Trackers
-iloom supports the tools you already use. Unless you use JIRA.
+iloom supports multiple issue tracking providers to fit your team's workflow.
| **Provider** | **Setup** | **Notes** |
|--------------|-----------|-----------|
| **GitHub** | `gh auth login` | Default. Supports Issues and Pull Requests automatically. |
| **Linear** | `il init` | Requires API token. Supports full read/write on Linear issues. |
+| **Jira** | Configure in `.iloom/settings.json` | Atlassian Cloud. Requires API token. See [Jira Setup](#jira-setup) below. |
+
+### Version Control Providers
+
+Choose which platform hosts your pull requests and code reviews.
+
+| **Provider** | **Setup** | **Notes** |
+|--------------|-----------|-----------|
+| **GitHub** | `gh auth login` | Default. Integrated with GitHub Issues. |
+| **BitBucket** | Configure in `.iloom/settings.json` | Atlassian Cloud. Requires API token. See [BitBucket Setup](#bitbucket-setup) below. |
+
+### Jira Setup
+
+To use Jira as your issue tracker, add this configuration:
+
+**.iloom/settings.json (Committed)**
+```json
+{
+ "issueManagement": {
+ "provider": "jira",
+ "jira": {
+ "host": "https://yourcompany.atlassian.net",
+ "username": "your.email@company.com",
+ "projectKey": "PROJ",
+ "boardId": "123",
+ "transitionMappings": {
+ "In Review": "Start Review"
+ }
+ }
+ }
+}
+```
+
+**.iloom/settings.local.json (Gitignored - Never commit this file)**
+```json
+{
+ "issueManagement": {
+ "jira": {
+ "apiToken": "your-jira-api-token-here"
+ }
+ }
+}
+```
+
+**Generate a Jira API Token:**
+1. Visit https://id.atlassian.com/manage-profile/security/api-tokens
+2. Click "Create API token"
+3. Copy the token to `.iloom/settings.local.json`
+
+**Configuration Options:**
+- `host`: Your Jira Cloud instance URL
+- `username`: Your Jira email address
+- `apiToken`: API token (store in settings.local.json only!)
+- `projectKey`: Jira project key (e.g., "PROJ", "ENG")
+- `boardId`: (Optional) Board ID for sprint/workflow operations
+- `transitionMappings`: (Optional) Map iloom states to your Jira workflow transition names
+
+### BitBucket Setup
+
+To use BitBucket for pull requests, add this configuration:
+
+**.iloom/settings.json (Committed)**
+```json
+{
+ "versionControl": {
+ "provider": "bitbucket",
+ "bitbucket": {
+ "username": "your-bitbucket-username",
+ "workspace": "your-workspace",
+ "repoSlug": "your-repo"
+ }
+ },
+ "mergeBehavior": {
+ "mode": "bitbucket-pr"
+ }
+}
+```
+
+**.iloom/settings.local.json (Gitignored - Never commit this file)**
+```json
+{
+ "versionControl": {
+ "bitbucket": {
+ "apiToken": "your-bitbucket-api-token"
+ }
+ }
+}
+```
+
+**Generate a BitBucket API Token:**
+1. Visit https://bitbucket.org/account/settings/app-passwords/
+2. Click "Create API token" (Note: App passwords were deprecated September 2025)
+3. Grant permissions: `repository:read`, `repository:write`, `pullrequest:read`, `pullrequest:write`
+4. Copy the token to `.iloom/settings.local.json`
+
+**Configuration Options:**
+- `username`: Your BitBucket username
+- `apiToken`: API token (store in settings.local.json only!)
+- `workspace`: (Optional) BitBucket workspace, auto-detected from git remote if not provided
+- `repoSlug`: (Optional) Repository slug, auto-detected from git remote if not provided
+- `reviewers`: (Optional) Array of BitBucket usernames to automatically add as PR reviewers. Usernames are resolved to BitBucket account IDs at PR creation time. Unresolved usernames are logged as warnings but don't block PR creation.
+
+**Example with Reviewers:**
+```json
+{
+ "versionControl": {
+ "provider": "bitbucket",
+ "bitbucket": {
+ "username": "your-bitbucket-username",
+ "reviewers": [
+ "alice.jones",
+ "bob.smith"
+ ]
+ }
+ },
+ "mergeBehavior": {
+ "mode": "bitbucket-pr"
+ }
+}
+```
+
+### Jira + BitBucket Together
+
+Use Jira for issues and BitBucket for pull requests:
+
+**.iloom/settings.json**
+```json
+{
+ "issueManagement": {
+ "provider": "jira",
+ "jira": {
+ "host": "https://yourcompany.atlassian.net",
+ "username": "your.email@company.com",
+ "projectKey": "PROJ"
+ }
+ },
+ "versionControl": {
+ "provider": "bitbucket",
+ "bitbucket": {
+ "username": "your-bitbucket-username"
+ }
+ },
+ "mergeBehavior": {
+ "mode": "bitbucket-pr"
+ }
+}
+```
+
+**.iloom/settings.local.json**
+```json
+{
+ "issueManagement": {
+ "jira": {
+ "apiToken": "your-jira-api-token"
+ }
+ },
+ "versionControl": {
+ "bitbucket": {
+ "apiToken": "your-bitbucket-api-token"
+ }
+ }
+}
+```
### IDE Support
iloom creates isolated workspace settings for your editor. Color synchronization (visual context) only works best VS Code-based editors.
* **Supported:** VS Code, Cursor, Windsurf, Antigravity, WebStorm, IntelliJ, Sublime Text.
-
+
* **Config:** Set your preference via `il init` or `il start --set ide.type=cursor`.
-
+
+### Git Operation Settings
+
+Configure git operation timeouts for projects with long-running pre-commit hooks.
+
+**.iloom/settings.json**
+```json
+{
+ "git": {
+ "commitTimeout": 120000
+ }
+}
+```
+
+| Setting | Default | Range | Description |
+|---------|---------|-------|-------------|
+| `git.commitTimeout` | 60000 (60s) | 1000-600000 | Timeout in milliseconds for git commit operations. Increase if pre-commit hooks (linting, tests, type checking) exceed the default timeout. |
+
+**When to increase:** If you see timeout errors during `il commit` or `il finish`, your pre-commit hooks are taking longer than the default 60 seconds. Set a higher value based on your typical hook duration.
+
Advanced Features
-----------------
diff --git a/docs/vite-iloom-color.md b/docs/vite-iloom-color.md
new file mode 100644
index 00000000..a5cf0d1a
--- /dev/null
+++ b/docs/vite-iloom-color.md
@@ -0,0 +1,101 @@
+# Using ILOOM_COLOR_HEX in a Vite App
+
+When you run a dev server inside a loom shell (`il shell`), the `ILOOM_COLOR_HEX` environment variable is automatically set to the hex color assigned to that loom (e.g., `#dcebff`). This lets your app visually distinguish which loom it's running in.
+
+## Setup
+
+### 1. Prefix the variable for Vite
+
+Vite only exposes env vars that start with `VITE_`. Add a `.env.local` (or use your existing one) in the project root:
+
+```bash
+# .env.local
+VITE_ILOOM_COLOR_HEX=$ILOOM_COLOR_HEX
+```
+
+Or, if you use `il shell` and then start the dev server manually, you can set it inline:
+
+```bash
+VITE_ILOOM_COLOR_HEX=$ILOOM_COLOR_HEX pnpm dev
+```
+
+### 2. Access it in your app
+
+```ts
+// src/main.ts (or any client-side file)
+const loomColor = import.meta.env.VITE_ILOOM_COLOR_HEX
+
+if (loomColor) {
+ document.documentElement.style.setProperty('--loom-color', loomColor)
+}
+```
+
+### 3. Use the CSS variable
+
+```css
+/* src/styles.css */
+:root {
+ --loom-color: transparent; /* fallback when not in a loom */
+}
+
+body {
+ border-top: 4px solid var(--loom-color);
+}
+```
+
+## Alternative: Use `define` in vite.config.ts
+
+If you don't want an extra `.env.local` entry, you can inject the value at build time:
+
+```ts
+// vite.config.ts
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ define: {
+ __ILOOM_COLOR_HEX__: JSON.stringify(process.env.ILOOM_COLOR_HEX ?? ''),
+ },
+})
+```
+
+Then in your app:
+
+```ts
+declare const __ILOOM_COLOR_HEX__: string
+
+if (__ILOOM_COLOR_HEX__) {
+ document.documentElement.style.setProperty('--loom-color', __ILOOM_COLOR_HEX__)
+}
+```
+
+## React Example
+
+```tsx
+// src/components/LoomIndicator.tsx
+function LoomIndicator() {
+ const color = import.meta.env.VITE_ILOOM_COLOR_HEX
+ if (!color) return null
+
+ return (
+
+ )
+}
+```
+
+## How It Works
+
+1. `il shell ` reads the loom's metadata (stored in `~/.config/iloom-ai/looms/`) and exports `ILOOM_COLOR_HEX` into the shell environment.
+2. Any process spawned from that shell inherits the variable.
+3. Vite picks it up (via `VITE_` prefix or `define`) and makes it available to client code.
+
+When you're not inside a loom shell, the variable is simply absent and your fallback styles apply.
diff --git a/package.json b/package.json
index 7acf79a8..97061f3d 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,7 @@
"deepmerge": "^4.3.1",
"dotenv-flow": "^4.1.0",
"execa": "^8.0.1",
+ "extended-markdown-adf-parser": "^2.4.0",
"fast-glob": "^3.3.3",
"fs-extra": "^11.1.1",
"handlebars": "^4.7.8",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6623a64c..ca64519e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
execa:
specifier: ^8.0.1
version: 8.0.1
+ extended-markdown-adf-parser:
+ specifier: ^2.4.0
+ version: 2.4.0
fast-glob:
specifier: ^3.3.3
version: 3.3.3
@@ -579,6 +582,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@types/debug@4.1.12':
+ resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -598,12 +604,21 @@ packages:
'@types/jsonfile@6.1.4':
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@20.19.14':
resolution: {integrity: sha512-gqiKWld3YIkmtrrg9zDvg9jfksZCcPywXVN7IauUGhilwGV/yOyeUsvpR796m/Jye0zUzMXPKe8Ct1B79A7N5Q==}
'@types/through@0.0.33':
resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==}
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
'@typescript-eslint/eslint-plugin@8.43.0':
resolution: {integrity: sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -720,9 +735,20 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+ ajv@8.17.1:
+ resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
+
ansi-escapes@4.3.2:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
@@ -753,6 +779,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -811,6 +840,9 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -823,6 +855,9 @@ packages:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
chardet@2.1.0:
resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==}
@@ -912,6 +947,9 @@ packages:
supports-color:
optional: true
+ decode-named-character-reference@1.2.0:
+ resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
+
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@@ -930,6 +968,13 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
dotenv-flow@4.1.0:
resolution: {integrity: sha512-0cwP9jpQBQfyHwvE0cRhraZMkdV45TQedA8AAUZMsFzvmLcQyc1HPv+oX0OOYwLFjIlvgVepQ+WuQHbqDaHJZg==}
engines: {node: '>= 12.0.0'}
@@ -988,6 +1033,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
@@ -1083,6 +1132,13 @@ packages:
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
engines: {node: '>= 18'}
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
+ extended-markdown-adf-parser@2.4.0:
+ resolution: {integrity: sha512-tTRkUUAJCCFvdnxJSGgnOoyUBdyNueeDpr2x/EhohO62/JZzQw+Wa+ZynlpbPWjONL9vdp0n2uRFxnaO02V72g==}
+ engines: {node: '>=20.11.1', yarn: '>=4.7.0'}
+
fast-check@3.23.2:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
@@ -1103,9 +1159,15 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ fast-uri@3.1.0:
+ resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
+ fault@2.0.1:
+ resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1148,6 +1210,10 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
+ format@0.2.2:
+ resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
+ engines: {node: '>=0.4.x'}
+
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@@ -1318,6 +1384,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -1372,6 +1442,9 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -1417,6 +1490,9 @@ packages:
resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==}
engines: {node: '>=12'}
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -1433,10 +1509,49 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
+ markdown-table@3.0.4:
+ resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdast-util-find-and-replace@3.0.2:
+ resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+ mdast-util-from-markdown@2.0.2:
+ resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
+
+ mdast-util-frontmatter@2.0.1:
+ resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==}
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+ mdast-util-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
+
+ mdast-util-gfm-table@2.0.0:
+ resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+ mdast-util-gfm@3.1.0:
+ resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@@ -1455,6 +1570,93 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-extension-frontmatter@2.0.0:
+ resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==}
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+ micromark-extension-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+ micromark-extension-gfm-table@2.1.1:
+ resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+ micromark-extension-gfm@3.0.0:
+ resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -1712,6 +1914,25 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
+ remark-frontmatter@5.0.0:
+ resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
+
+ remark-gfm@4.0.1:
+ resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
+ remark@15.0.1:
+ resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -1960,6 +2181,9 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
@@ -2027,6 +2251,21 @@ packages:
unfetch@4.2.0:
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.0.0:
+ resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
+
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -2045,6 +2284,12 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -2169,6 +2414,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
snapshots:
'@ampproject/remapping@2.3.0':
@@ -2509,6 +2757,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.50.1':
optional: true
+ '@types/debug@4.1.12':
+ dependencies:
+ '@types/ms': 2.1.0
+
'@types/estree@1.0.8': {}
'@types/fs-extra@11.0.4':
@@ -2531,6 +2783,12 @@ snapshots:
dependencies:
'@types/node': 20.19.14
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/ms@2.1.0': {}
+
'@types/node@20.19.14':
dependencies:
undici-types: 6.21.0
@@ -2539,6 +2797,8 @@ snapshots:
dependencies:
'@types/node': 20.19.14
+ '@types/unist@3.0.3': {}
+
'@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0)(typescript@5.9.2)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@@ -2712,6 +2972,10 @@ snapshots:
acorn@8.15.0: {}
+ ajv-formats@3.0.1(ajv@8.17.1):
+ optionalDependencies:
+ ajv: 8.17.1
+
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -2719,6 +2983,13 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
+ ajv@8.17.1:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.0
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
ansi-escapes@4.3.2:
dependencies:
type-fest: 0.21.3
@@ -2739,6 +3010,8 @@ snapshots:
assertion-error@2.0.1: {}
+ bail@2.0.2: {}
+
balanced-match@1.0.2: {}
base64-js@1.5.1: {}
@@ -2813,6 +3086,8 @@ snapshots:
callsites@3.1.0: {}
+ ccount@2.0.1: {}
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -2828,6 +3103,8 @@ snapshots:
chalk@5.6.2: {}
+ character-entities@2.0.2: {}
+
chardet@2.1.0: {}
check-error@2.1.1: {}
@@ -2891,6 +3168,10 @@ snapshots:
dependencies:
ms: 2.1.3
+ decode-named-character-reference@1.2.0:
+ dependencies:
+ character-entities: 2.0.2
+
deep-eql@5.0.2: {}
deep-is@0.1.4: {}
@@ -2903,6 +3184,12 @@ snapshots:
depd@2.0.0: {}
+ dequal@2.0.3: {}
+
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
dotenv-flow@4.1.0:
dependencies:
dotenv: 16.6.1
@@ -2970,6 +3257,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ escape-string-regexp@5.0.0: {}
+
eslint-config-prettier@10.1.8(eslint@9.35.0):
dependencies:
eslint: 9.35.0
@@ -3112,6 +3401,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extend@3.0.2: {}
+
+ extended-markdown-adf-parser@2.4.0:
+ dependencies:
+ ajv: 8.17.1
+ ajv-formats: 3.0.1(ajv@8.17.1)
+ micromark: 4.0.2
+ remark: 15.0.1
+ remark-frontmatter: 5.0.0
+ remark-gfm: 4.0.1
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
fast-check@3.23.2:
dependencies:
pure-rand: 6.1.0
@@ -3132,10 +3437,16 @@ snapshots:
fast-levenshtein@2.0.6: {}
+ fast-uri@3.1.0: {}
+
fastq@1.19.1:
dependencies:
reusify: 1.1.0
+ fault@2.0.1:
+ dependencies:
+ format: 0.2.2
+
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -3184,6 +3495,8 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
+ format@0.2.2: {}
+
forwarded@0.2.0: {}
fresh@2.0.0: {}
@@ -3343,6 +3656,8 @@ snapshots:
is-number@7.0.0: {}
+ is-plain-obj@4.1.0: {}
+
is-promise@4.0.0: {}
is-stream@3.0.0: {}
@@ -3397,6 +3712,8 @@ snapshots:
json-schema-traverse@0.4.1: {}
+ json-schema-traverse@1.0.0: {}
+
json-stable-stringify-without-jsonify@1.0.1: {}
jsonc-parser@3.3.1: {}
@@ -3440,6 +3757,8 @@ snapshots:
chalk: 5.6.2
is-unicode-supported: 1.3.0
+ longest-streak@3.1.0: {}
+
loupe@3.2.1: {}
lru-cache@10.4.3: {}
@@ -3458,8 +3777,123 @@ snapshots:
dependencies:
semver: 7.7.2
+ markdown-table@3.0.4: {}
+
math-intrinsics@1.1.0: {}
+ mdast-util-find-and-replace@3.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ mdast-util-from-markdown@2.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.2.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-frontmatter@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ escape-string-regexp: 5.0.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ micromark-extension-frontmatter: 2.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-find-and-replace: 3.0.2
+ micromark-util-character: 2.1.1
+
+ mdast-util-gfm-footnote@2.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-table@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ markdown-table: 3.0.4
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm@3.1.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-gfm-autolink-literal: 2.0.1
+ mdast-util-gfm-footnote: 2.1.0
+ mdast-util-gfm-strikethrough: 2.0.0
+ mdast-util-gfm-table: 2.0.0
+ mdast-util-gfm-task-list-item: 2.0.0
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.0.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
media-typer@1.1.0: {}
memfs@4.49.0:
@@ -3477,6 +3911,204 @@ snapshots:
merge2@1.4.1: {}
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.2.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-frontmatter@2.0.0:
+ dependencies:
+ fault: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-footnote@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-table@2.1.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm@3.0.0:
+ dependencies:
+ micromark-extension-gfm-autolink-literal: 2.1.0
+ micromark-extension-gfm-footnote: 2.1.0
+ micromark-extension-gfm-strikethrough: 2.1.0
+ micromark-extension-gfm-table: 2.1.1
+ micromark-extension-gfm-tagfilter: 2.0.0
+ micromark-extension-gfm-task-list-item: 2.1.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.2.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.12
+ debug: 4.4.3
+ decode-named-character-reference: 1.2.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@@ -3698,6 +4330,52 @@ snapshots:
readdirp@4.1.2: {}
+ remark-frontmatter@5.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-frontmatter: 2.0.1
+ micromark-extension-frontmatter: 2.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-gfm@4.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-gfm: 3.1.0
+ micromark-extension-gfm: 3.0.0
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.2
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
+ remark@15.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ require-from-string@2.0.2: {}
+
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@@ -3971,6 +4649,8 @@ snapshots:
tree-kill@1.2.2: {}
+ trough@2.2.0: {}
+
ts-api-utils@2.1.0(typescript@5.9.2):
dependencies:
typescript: 5.9.2
@@ -4037,6 +4717,35 @@ snapshots:
unfetch@4.2.0: {}
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
universalify@2.0.1: {}
unpipe@1.0.0: {}
@@ -4049,6 +4758,16 @@ snapshots:
vary@1.1.2: {}
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
vite-node@2.1.9(@types/node@20.19.14):
dependencies:
cac: 6.7.14
@@ -4173,3 +4892,5 @@ snapshots:
zod: 3.25.76
zod@3.25.76: {}
+
+ zwitch@2.0.4: {}
diff --git a/src/cli.ts b/src/cli.ts
index f080aa32..39a0b25b 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -538,6 +538,7 @@ program
.option('-n, --dry-run', 'Preview actions without executing')
.option('--pr ', 'Treat input as PR number', parseFloat)
.option('--skip-build', 'Skip post-merge build verification')
+ .option('--skip-to-pr', 'Skip rebase/validation/commit, go directly to PR creation (debug)')
.option('--no-browser', 'Skip opening PR in browser (github-pr mode only)')
.option('--cleanup', 'Clean up worktree after finishing (default in local mode)')
.option('--no-cleanup', 'Keep worktree after finishing')
@@ -1587,6 +1588,93 @@ program
process.exit(0)
})
+// Debug commands - only registered when debug mode is enabled
+if (process.env.ILOOM_DEBUG === 'true') {
+ const debugCommand = program
+ .command('debug')
+ .description('Debug tools (only available in debug mode)')
+
+ const bitbucketDebugCommand = debugCommand
+ .command('bitbucket')
+ .description('BitBucket debug tools')
+
+ bitbucketDebugCommand
+ .command('resolve-reviewer-ids')
+ .description('Resolve configured reviewer usernames to BitBucket account IDs')
+ .action(async () => {
+ try {
+ const settingsManager = new SettingsManager()
+ const settings = await settingsManager.loadSettings()
+
+ const bitbucketConfig = settings.versionControl?.bitbucket
+ if (!bitbucketConfig) {
+ logger.error('BitBucket configuration not found in settings')
+ logger.info('Configure versionControl.bitbucket in .iloom/settings.json')
+ process.exit(1)
+ }
+
+ if (!bitbucketConfig.username) {
+ logger.error('BitBucket username not configured')
+ logger.info('Configure versionControl.bitbucket.username in .iloom/settings.json')
+ process.exit(1)
+ }
+
+ if (!bitbucketConfig.apiToken) {
+ logger.error('BitBucket API token not configured')
+ logger.info('Configure versionControl.bitbucket.apiToken in .iloom/settings.local.json')
+ process.exit(1)
+ }
+
+ const reviewers = bitbucketConfig.reviewers ?? []
+ if (reviewers.length === 0) {
+ logger.warn('No reviewers configured in settings')
+ logger.info('Configure versionControl.bitbucket.reviewers in .iloom/settings.json')
+ console.log(JSON.stringify({}, null, 2))
+ process.exit(0)
+ }
+
+ // Get workspace from config or auto-detect from git remote
+ let workspace = bitbucketConfig.workspace
+ if (!workspace) {
+ const { parseGitRemotes } = await import('./utils/remote.js')
+ const remotes = await parseGitRemotes()
+ const bitbucketRemote = remotes.find(r => r.url.includes('bitbucket.org'))
+ if (!bitbucketRemote) {
+ logger.error('Could not auto-detect BitBucket workspace from git remote')
+ logger.info('Configure versionControl.bitbucket.workspace in .iloom/settings.json')
+ process.exit(1)
+ }
+ workspace = bitbucketRemote.owner
+ }
+
+ // At this point workspace is guaranteed to be a string (either from config or auto-detected)
+ const resolvedWorkspace = workspace
+
+ // Create BitBucket API client and resolve reviewer IDs
+ const { BitBucketApiClient } = await import('./lib/providers/bitbucket/BitBucketApiClient.js')
+ const apiClient = new BitBucketApiClient({
+ username: bitbucketConfig.username,
+ apiToken: bitbucketConfig.apiToken,
+ workspace: resolvedWorkspace,
+ })
+
+ const resolvedMap = await apiClient.findUsersByUsername(resolvedWorkspace, reviewers)
+
+ // Convert Map to plain object for JSON output
+ const result: Record = {}
+ for (const [username, accountId] of resolvedMap) {
+ result[username] = accountId
+ }
+
+ console.log(JSON.stringify(result, null, 2))
+ process.exit(0)
+ } catch (error) {
+ logger.error(`Failed to resolve reviewer IDs: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ process.exit(1)
+ }
+ })
+}
+
// Parse CLI arguments (only when run directly, not when imported for testing)
// Resolve symlinks to handle npm link and global installs
const isRunDirectly = process.argv[1] && ((): boolean => {
diff --git a/src/commands/commit.ts b/src/commands/commit.ts
index ced2db2b..b688430c 100644
--- a/src/commands/commit.ts
+++ b/src/commands/commit.ts
@@ -134,7 +134,7 @@ export class CommitCommand {
// Step 6: Load settings to get issue prefix
const settings = await this.settingsManager.loadSettings(worktreePath)
const providerType = settings.issueManagement?.provider ?? 'github'
- const issuePrefix = IssueManagementProviderFactory.create(providerType).issuePrefix
+ const issuePrefix = IssueManagementProviderFactory.create(providerType, settings).issuePrefix
// Determine whether to skip pre-commit hooks:
// - With --wip-commit: always skip hooks (quick WIP commit)
@@ -160,6 +160,7 @@ export class CommitCommand {
skipVerifySilent: input.wipCommit === true, // Don't warn for --wip-commit
noReview: input.noReview ?? false,
trailerType,
+ timeout: settings.git?.commitTimeout,
...(commitMessage && { message: commitMessage }),
...(detected.issueNumber !== undefined && { issueNumber: detected.issueNumber }),
}
@@ -261,10 +262,10 @@ export class CommitCommand {
if (issueNumber !== null) {
logger.debug(`Auto-detected issue #${issueNumber} from directory: ${currentDir}`)
- // Try to get issue number from metadata for more accuracy
+ // Try to get issue key from metadata for more accuracy (canonical case)
const metadata = await this.metadataManager.readMetadata(worktreePath)
return {
- issueNumber: metadata?.issue_numbers?.[0] ?? issueNumber,
+ issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? issueNumber,
loomType: metadata?.issueType ?? 'issue',
}
}
@@ -280,7 +281,7 @@ export class CommitCommand {
const metadata = await this.metadataManager.readMetadata(worktreePath)
return {
- issueNumber: metadata?.issue_numbers?.[0] ?? branchIssueNumber,
+ issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? branchIssueNumber,
loomType: metadata?.issueType ?? 'issue',
}
}
@@ -294,8 +295,8 @@ export class CommitCommand {
let resolvedIssueNumber: string | number | undefined
const loomType = metadata?.issueType ?? 'branch'
- if (loomType === 'issue' && metadata?.issue_numbers?.[0]) {
- resolvedIssueNumber = metadata.issue_numbers[0]
+ if (loomType === 'issue' && (metadata?.issueKey || metadata?.issue_numbers?.[0])) {
+ resolvedIssueNumber = metadata?.issueKey ?? metadata?.issue_numbers?.[0]
} else if (loomType === 'pr' && metadata?.pr_numbers?.[0]) {
resolvedIssueNumber = metadata.pr_numbers[0]
}
diff --git a/src/commands/dev-server.test.ts b/src/commands/dev-server.test.ts
index a565181e..b8ecf8ed 100644
--- a/src/commands/dev-server.test.ts
+++ b/src/commands/dev-server.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DevServerCommand } from './dev-server.js'
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
+import { MetadataManager } from '../lib/MetadataManager.js'
import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js'
import { DevServerManager } from '../lib/DevServerManager.js'
import { SettingsManager } from '../lib/SettingsManager.js'
@@ -12,6 +13,7 @@ import fs from 'fs-extra'
// Mock dependencies
vi.mock('../lib/GitWorktreeManager.js')
+vi.mock('../lib/MetadataManager.js')
vi.mock('../lib/ProjectCapabilityDetector.js')
vi.mock('../lib/DevServerManager.js')
vi.mock('../utils/IdentifierParser.js')
@@ -40,6 +42,7 @@ vi.mock('../utils/logger.js', () => ({
describe('DevServerCommand', () => {
let command: DevServerCommand
let mockGitWorktreeManager: GitWorktreeManager
+ let mockMetadataManager: MetadataManager
let mockCapabilityDetector: ProjectCapabilityDetector
let mockDevServerManager: DevServerManager
let mockIdentifierParser: IdentifierParser
@@ -54,11 +57,17 @@ describe('DevServerCommand', () => {
beforeEach(() => {
mockGitWorktreeManager = new GitWorktreeManager()
+ mockMetadataManager = new MetadataManager()
mockCapabilityDetector = new ProjectCapabilityDetector()
mockDevServerManager = new DevServerManager()
mockIdentifierParser = new IdentifierParser(mockGitWorktreeManager)
mockSettingsManager = new SettingsManager()
+ // Mock MetadataManager - default to returning metadata with color
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ colorHex: '#dcebff',
+ })
+
// Mock DevServerManager methods
vi.mocked(mockDevServerManager.isServerRunning).mockResolvedValue(false)
vi.mocked(mockDevServerManager.runServerForeground).mockImplementation(
@@ -83,7 +92,8 @@ describe('DevServerCommand', () => {
mockCapabilityDetector,
mockIdentifierParser,
mockDevServerManager,
- mockSettingsManager
+ mockSettingsManager,
+ mockMetadataManager
)
})
@@ -458,7 +468,7 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
- { DATABASE_URL: 'postgres://test', API_KEY: 'secret' }
+ expect.objectContaining({ DATABASE_URL: 'postgres://test', API_KEY: 'secret', ILOOM_LOOM: '87' })
)
})
@@ -475,7 +485,7 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
- {}
+ expect.objectContaining({ ILOOM_LOOM: '87' })
)
})
@@ -490,7 +500,7 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
- {}
+ expect.objectContaining({ ILOOM_LOOM: '87' })
)
})
@@ -513,7 +523,7 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
- {}
+ expect.objectContaining({ ILOOM_LOOM: '87' })
)
})
@@ -533,4 +543,77 @@ describe('DevServerCommand', () => {
expect(mockDevServerManager.runServerForeground).toHaveBeenCalled()
})
})
+
+ describe('loom environment variables', () => {
+ beforeEach(() => {
+ vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({
+ type: 'issue',
+ number: 87,
+ originalInput: '87',
+ })
+
+ vi.mocked(mockGitWorktreeManager.findWorktreeForIssue).mockResolvedValue(mockWorktree)
+
+ const mockCapabilities: ProjectCapabilities = {
+ capabilities: ['web'],
+ binEntries: {},
+ }
+ vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue(mockCapabilities)
+
+ vi.mocked(fs.pathExists).mockResolvedValue(true)
+ vi.mocked(fs.readFile).mockResolvedValue('PORT=3087\n')
+ })
+
+ it('should set ILOOM_LOOM env var to original input', async () => {
+ await command.execute({ identifier: '87' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).toHaveProperty('ILOOM_LOOM', '87')
+ })
+
+ it('should set ILOOM_LOOM for PR identifier', async () => {
+ vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({
+ type: 'pr',
+ number: 42,
+ originalInput: '42',
+ })
+ vi.mocked(mockGitWorktreeManager.findWorktreeForPR).mockResolvedValue(mockWorktree)
+
+ await command.execute({ identifier: '42' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).toHaveProperty('ILOOM_LOOM', '42')
+ })
+
+ it('should set ILOOM_COLOR_HEX from metadata', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ colorHex: '#dcebff',
+ })
+
+ await command.execute({ identifier: '87' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).toHaveProperty('ILOOM_COLOR_HEX', '#dcebff')
+ })
+
+ it('should not set ILOOM_COLOR_HEX when metadata has no colorHex', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ colorHex: null,
+ })
+
+ await command.execute({ identifier: '87' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).not.toHaveProperty('ILOOM_COLOR_HEX')
+ })
+
+ it('should not set ILOOM_COLOR_HEX when metadata is null', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue(null)
+
+ await command.execute({ identifier: '87' })
+
+ const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4]
+ expect(envArg).not.toHaveProperty('ILOOM_COLOR_HEX')
+ })
+ })
})
diff --git a/src/commands/dev-server.ts b/src/commands/dev-server.ts
index c1739f72..993f6183 100644
--- a/src/commands/dev-server.ts
+++ b/src/commands/dev-server.ts
@@ -1,5 +1,6 @@
import path from 'path'
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
+import { MetadataManager } from '../lib/MetadataManager.js'
import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js'
import { DevServerManager } from '../lib/DevServerManager.js'
import { SettingsManager } from '../lib/SettingsManager.js'
@@ -42,7 +43,8 @@ export class DevServerCommand {
private capabilityDetector = new ProjectCapabilityDetector(),
private identifierParser = new IdentifierParser(new GitWorktreeManager()),
private devServerManager = new DevServerManager(),
- private settingsManager = new SettingsManager()
+ private settingsManager = new SettingsManager(),
+ private metadataManager = new MetadataManager()
) {}
/**
@@ -82,6 +84,15 @@ export class DevServerCommand {
}
}
+ // 3b. Set ILOOM_LOOM for loom identification
+ envOverrides.ILOOM_LOOM = this.formatLoomIdentifier(parsed)
+
+ // 3c. Set ILOOM_COLOR_HEX from loom metadata if available
+ const metadata = await this.metadataManager.readMetadata(worktree.path)
+ if (metadata?.colorHex) {
+ envOverrides.ILOOM_COLOR_HEX = metadata.colorHex
+ }
+
// 4. Detect project capabilities
const { capabilities } =
await this.capabilityDetector.detectCapabilities(worktree.path)
@@ -312,4 +323,11 @@ export class DevServerCommand {
}
return `branch "${parsed.branchName}"${autoLabel}`
}
+
+ /**
+ * Format loom identifier for ILOOM_LOOM env var
+ */
+ private formatLoomIdentifier(parsed: ParsedDevServerInput): string {
+ return parsed.originalInput
+ }
}
diff --git a/src/commands/finish.ts b/src/commands/finish.ts
index efedf1fc..eef06a4f 100644
--- a/src/commands/finish.ts
+++ b/src/commands/finish.ts
@@ -219,6 +219,7 @@ export class FinishCommand {
// We need repo info if:
// 1. Merge mode is github-pr (for creating PRs on GitHub, even with Linear issues)
// 2. Provider is GitHub (for GitHub issue operations)
+ // Note: bitbucket-pr mode handles repo detection internally via BitBucketVCSProvider
const needsRepo =
settings.mergeBehavior?.mode === 'github-pr' || settings.mergeBehavior?.mode === 'github-draft-pr' || this.issueTracker.providerName === 'github'
if (needsRepo && (await hasMultipleRemotes())) {
@@ -248,7 +249,6 @@ export class FinishCommand {
if (!worktree) {
throw new Error('No worktree found')
}
-
// Step 4: Branch based on input type
if (parsed.type === 'pr') {
// Fetch PR to get current state
@@ -345,6 +345,20 @@ export class FinishCommand {
result.branchName = parsed.branchName
}
+ // For issue types, get original issue key from metadata (preserves case for Jira/Linear IDs)
+ if (result.type === 'issue' && result.number !== undefined) {
+ const worktree = await this.gitWorktreeManager.findWorktreeForIssue(result.number)
+ if (worktree) {
+ const { MetadataManager } = await import('../lib/MetadataManager.js')
+ const metadataManager = new MetadataManager()
+ const metadata = await metadataManager.readMetadata(worktree.path)
+ const canonicalKey = metadata?.issueKey ?? metadata?.issue_numbers?.[0]
+ if (canonicalKey) {
+ result.number = canonicalKey
+ }
+ }
+ }
+
return result
}
@@ -371,16 +385,24 @@ export class FinishCommand {
}
}
+ // Read metadata to get original issue key (preserves case for Jira/Linear IDs)
+ // process.cwd() is the worktree path when auto-detecting
+ const { MetadataManager } = await import('../lib/MetadataManager.js')
+ const metadataManager = new MetadataManager()
+ const metadata = await metadataManager.readMetadata(process.cwd())
+
// Check for issue pattern in directory or branch name
const issueNumber = extractIssueNumber(currentDir)
if (issueNumber !== null) {
+ // Use issueKey from metadata (canonical case), then issue_numbers, then extracted (lowercase)
+ const originalIssueKey = metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? issueNumber
getLogger().debug(
- `Auto-detected issue #${issueNumber} from directory: ${currentDir}`
+ `Auto-detected issue #${originalIssueKey} from directory: ${currentDir}`
)
return {
type: 'issue',
- number: issueNumber,
+ number: originalIssueKey,
originalInput: currentDir,
autoDetected: true,
}
@@ -400,12 +422,14 @@ export class FinishCommand {
// Try to extract issue from branch name
const branchIssueNumber = extractIssueNumber(currentBranch)
if (branchIssueNumber !== null) {
+ // Use issueKey from metadata (canonical case), then issue_numbers, then extracted (lowercase)
+ const originalIssueKey = metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? branchIssueNumber
getLogger().debug(
- `Auto-detected issue #${branchIssueNumber} from branch: ${currentBranch}`
+ `Auto-detected issue #${originalIssueKey} from branch: ${currentBranch}`
)
return {
type: 'issue',
- number: branchIssueNumber,
+ number: originalIssueKey,
originalInput: currentBranch,
autoDetected: true,
}
@@ -599,101 +623,109 @@ export class FinishCommand {
worktree: GitWorktree,
result: FinishResult
): Promise {
- // Step 1: Rebase branch on main FIRST (Issue #344)
- // This ensures validation runs against the rebased code (with latest main changes)
- getLogger().info('Rebasing branch on main...')
-
+ // Define merge options early so they're available for all code paths
const mergeOptions: MergeOptions = {
dryRun: options.dryRun ?? false,
force: options.force ?? false,
}
- await this.mergeManager.rebaseOnMain(worktree.path, mergeOptions)
- getLogger().success('Branch rebased successfully')
- result.operations.push({
- type: 'rebase',
- message: 'Branch rebased on main',
- success: true,
- })
-
- // Step 2: Run pre-merge validations AFTER rebase (Issue #344)
- // Validates code with latest main changes integrated
- if (!options.dryRun) {
- getLogger().info('Running pre-merge validations...')
-
- await this.validationRunner.runValidations(worktree.path, {
- dryRun: options.dryRun ?? false,
- })
- getLogger().success('All validations passed')
- result.operations.push({
- type: 'validation',
- message: 'Pre-merge validations passed',
- success: true,
- })
+ // Skip rebase/validation/commit steps if --skip-to-pr flag is set (debug mode)
+ if (options.skipToPr) {
+ getLogger().info('Skipping rebase/validation/commit (--skip-to-pr flag)')
} else {
- getLogger().info('[DRY RUN] Would run pre-merge validations')
+ // Step 1: Rebase branch on main FIRST (Issue #344)
+ // This ensures validation runs against the rebased code (with latest main changes)
+ getLogger().info('Rebasing branch on main...')
+
+ await this.mergeManager.rebaseOnMain(worktree.path, mergeOptions)
+ getLogger().success('Branch rebased successfully')
result.operations.push({
- type: 'validation',
- message: 'Would run pre-merge validations (dry-run)',
+ type: 'rebase',
+ message: 'Branch rebased on main',
success: true,
})
- }
- // Step 3: Detect uncommitted changes AFTER validation passes
- const gitStatus = await this.commitManager.detectUncommittedChanges(worktree.path)
+ // Step 2: Run pre-merge validations AFTER rebase (Issue #344)
+ // Validates code with latest main changes integrated
+ if (!options.dryRun) {
+ getLogger().info('Running pre-merge validations...')
- // Step 4: Commit changes only if validation passed AND changes exist
- if (gitStatus.hasUncommittedChanges) {
- if (options.dryRun) {
- getLogger().info('[DRY RUN] Would auto-commit uncommitted changes (validation passed)')
+ await this.validationRunner.runValidations(worktree.path, {
+ dryRun: options.dryRun ?? false,
+ })
+ getLogger().success('All validations passed')
result.operations.push({
- type: 'commit',
- message: 'Would auto-commit uncommitted changes (dry-run)',
+ type: 'validation',
+ message: 'Pre-merge validations passed',
success: true,
})
} else {
- getLogger().info('Validation passed, auto-committing uncommitted changes...')
-
- // Load settings to get skipVerify configuration and issuePrefix
- const settings = await this.settingsManager.loadSettings(worktree.path)
- const skipVerify = settings.workflows?.issue?.noVerify ?? false
- const providerType = settings.issueManagement?.provider ?? 'github'
- const issuePrefix = IssueManagementProviderFactory.create(providerType).issuePrefix
-
- const commitOptions: CommitOptions = {
- dryRun: options.dryRun ?? false,
- skipVerify,
- issuePrefix,
- }
+ getLogger().info('[DRY RUN] Would run pre-merge validations')
+ result.operations.push({
+ type: 'validation',
+ message: 'Would run pre-merge validations (dry-run)',
+ success: true,
+ })
+ }
- // Only add issueNumber if it's an issue
- if (parsed.type === 'issue' && parsed.number) {
- commitOptions.issueNumber = parsed.number
- }
+ // Step 3: Detect uncommitted changes AFTER validation passes
+ const gitStatus = await this.commitManager.detectUncommittedChanges(worktree.path)
- try {
- await this.commitManager.commitChanges(worktree.path, commitOptions)
- getLogger().success('Changes committed successfully')
+ // Step 4: Commit changes only if validation passed AND changes exist
+ if (gitStatus.hasUncommittedChanges) {
+ if (options.dryRun) {
+ getLogger().info('[DRY RUN] Would auto-commit uncommitted changes (validation passed)')
result.operations.push({
type: 'commit',
- message: 'Changes committed successfully',
+ message: 'Would auto-commit uncommitted changes (dry-run)',
success: true,
})
- } catch (error) {
- if (error instanceof UserAbortedCommitError) {
- getLogger().info('Commit aborted by user')
+ } else {
+ getLogger().info('Validation passed, auto-committing uncommitted changes...')
+
+ // Load settings to get skipVerify configuration and issuePrefix
+ const settings = await this.settingsManager.loadSettings(worktree.path)
+ const skipVerify = settings.workflows?.issue?.noVerify ?? false
+ const providerType = settings.issueManagement?.provider ?? 'github'
+ const issuePrefix = IssueManagementProviderFactory.create(providerType, settings).issuePrefix
+
+ const commitOptions: CommitOptions = {
+ dryRun: options.dryRun ?? false,
+ skipVerify,
+ issuePrefix,
+ timeout: settings.git?.commitTimeout,
+ }
+
+ // Only add issueNumber if it's an issue
+ // Note: parsed.number already has correct case from parseInput() metadata lookup
+ if (parsed.type === 'issue' && parsed.number) {
+ commitOptions.issueNumber = parsed.number
+ }
+
+ try {
+ await this.commitManager.commitChanges(worktree.path, commitOptions)
+ getLogger().success('Changes committed successfully')
result.operations.push({
type: 'commit',
- message: 'Commit aborted by user',
- success: false,
+ message: 'Changes committed successfully',
+ success: true,
})
- throw error // Propagate to CLI for non-zero exit
+ } catch (error) {
+ if (error instanceof UserAbortedCommitError) {
+ getLogger().info('Commit aborted by user')
+ result.operations.push({
+ type: 'commit',
+ message: 'Commit aborted by user',
+ success: false,
+ })
+ throw error // Propagate to CLI for non-zero exit
+ }
+ throw error // Re-throw other errors
}
- throw error // Re-throw other errors
}
+ } else {
+ getLogger().debug('No uncommitted changes found')
}
- } else {
- getLogger().debug('No uncommitted changes found')
}
// Step 5: Check merge mode from settings and branch workflow
@@ -839,6 +871,23 @@ export class FinishCommand {
return
}
+ if (mergeBehavior.mode === 'bitbucket-pr') {
+ // For BitBucket, we use the VCS provider layer - NOT the issue tracker
+ // This allows Jira/Linear issues to create PRs in BitBucket
+ const { VCSProviderFactory } = await import('../lib/VCSProviderFactory.js')
+ const vcsProvider = VCSProviderFactory.create(settings)
+
+ if (!vcsProvider || vcsProvider.providerName !== 'bitbucket') {
+ throw new Error(
+ `The 'bitbucket-pr' merge mode requires BitBucket VCS configuration. ` +
+ `Add versionControl.provider: 'bitbucket' to your settings.`
+ )
+ }
+
+ await this.executeBitBucketPRWorkflow(parsed, options, worktree, settings, vcsProvider, result)
+ return
+ }
+
// Step 6: Perform fast-forward merge
getLogger().info('Performing fast-forward merge...')
await this.mergeManager.performFastForwardMerge(worktree.branch, worktree.path, mergeOptions)
@@ -956,13 +1005,14 @@ export class FinishCommand {
const settings = await this.settingsManager.loadSettings(worktree.path)
const skipVerify = settings.workflows?.pr?.noVerify ?? false
const providerType = settings.issueManagement?.provider ?? 'github'
- const issuePrefix = IssueManagementProviderFactory.create(providerType).issuePrefix
+ const issuePrefix = IssueManagementProviderFactory.create(providerType, settings).issuePrefix
try {
await this.commitManager.commitChanges(worktree.path, {
dryRun: false,
skipVerify,
issuePrefix,
+ timeout: settings.git?.commitTimeout,
// Do NOT pass issueNumber for PRs - no "Fixes #" trailer needed
})
getLogger().success('Changes committed')
@@ -1083,6 +1133,21 @@ export class FinishCommand {
message: `Pull request created`,
success: true,
})
+
+ // Move issue to Ready for Review state
+ if (parsed.type === 'issue' && parsed.number) {
+ try {
+ if (this.issueTracker.moveIssueToReadyForReview) {
+ await this.issueTracker.moveIssueToReadyForReview(parsed.number)
+ getLogger().info('Issue moved to Ready for Review')
+ }
+ } catch (error) {
+ getLogger().warn(
+ `Failed to move issue to Ready for Review: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ error
+ )
+ }
+ }
}
// Set PR URL in result
@@ -1104,6 +1169,130 @@ export class FinishCommand {
}
}
+ /**
+ * Execute workflow for BitBucket PR creation (bitbucket-pr merge mode)
+ * Validates → Commits → Pushes → Creates PR → Prompts for cleanup
+ *
+ * Unlike GitHub PR workflow, this uses the VersionControlProvider abstraction
+ * instead of PRManager, allowing it to work with any issue tracker (Jira, Linear, etc.)
+ */
+ private async executeBitBucketPRWorkflow(
+ parsed: ParsedFinishInput,
+ options: FinishOptions,
+ worktree: GitWorktree,
+ settings: import('../lib/SettingsManager.js').IloomSettings,
+ vcsProvider: import('../lib/VersionControlProvider.js').VersionControlProvider,
+ finishResult: FinishResult
+ ): Promise {
+ // Step 1: Push branch to origin
+ if (options.dryRun) {
+ getLogger().info('[DRY RUN] Would push branch to origin')
+ } else {
+ getLogger().info('Pushing branch to origin...')
+ await pushBranchToRemote(worktree.branch, worktree.path, { dryRun: false })
+ getLogger().success('Branch pushed successfully')
+ }
+
+ // Step 2: Generate PR title from issue if available
+ // Note: parsed.number already has correct case from parseInput() metadata lookup
+ let prTitle = `Work from ${worktree.branch}`
+ if (parsed.type === 'issue' && parsed.number) {
+ try {
+ const issue = await this.issueTracker.fetchIssue(parsed.number)
+
+ // Apply ticket prefix if enabled (default: true)
+ const usePrefix = settings.mergeBehavior?.prTitlePrefix;
+ if (usePrefix) {
+ prTitle = `${parsed.number}: ${issue.title}`
+ } else {
+ prTitle = issue.title
+ }
+ } catch (error) {
+ getLogger().debug('Could not fetch issue title, using branch name', { error })
+ }
+ }
+
+ // Step 3: Get base branch (respects parent loom metadata for child looms)
+ const baseBranch = await getMergeTargetBranch(worktree.path)
+
+ // Step 4: Check for existing PR or create new one
+ if (options.dryRun) {
+ getLogger().info('[DRY RUN] Would create BitBucket PR')
+ getLogger().info(` Title: ${prTitle}`)
+ getLogger().info(` Base: ${baseBranch}`)
+ finishResult.operations.push({
+ type: 'pr-creation',
+ message: 'Would create BitBucket PR (dry-run)',
+ success: true,
+ })
+ } else {
+ // Check for existing PR first
+ const existingPR = await vcsProvider.checkForExistingPR(worktree.branch, worktree.path)
+
+ if (existingPR) {
+ getLogger().success(`Existing pull request: ${existingPR.url}`)
+ finishResult.prUrl = existingPR.url
+ finishResult.operations.push({
+ type: 'pr-creation',
+ message: 'Found existing pull request',
+ success: true,
+ })
+ } else {
+ // Generate PR body using Claude (same as GitHub workflow)
+ const { PRManager } = await import('../lib/PRManager.js')
+ const prManager = new PRManager(settings)
+ const prBody = await prManager.generatePRBody(
+ parsed.type === 'issue' ? parsed.number : undefined,
+ worktree.path
+ )
+
+ // Create new PR
+ const prUrl = await vcsProvider.createPR(
+ worktree.branch,
+ prTitle,
+ prBody,
+ baseBranch,
+ worktree.path
+ )
+ getLogger().success(`Pull request created: ${prUrl}`)
+ finishResult.prUrl = prUrl
+ finishResult.operations.push({
+ type: 'pr-creation',
+ message: 'Pull request created',
+ success: true,
+ })
+
+ // Move issue to Ready for Review state
+ if (parsed.type === 'issue' && parsed.number) {
+ try {
+ if (this.issueTracker.moveIssueToReadyForReview) {
+ await this.issueTracker.moveIssueToReadyForReview(parsed.number)
+ getLogger().info('Issue moved to Ready for Review')
+ }
+ } catch (error) {
+ getLogger().warn(
+ `Failed to move issue to Ready for Review: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ error
+ )
+ }
+ }
+ }
+
+ // Generate session summary - posts to the ISSUE (Jira/Linear), not the PR
+ // For BitBucket workflows, the issue tracker (Jira/Linear) doesn't support PR comments,
+ // so we post to the issue where the knowledge capture belongs
+ await this.generateSessionSummaryIfConfigured(parsed, worktree, options)
+
+ // Archive metadata BEFORE cleanup prompt (ensures it runs even with --no-cleanup)
+ const { MetadataManager } = await import('../lib/MetadataManager.js')
+ const metadataManager = new MetadataManager()
+ await metadataManager.archiveMetadata(worktree.path)
+
+ // Interactive cleanup prompt (unless flags override)
+ await this.handlePRCleanupPrompt(parsed, options, worktree, finishResult)
+ }
+ }
+
/**
* Handle cleanup prompt after PR creation
* Respects --cleanup and --no-cleanup flags, otherwise prompts user
diff --git a/src/commands/shell.test.ts b/src/commands/shell.test.ts
index 044786f1..63721038 100644
--- a/src/commands/shell.test.ts
+++ b/src/commands/shell.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ShellCommand } from './shell.js'
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
+import { MetadataManager } from '../lib/MetadataManager.js'
import { IdentifierParser } from '../utils/IdentifierParser.js'
import { SettingsManager } from '../lib/SettingsManager.js'
import type { GitWorktree } from '../types/worktree.js'
@@ -9,6 +10,7 @@ import { execa } from 'execa'
// Mock dependencies
vi.mock('../lib/GitWorktreeManager.js')
+vi.mock('../lib/MetadataManager.js')
vi.mock('../utils/IdentifierParser.js')
vi.mock('../lib/SettingsManager.js')
vi.mock('fs-extra')
@@ -37,6 +39,7 @@ import { loadWorkspaceEnv, getDotenvFlowFiles } from '../utils/env.js'
describe('ShellCommand', () => {
let command: ShellCommand
let mockGitWorktreeManager: GitWorktreeManager
+ let mockMetadataManager: MetadataManager
let mockIdentifierParser: IdentifierParser
let mockSettingsManager: SettingsManager
@@ -49,6 +52,7 @@ describe('ShellCommand', () => {
beforeEach(() => {
mockGitWorktreeManager = new GitWorktreeManager()
+ mockMetadataManager = new MetadataManager()
mockIdentifierParser = new IdentifierParser(mockGitWorktreeManager)
mockSettingsManager = new SettingsManager()
@@ -57,6 +61,27 @@ describe('ShellCommand', () => {
sourceEnvOnStart: true,
})
+ // Default metadata mock - return colorHex
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ description: 'test',
+ created_at: null,
+ branchName: null,
+ worktreePath: null,
+ issueType: null,
+ issueKey: null,
+ issue_numbers: [],
+ pr_numbers: [],
+ issueTracker: null,
+ colorHex: '#dcebff',
+ sessionId: null,
+ projectPath: null,
+ issueUrls: {},
+ prUrls: {},
+ draftPrNumber: null,
+ capabilities: [],
+ parentLoom: null,
+ })
+
// Set up env mocks
vi.mocked(loadWorkspaceEnv).mockReturnValue({ parsed: { PORT: '3087', NODE_ENV: 'development' } })
vi.mocked(getDotenvFlowFiles).mockReturnValue(['.env', '.env.local', '.env.development', '.env.development.local'])
@@ -64,7 +89,8 @@ describe('ShellCommand', () => {
command = new ShellCommand(
mockGitWorktreeManager,
mockIdentifierParser,
- mockSettingsManager
+ mockSettingsManager,
+ mockMetadataManager
)
})
@@ -227,6 +253,52 @@ describe('ShellCommand', () => {
const envArg = execaCall[2]?.env as Record
expect(envArg.ILOOM_LOOM).toBe('pr-42')
})
+
+ it('should set ILOOM_COLOR_HEX when metadata has colorHex', async () => {
+ await command.execute({ identifier: '87' })
+
+ const execaCall = vi.mocked(execa).mock.calls[0]
+ const envArg = execaCall[2]?.env as Record
+ expect(envArg.ILOOM_COLOR_HEX).toBe('#dcebff')
+ })
+
+ it('should not set ILOOM_COLOR_HEX when metadata is null', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue(null)
+
+ await command.execute({ identifier: '87' })
+
+ const execaCall = vi.mocked(execa).mock.calls[0]
+ const envArg = execaCall[2]?.env as Record
+ expect(envArg.ILOOM_COLOR_HEX).toBeUndefined()
+ })
+
+ it('should not set ILOOM_COLOR_HEX when metadata.colorHex is null', async () => {
+ vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({
+ description: 'test',
+ created_at: null,
+ branchName: null,
+ worktreePath: null,
+ issueType: null,
+ issueKey: null,
+ issue_numbers: [],
+ pr_numbers: [],
+ issueTracker: null,
+ colorHex: null,
+ sessionId: null,
+ projectPath: null,
+ issueUrls: {},
+ prUrls: {},
+ draftPrNumber: null,
+ capabilities: [],
+ parentLoom: null,
+ })
+
+ await command.execute({ identifier: '87' })
+
+ const execaCall = vi.mocked(execa).mock.calls[0]
+ const envArg = execaCall[2]?.env as Record
+ expect(envArg.ILOOM_COLOR_HEX).toBeUndefined()
+ })
})
describe('shell detection', () => {
diff --git a/src/commands/shell.ts b/src/commands/shell.ts
index 4a2cd09e..c97d8595 100644
--- a/src/commands/shell.ts
+++ b/src/commands/shell.ts
@@ -2,6 +2,7 @@ import path from 'path'
import { execa } from 'execa'
import fs from 'fs-extra'
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
+import { MetadataManager } from '../lib/MetadataManager.js'
import { SettingsManager } from '../lib/SettingsManager.js'
import { IdentifierParser } from '../utils/IdentifierParser.js'
import { loadWorkspaceEnv, getDotenvFlowFiles } from '../utils/env.js'
@@ -29,7 +30,8 @@ export class ShellCommand {
constructor(
private gitWorktreeManager = new GitWorktreeManager(),
private identifierParser = new IdentifierParser(new GitWorktreeManager()),
- private settingsManager = new SettingsManager()
+ private settingsManager = new SettingsManager(),
+ private metadataManager = new MetadataManager()
) {}
async execute(input: ShellCommandInput): Promise {
@@ -66,6 +68,12 @@ export class ShellCommand {
const loomIdentifier = this.formatLoomIdentifier(parsed)
envVars.ILOOM_LOOM = loomIdentifier
+ // 5b. Set ILOOM_COLOR_HEX from loom metadata if available
+ const metadata = await this.metadataManager.readMetadata(worktree.path)
+ if (metadata?.colorHex) {
+ envVars.ILOOM_COLOR_HEX = metadata.colorHex
+ }
+
// 6. Detect shell
const shell = this.detectShell()
diff --git a/src/commands/summary.ts b/src/commands/summary.ts
index bdb5a00d..2ccc85a5 100644
--- a/src/commands/summary.ts
+++ b/src/commands/summary.ts
@@ -152,7 +152,7 @@ export class SummaryCommand {
return {
worktree: issueWorktree,
loomType: metadata?.issueType ?? 'issue',
- issueNumber: metadata?.issue_numbers?.[0] ?? String(issueNumber),
+ issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? String(issueNumber),
}
}
@@ -180,7 +180,7 @@ export class SummaryCommand {
return {
worktree: issueWorktree,
loomType: metadata?.issueType ?? 'issue',
- issueNumber: metadata?.issue_numbers?.[0] ?? alphanumericId,
+ issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? alphanumericId,
}
}
throw new Error(`No loom found for identifier: ${identifier}`)
@@ -194,8 +194,8 @@ export class SummaryCommand {
// For branch looms, try to get issue number from metadata
let issueNumber: string | number | undefined
- if (loomType === 'issue' && metadata?.issue_numbers?.[0]) {
- issueNumber = metadata.issue_numbers[0]
+ if (loomType === 'issue' && (metadata?.issueKey || metadata?.issue_numbers?.[0])) {
+ issueNumber = metadata?.issueKey ?? metadata?.issue_numbers?.[0]
} else if (loomType === 'pr' && metadata?.pr_numbers?.[0]) {
issueNumber = metadata.pr_numbers[0]
}
@@ -256,7 +256,7 @@ export class SummaryCommand {
return {
worktree,
loomType: metadata?.issueType ?? 'issue',
- issueNumber: metadata?.issue_numbers?.[0] ?? String(issueNumber),
+ issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? String(issueNumber),
}
}
throw new Error(`No loom found for auto-detected issue #${issueNumber}`)
@@ -284,7 +284,7 @@ export class SummaryCommand {
return {
worktree,
loomType: metadata?.issueType ?? 'issue',
- issueNumber: metadata?.issue_numbers?.[0] ?? String(branchIssueNumber),
+ issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? String(branchIssueNumber),
}
}
}
@@ -297,8 +297,8 @@ export class SummaryCommand {
// For branch looms, try to get issue number from metadata
let resolvedIssueNumber: string | number | undefined
- if (loomType === 'issue' && metadata?.issue_numbers?.[0]) {
- resolvedIssueNumber = metadata.issue_numbers[0]
+ if (loomType === 'issue' && (metadata?.issueKey || metadata?.issue_numbers?.[0])) {
+ resolvedIssueNumber = metadata?.issueKey ?? metadata?.issue_numbers?.[0]
} else if (loomType === 'pr' && metadata?.pr_numbers?.[0]) {
resolvedIssueNumber = metadata.pr_numbers[0]
}
diff --git a/src/lib/ClaudeContextManager.test.ts b/src/lib/ClaudeContextManager.test.ts
index d1355ee8..9cad1b33 100644
--- a/src/lib/ClaudeContextManager.test.ts
+++ b/src/lib/ClaudeContextManager.test.ts
@@ -85,29 +85,29 @@ describe('ClaudeContextManager', () => {
)
})
- it('should throw error when issue identifier is not a number', async () => {
+ it('should throw error when issue identifier is undefined', async () => {
const context = {
type: 'issue',
- identifier: 'not-a-number',
+ identifier: undefined,
workspacePath: '/workspace',
port: 3000,
} as unknown as ClaudeContext
await expect(manager.prepareContext(context)).rejects.toThrow(
- 'Issue identifier must be a number'
+ 'Issue identifier is required'
)
})
- it('should throw error when PR identifier is not a number', async () => {
+ it('should throw error when PR identifier is undefined', async () => {
const context = {
type: 'pr',
- identifier: 'not-a-number',
+ identifier: undefined,
workspacePath: '/workspace',
port: 3000,
} as unknown as ClaudeContext
await expect(manager.prepareContext(context)).rejects.toThrow(
- 'PR identifier must be a number'
+ 'PR identifier is required'
)
})
diff --git a/src/lib/ClaudeContextManager.ts b/src/lib/ClaudeContextManager.ts
index a041ee2f..0ad74f94 100644
--- a/src/lib/ClaudeContextManager.ts
+++ b/src/lib/ClaudeContextManager.ts
@@ -33,12 +33,12 @@ export class ClaudeContextManager {
throw new Error('Workspace path is required')
}
- if (context.type === 'issue' && typeof context.identifier !== 'number') {
- throw new Error('Issue identifier must be a number')
+ if (context.type === 'issue' && context.identifier === undefined) {
+ throw new Error('Issue identifier is required')
}
- if (context.type === 'pr' && typeof context.identifier !== 'number') {
- throw new Error('PR identifier must be a number')
+ if (context.type === 'pr' && context.identifier === undefined) {
+ throw new Error('PR identifier is required')
}
logger.debug('Context prepared', { context })
diff --git a/src/lib/CommitManager.test.ts b/src/lib/CommitManager.test.ts
index adff6681..3fc38390 100644
--- a/src/lib/CommitManager.test.ts
+++ b/src/lib/CommitManager.test.ts
@@ -1593,4 +1593,73 @@ describe('CommitManager', () => {
})
})
})
+
+ describe('Commit Timeout Configuration', () => {
+ beforeEach(() => {
+ vi.mocked(claude.detectClaudeCli).mockResolvedValue(false)
+ vi.mocked(prompt.promptCommitAction).mockResolvedValue('accept')
+ })
+
+ it('should pass custom timeout to executeGitCommand when timeout option is provided', async () => {
+ vi.mocked(git.executeGitCommand).mockResolvedValue('')
+
+ await manager.commitChanges(mockWorktreePath, {
+ issuePrefix: '#',
+ noReview: true,
+ timeout: 120000,
+ dryRun: false,
+ })
+
+ expect(git.executeGitCommand).toHaveBeenCalledWith(
+ ['commit', '-m', 'WIP: Auto-commit uncommitted changes'],
+ { cwd: mockWorktreePath, timeout: 120000 }
+ )
+ })
+
+ it('should not include timeout in options when timeout is undefined', async () => {
+ vi.mocked(git.executeGitCommand).mockResolvedValue('')
+
+ await manager.commitChanges(mockWorktreePath, {
+ issuePrefix: '#',
+ noReview: true,
+ dryRun: false,
+ })
+
+ expect(git.executeGitCommand).toHaveBeenCalledWith(
+ ['commit', '-m', 'WIP: Auto-commit uncommitted changes'],
+ { cwd: mockWorktreePath, timeout: undefined }
+ )
+ })
+
+ it('should pass timeout to interactive editor commit', async () => {
+ vi.mocked(prompt.promptCommitAction).mockResolvedValue('edit')
+ vi.mocked(git.executeGitCommand).mockResolvedValue('')
+
+ await manager.commitChanges(mockWorktreePath, {
+ issuePrefix: '#',
+ timeout: 180000,
+ dryRun: false,
+ })
+
+ expect(git.executeGitCommand).toHaveBeenCalledWith(
+ ['commit', '-e', '-m', 'WIP: Auto-commit uncommitted changes'],
+ { cwd: mockWorktreePath, stdio: 'inherit', timeout: 180000 }
+ )
+ })
+
+ it('should use 300000ms fallback when timeout not provided for interactive editing', async () => {
+ vi.mocked(prompt.promptCommitAction).mockResolvedValue('edit')
+ vi.mocked(git.executeGitCommand).mockResolvedValue('')
+
+ await manager.commitChanges(mockWorktreePath, {
+ issuePrefix: '#',
+ dryRun: false,
+ })
+
+ expect(git.executeGitCommand).toHaveBeenCalledWith(
+ ['commit', '-e', '-m', 'WIP: Auto-commit uncommitted changes'],
+ { cwd: mockWorktreePath, stdio: 'inherit', timeout: 300000 }
+ )
+ })
+ })
})
diff --git a/src/lib/CommitManager.ts b/src/lib/CommitManager.ts
index c41c8f69..cbadaf4f 100644
--- a/src/lib/CommitManager.ts
+++ b/src/lib/CommitManager.ts
@@ -95,7 +95,7 @@ export class CommitManager {
if (options.skipVerify) {
commitArgs.push('--no-verify')
}
- await executeGitCommand(commitArgs, { cwd: worktreePath })
+ await executeGitCommand(commitArgs, { cwd: worktreePath, timeout: options.timeout })
} else {
// Prompt user for action instead of going straight to editor
const action = await promptCommitAction(message)
@@ -110,7 +110,7 @@ export class CommitManager {
if (options.skipVerify) {
commitArgs.push('--no-verify')
}
- await executeGitCommand(commitArgs, { cwd: worktreePath })
+ await executeGitCommand(commitArgs, { cwd: worktreePath, timeout: options.timeout })
} else {
// action === 'edit': Use git editor for user review
getLogger().info('Opening editor for commit message review...')
@@ -135,7 +135,7 @@ export class CommitManager {
await executeGitCommand(commitArgs, {
cwd: worktreePath,
stdio: 'inherit',
- timeout: 300000 // 5 minutes for interactive editing
+ timeout: options.timeout ?? 300000 // Use configured timeout or default 5 minutes for interactive editing
})
}
}
@@ -215,7 +215,7 @@ export class CommitManager {
// Rewrite the file without comments for git commit -F
await writeFile(commitMsgPath, finalMessage, 'utf-8')
- await executeGitCommand(commitArgs, { cwd: worktreePath })
+ await executeGitCommand(commitArgs, { cwd: worktreePath, timeout: options.timeout })
} finally {
// Clean up - git normally handles this but we should be safe
diff --git a/src/lib/GitHubService.ts b/src/lib/GitHubService.ts
index 32c08828..8d3a015d 100644
--- a/src/lib/GitHubService.ts
+++ b/src/lib/GitHubService.ts
@@ -248,10 +248,66 @@ export class GitHubService implements IssueTracker {
}
}
+ // GitHub Projects integration - move to Ready for Review
+ public async moveIssueToReadyForReview(issueNumber: number): Promise {
+ getLogger().info('Moving issue to Ready for Review in GitHub Projects', {
+ issueNumber,
+ })
+
+ // Check for project scope
+ if (!(await hasProjectScope())) {
+ getLogger().warn('Missing project scope in GitHub CLI auth')
+ throw new GitHubError(
+ GitHubErrorCode.MISSING_SCOPE,
+ 'GitHub CLI lacks project scope. Run: gh auth refresh -s project'
+ )
+ }
+
+ // Get repository info
+ let owner: string
+ try {
+ const repoInfo = await executeGhCommand<{
+ owner: { login: string }
+ name: string
+ }>(['repo', 'view', '--json', 'owner,name'])
+ owner = repoInfo.owner.login
+ } catch (error) {
+ getLogger().warn('Could not determine repository info', { error })
+ return
+ }
+
+ // List all projects
+ let projects: GitHubProject[]
+ try {
+ projects = await fetchProjectList(owner)
+ } catch (error) {
+ getLogger().warn('Could not fetch projects', { owner, error })
+ return
+ }
+
+ if (!projects.length) {
+ getLogger().warn('No projects found', { owner })
+ return
+ }
+
+ // Process each project
+ for (const project of projects) {
+ await this.updateIssueStatusInProject(
+ project,
+ issueNumber,
+ owner,
+ ['Ready for Review', 'In Review', 'Review'],
+ 'Ready for Review'
+ )
+ }
+ }
+
private async updateIssueStatusInProject(
project: GitHubProject,
issueNumber: number,
- owner: string
+ owner: string,
+ statusNames: string[] = ['In Progress', 'In progress'],
+ logLabel: string = 'In Progress'
): Promise {
// Check if issue is in project
let items: ProjectItem[]
@@ -285,19 +341,21 @@ export class GitHubService implements IssueTracker {
return
}
- // Find Status field and In Progress option
+ // Find Status field and target option
const statusField = fieldsData.fields.find((f) => f.name === 'Status')
if (!statusField) {
getLogger().debug('No Status field found in project', { projectNumber: project.number })
return
}
- const inProgressOption = statusField.options?.find(
- (o: { id: string; name: string }) => o.name === 'In Progress' || o.name === 'In progress'
+ const targetOption = statusField.options?.find(
+ (o: { id: string; name: string }) => statusNames.some(name =>
+ o.name.toLowerCase() === name.toLowerCase()
+ )
)
- if (!inProgressOption) {
- getLogger().debug('No In Progress option found in Status field', { projectNumber: project.number })
+ if (!targetOption) {
+ getLogger().debug(`No ${logLabel} option found in Status field`, { projectNumber: project.number })
return
}
@@ -307,18 +365,24 @@ export class GitHubService implements IssueTracker {
item.id,
project.id,
statusField.id,
- inProgressOption.id
+ targetOption.id
)
getLogger().info('Updated issue status in project', {
issueNumber,
projectNumber: project.number,
+ status: logLabel,
})
} catch (error) {
getLogger().debug('Could not update project item', { item: item.id, error })
}
}
+ // Identifier normalization - GitHub identifiers are numeric, just stringify
+ public normalizeIdentifier(identifier: string | number): string {
+ return String(identifier)
+ }
+
// Utility methods
public extractContext(entity: Issue | PullRequest): string {
if ('branch' in entity) {
diff --git a/src/lib/IssueEnhancementService.ts b/src/lib/IssueEnhancementService.ts
index fdaa1922..4c2cc2ae 100644
--- a/src/lib/IssueEnhancementService.ts
+++ b/src/lib/IssueEnhancementService.ts
@@ -64,20 +64,22 @@ export class IssueEnhancementService {
// Call Claude in headless mode with issue enhancer agent
const prompt = `@agent-iloom-issue-enhancer
-TASK: Enhance the following issue description for GitHub.
+TASK: Enhance the following issue description for the issue tracker.
INPUT:
${description}
OUTPUT REQUIREMENTS:
- Return ONLY the enhanced description markdown text
+- Use GitHub-Flavored Markdown syntax ONLY
+- NEVER use Jira Wiki format (e.g., {code}, h1., *bold*, {quote}, [link|url])
- NO meta-commentary (no "Here is...", "The enhanced...", "I have...", etc)
- NO code block markers (\`\`\`)
- NO conversational framing or acknowledgments
- NO explanations of your work
- Start your response immediately with the enhanced content
-Your response should be the raw markdown that will become the GitHub issue body.`
+Your response should be the raw markdown that will become the issue body.`
const enhanced = await launchClaude(prompt, {
headless: true,
diff --git a/src/lib/IssueTracker.ts b/src/lib/IssueTracker.ts
index a562bf9d..f7af80e5 100644
--- a/src/lib/IssueTracker.ts
+++ b/src/lib/IssueTracker.ts
@@ -40,6 +40,11 @@ export interface IssueTracker {
// Status management - optional, check provider capabilities before calling
moveIssueToInProgress?(identifier: string | number): Promise
+ moveIssueToReadyForReview?(identifier: string | number): Promise
+
+ // Identifier normalization - ensures identifiers are in canonical form
+ // GitHub: returns String(id), Linear/Jira: returns uppercase (e.g., "PROJ-123")
+ normalizeIdentifier(identifier: string | number): string
// Context extraction - formats issue/PR for AI prompts
extractContext(entity: Issue | PullRequest): string
diff --git a/src/lib/IssueTrackerFactory.ts b/src/lib/IssueTrackerFactory.ts
index 8307667d..04fed002 100644
--- a/src/lib/IssueTrackerFactory.ts
+++ b/src/lib/IssueTrackerFactory.ts
@@ -4,10 +4,11 @@
import type { IssueTracker } from './IssueTracker.js'
import { GitHubService } from './GitHubService.js'
import { LinearService, type LinearServiceConfig } from './LinearService.js'
+import { JiraIssueTracker, type JiraTrackerConfig } from './providers/jira/index.js'
import type { IloomSettings } from './SettingsManager.js'
import { getLogger } from '../utils/logger-context.js'
-export type IssueTrackerProviderType = 'github' | 'linear'
+export type IssueTrackerProviderType = 'github' | 'linear' | 'jira'
/**
* Factory for creating IssueTracker instances based on settings
@@ -53,6 +54,36 @@ export class IssueTrackerFactory {
getLogger().debug(`IssueTrackerFactory: Creating LinearService with config:`, JSON.stringify(linearConfig, null, 2))
return new LinearService(linearConfig)
}
+ case 'jira': {
+ const jiraSettings = settings.issueManagement?.jira
+
+ if (!jiraSettings?.host) {
+ throw new Error('Jira host is required. Configure issueManagement.jira.host in .iloom/settings.json')
+ }
+ if (!jiraSettings?.username) {
+ throw new Error('Jira username is required. Configure issueManagement.jira.username in .iloom/settings.json')
+ }
+ if (!jiraSettings?.apiToken) {
+ throw new Error('Jira API token is required. Configure issueManagement.jira.apiToken in .iloom/settings.local.json')
+ }
+ if (!jiraSettings?.projectKey) {
+ throw new Error('Jira project key is required. Configure issueManagement.jira.projectKey in .iloom/settings.json')
+ }
+
+ const jiraConfig: JiraTrackerConfig = {
+ host: jiraSettings.host,
+ username: jiraSettings.username,
+ apiToken: jiraSettings.apiToken,
+ projectKey: jiraSettings.projectKey,
+ }
+
+ if (jiraSettings.transitionMappings) {
+ jiraConfig.transitionMappings = jiraSettings.transitionMappings
+ }
+
+ getLogger().debug(`IssueTrackerFactory: Creating JiraIssueTracker for host: ${jiraSettings.host}`)
+ return new JiraIssueTracker(jiraConfig)
+ }
default:
throw new Error(`Unsupported issue tracker provider: ${provider}`)
}
diff --git a/src/lib/LinearService.ts b/src/lib/LinearService.ts
index f26d0456..e7aed9be 100644
--- a/src/lib/LinearService.ts
+++ b/src/lib/LinearService.ts
@@ -192,6 +192,25 @@ export class LinearService implements IssueTracker {
await updateLinearIssueState(String(identifier), 'In Progress')
}
+ /**
+ * Move a Linear issue to "In Review" state
+ * @param identifier - Linear issue identifier
+ * @throws LinearServiceError if state update fails
+ */
+ public async moveIssueToReadyForReview(identifier: string | number): Promise {
+ getLogger().info(`Moving Linear issue ${identifier} to In Review`)
+ await updateLinearIssueState(String(identifier), 'In Review')
+ }
+
+ /**
+ * Normalize identifier to canonical form (uppercase for Linear keys)
+ * @param identifier - Linear issue identifier (e.g., "eng-123" or "ENG-123")
+ * @returns Uppercase identifier (e.g., "ENG-123")
+ */
+ public normalizeIdentifier(identifier: string | number): string {
+ return String(identifier).toUpperCase()
+ }
+
/**
* Extract issue context for AI prompts
* @param entity - Issue (Linear doesn't have PRs)
diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts
index ef37a54f..3cfe9419 100644
--- a/src/lib/LoomManager.ts
+++ b/src/lib/LoomManager.ts
@@ -1,6 +1,8 @@
import path from 'path'
+import os from 'os'
import fs from 'fs-extra'
import fg from 'fast-glob'
+import { execa } from 'execa'
import { GitWorktreeManager } from './GitWorktreeManager.js'
import type { IssueTracker } from './IssueTracker.js'
import type { BranchNamingService } from './BranchNamingService.js'
@@ -194,6 +196,11 @@ export class LoomManager {
}
}
+ // 10.1. Pre-accept Claude trust dialog if Claude is enabled
+ if (input.options?.enableClaude !== false) {
+ await this.acceptClaudeTrustDialog(worktreePath)
+ }
+
// 10.5. Handle github-draft-pr mode - push branch and create draft PR
let draftPrNumber: number | undefined = undefined
let draftPrUrl: string | undefined = undefined
@@ -375,6 +382,7 @@ export class LoomManager {
branchName,
worktreePath,
issueType: input.type,
+ ...(input.type === 'issue' && { issueKey: this.issueTracker.normalizeIdentifier(input.identifier) }),
issue_numbers,
pr_numbers,
issueTracker: this.issueTracker.providerName,
@@ -988,6 +996,57 @@ export class LoomManager {
// The colorTerminal setting is passed through to launch options
}
+ /**
+ * Pre-accept Claude Code trust dialog for a worktree path
+ * This allows Claude to launch without the interactive trust confirmation
+ *
+ * @param worktreePath - The path to the worktree to trust
+ */
+ private async acceptClaudeTrustDialog(worktreePath: string): Promise {
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json')
+
+ // 1. Run claude command to register the project path (will fail but updates config)
+ try {
+ await execa('claude', [
+ '--dangerously-skip-permissions',
+ '--no-session-persistence',
+ '--print'
+ ], {
+ cwd: worktreePath,
+ timeout: 10000,
+ reject: false, // Don't throw on non-zero exit
+ })
+ } catch {
+ // Expected to fail - we just need it to register the path
+ }
+
+ // 2. Read ~/.claude.json and set hasTrustDialogAccepted
+ try {
+ let claudeJson: Record = {}
+ if (await fs.pathExists(claudeJsonPath)) {
+ claudeJson = await fs.readJson(claudeJsonPath)
+ }
+
+ // Ensure projects object exists (structure: { projects: { ... } })
+ if (!claudeJson.projects || typeof claudeJson.projects !== 'object') {
+ claudeJson.projects = {}
+ }
+ const projects = claudeJson.projects as Record>
+
+ // Ensure project entry exists
+ projects[worktreePath] ??= {}
+
+ // Set trust flag
+ projects[worktreePath].hasTrustDialogAccepted = true
+
+ // Write back
+ await fs.writeJson(claudeJsonPath, claudeJson, { spaces: 2 })
+ getLogger().debug(`Accepted Claude trust dialog for: ${worktreePath}`)
+ } catch (error) {
+ getLogger().warn(`Failed to accept Claude trust dialog: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ }
+ }
+
/**
* Map worktrees to loom objects
* Reads loom metadata from MetadataManager with branch name parsing as fallback
@@ -1005,8 +1064,9 @@ export class LoomManager {
type = loomMetadata.issueType
// Extract identifier from metadata based on type
- if (type === 'issue' && loomMetadata.issue_numbers?.[0]) {
- const issueId = loomMetadata.issue_numbers[0]
+ // Prefer issueKey (canonical case) over issue_numbers (may be lowercase from branch extraction)
+ if (type === 'issue' && (loomMetadata.issueKey || loomMetadata.issue_numbers?.[0])) {
+ const issueId = loomMetadata.issueKey ?? loomMetadata.issue_numbers[0] ?? ''
// Try to parse as number, otherwise keep as string (for alphanumeric IDs)
const numericId = parseInt(issueId, 10)
identifier = isNaN(numericId) ? issueId : numericId
@@ -1165,8 +1225,14 @@ export class LoomManager {
}
}
- // 7. Launch components (same as new worktree)
+ // 6.5. Pre-accept Claude trust dialog if Claude is enabled
const enableClaude = input.options?.enableClaude !== false
+ if (enableClaude) {
+ await this.acceptClaudeTrustDialog(worktreePath)
+ }
+
+ // 7. Launch components (same as new worktree)
+ // Note: enableClaude is already defined above
const enableCode = input.options?.enableCode !== false
const enableDevServer = input.options?.enableDevServer !== false
const enableTerminal = input.options?.enableTerminal ?? false
@@ -1243,6 +1309,7 @@ export class LoomManager {
branchName,
worktreePath,
issueType: input.type,
+ ...(input.type === 'issue' && { issueKey: this.issueTracker.normalizeIdentifier(input.identifier) }),
issue_numbers,
pr_numbers,
issueTracker: this.issueTracker.providerName,
diff --git a/src/lib/MetadataManager.test.ts b/src/lib/MetadataManager.test.ts
index 211eb4fc..4be76a88 100644
--- a/src/lib/MetadataManager.test.ts
+++ b/src/lib/MetadataManager.test.ts
@@ -279,6 +279,7 @@ describe('MetadataManager', () => {
branchName: 'issue-42__auth-fix',
worktreePath: '/Users/jane/dev/repo',
issueType: 'issue',
+ issueKey: null,
issue_numbers: ['42'],
pr_numbers: [],
issueTracker: 'github',
@@ -373,6 +374,7 @@ describe('MetadataManager', () => {
branchName: null,
worktreePath: null,
issueType: null,
+ issueKey: null,
issue_numbers: [],
pr_numbers: [],
issueTracker: null,
@@ -624,6 +626,7 @@ describe('MetadataManager', () => {
branchName: 'issue-1__feat',
worktreePath: '/Users/alice/project1',
issueType: 'issue',
+ issueKey: null,
issue_numbers: ['1'],
pr_numbers: [],
issueTracker: 'github',
@@ -642,6 +645,7 @@ describe('MetadataManager', () => {
branchName: 'issue-2__fix',
worktreePath: '/Users/bob/project2',
issueType: 'issue',
+ issueKey: null,
issue_numbers: ['2'],
pr_numbers: [],
issueTracker: 'github',
@@ -744,6 +748,7 @@ describe('MetadataManager', () => {
branchName: null,
worktreePath: null,
issueType: null,
+ issueKey: null,
issue_numbers: [],
pr_numbers: [],
issueTracker: null,
diff --git a/src/lib/MetadataManager.ts b/src/lib/MetadataManager.ts
index f314efde..a68bb622 100644
--- a/src/lib/MetadataManager.ts
+++ b/src/lib/MetadataManager.ts
@@ -16,6 +16,7 @@ export interface MetadataFile {
branchName?: string
worktreePath?: string
issueType?: 'branch' | 'issue' | 'pr'
+ issueKey?: string // Canonical, properly-cased issue key (e.g., "PROJ-123")
issue_numbers?: string[]
pr_numbers?: string[]
issueTracker?: string
@@ -45,6 +46,7 @@ export interface WriteMetadataInput {
branchName: string
worktreePath: string
issueType: 'branch' | 'issue' | 'pr'
+ issueKey?: string // Canonical, properly-cased issue key (e.g., "PROJ-123")
issue_numbers: string[]
pr_numbers: string[]
issueTracker: string
@@ -75,6 +77,7 @@ export interface LoomMetadata {
branchName: string | null
worktreePath: string | null
issueType: 'branch' | 'issue' | 'pr' | null
+ issueKey: string | null // Canonical, properly-cased issue key (e.g., "PROJ-123")
issue_numbers: string[]
pr_numbers: string[]
issueTracker: string | null
@@ -124,6 +127,7 @@ export class MetadataManager {
branchName: data.branchName ?? null,
worktreePath: data.worktreePath ?? null,
issueType: data.issueType ?? null,
+ issueKey: data.issueKey ?? null,
issue_numbers: data.issue_numbers ?? [],
pr_numbers: data.pr_numbers ?? [],
issueTracker: data.issueTracker ?? null,
@@ -201,6 +205,7 @@ export class MetadataManager {
branchName: input.branchName,
worktreePath: input.worktreePath,
issueType: input.issueType,
+ ...(input.issueKey && { issueKey: input.issueKey }),
issue_numbers: input.issue_numbers,
pr_numbers: input.pr_numbers,
issueTracker: input.issueTracker,
diff --git a/src/lib/PRManager.ts b/src/lib/PRManager.ts
index 29373e89..58fafff0 100644
--- a/src/lib/PRManager.ts
+++ b/src/lib/PRManager.ts
@@ -27,7 +27,7 @@ export class PRManager {
*/
private get issuePrefix(): string {
const providerType = this.settings.issueManagement?.provider ?? 'github'
- const provider = IssueManagementProviderFactory.create(providerType)
+ const provider = IssueManagementProviderFactory.create(providerType, this.settings)
return provider.issuePrefix
}
diff --git a/src/lib/ProviderCoordinator.ts b/src/lib/ProviderCoordinator.ts
new file mode 100644
index 00000000..e49d144d
--- /dev/null
+++ b/src/lib/ProviderCoordinator.ts
@@ -0,0 +1,165 @@
+// ProviderCoordinator - Orchestrates workflows between IssueTracker and VersionControlProvider
+// Manages the interaction between issue tracking and version control systems
+
+import type { IssueTracker } from './IssueTracker.js'
+import type { VersionControlProvider } from './VersionControlProvider.js'
+import { getLogger } from '../utils/logger-context.js'
+
+/**
+ * Options for posting agent output
+ */
+export interface PostAgentOutputOptions {
+ issueNumber?: string | number
+ prNumber?: number
+ body: string
+ cwd?: string
+}
+
+/**
+ * Options for finish workflow
+ */
+export interface FinishWorkflowOptions {
+ branchName: string
+ title: string
+ body: string
+ baseBranch: string
+ issueNumber?: string | number
+ transitionState?: string
+ cwd?: string
+}
+
+/**
+ * Result of finish workflow
+ */
+export interface FinishWorkflowResult {
+ prUrl: string
+ prNumber: number
+ issueTransitioned: boolean
+}
+
+/**
+ * ProviderCoordinator orchestrates workflows across issue tracking and version control providers.
+ *
+ * Key responsibilities:
+ * - Route agent output to the correct destination (issue vs PR)
+ * - Coordinate PR creation with issue state transitions
+ * - Provide a unified interface for start/finish workflows
+ *
+ * Design pattern:
+ * - Uses composition over inheritance
+ * - Delegates provider-specific operations to injected providers
+ * - Handles cross-provider coordination logic
+ */
+export class ProviderCoordinator {
+ constructor(
+ private issueTracker: IssueTracker,
+ private vcsProvider: VersionControlProvider
+ ) {}
+
+ /**
+ * Post agent output to the appropriate destination
+ * - If PR number provided, post to PR
+ * - Otherwise, post to issue
+ */
+ async postAgentOutput(options: PostAgentOutputOptions): Promise {
+ const { issueNumber, prNumber, body, cwd } = options
+
+ if (prNumber) {
+ // Post to PR via VCS provider
+ getLogger().debug('Posting agent output to PR', { prNumber })
+ await this.vcsProvider.createPRComment(prNumber, body, cwd)
+ } else if (issueNumber) {
+ // Post to issue via issue tracker
+ getLogger().debug('Posting agent output to issue', { issueNumber })
+ // Note: This will need the MCP server-based approach or direct API call
+ // For now, we'll throw since this needs to be integrated with the MCP system
+ throw new Error('Issue comment posting not yet implemented in coordinator')
+ } else {
+ throw new Error('Either issueNumber or prNumber must be provided')
+ }
+ }
+
+ /**
+ * Execute finish workflow:
+ * 1. Create PR via VCS provider
+ * 2. Post session summary to PR
+ * 3. Transition issue to target state (e.g., "In Review")
+ */
+ async executeFinishWorkflow(options: FinishWorkflowOptions): Promise {
+ const { branchName, title, body, baseBranch, issueNumber, transitionState, cwd } = options
+
+ // Step 1: Create PR
+ getLogger().debug('Creating PR via VCS provider', { branchName, title })
+ const prUrl = await this.vcsProvider.createPR(branchName, title, body, baseBranch, cwd)
+
+ // Extract PR number from URL
+ const prNumber = this.extractPRNumberFromUrl(prUrl)
+
+ getLogger().info('PR created successfully', { prUrl, prNumber })
+
+ // Step 2: Post session summary to PR (if provided in body)
+ // The body already contains the session summary, so this is handled by createPR
+
+ // Step 3: Transition issue if requested
+ let issueTransitioned = false
+ if (issueNumber && transitionState) {
+ try {
+ // Check if issue tracker supports state transitions
+ if (this.issueTracker.moveIssueToInProgress) {
+ getLogger().debug('Transitioning issue state', { issueNumber, transitionState })
+ // Note: This is a placeholder - actual transition logic will vary by provider
+ // For now, we only support moveIssueToInProgress
+ // TODO: Add more flexible transition support
+ await this.issueTracker.moveIssueToInProgress(issueNumber)
+ issueTransitioned = true
+ getLogger().info('Issue transitioned successfully', { issueNumber, transitionState })
+ } else {
+ getLogger().warn('Issue tracker does not support state transitions', {
+ provider: this.issueTracker.providerName
+ })
+ }
+ } catch (error) {
+ // Don't fail the whole workflow if transition fails
+ getLogger().error('Failed to transition issue', { error, issueNumber })
+ }
+ }
+
+ return {
+ prUrl,
+ prNumber,
+ issueTransitioned,
+ }
+ }
+
+ /**
+ * Extract PR number from PR URL
+ * Handles various VCS provider URL formats
+ */
+ private extractPRNumberFromUrl(url: string): number {
+ // GitHub: https://github.com/owner/repo/pull/123
+ // BitBucket: https://bitbucket.org/workspace/repo/pull-requests/123
+ const githubMatch = url.match(/\/pull\/(\d+)/)
+ const bitbucketMatch = url.match(/\/pull-requests\/(\d+)/)
+
+ const match = githubMatch ?? bitbucketMatch
+ if (match?.[1]) {
+ return parseInt(match[1], 10)
+ }
+
+ throw new Error(`Failed to extract PR number from URL: ${url}`)
+ }
+
+ /**
+ * Get issue tracker instance
+ */
+ getIssueTracker(): IssueTracker {
+ return this.issueTracker
+ }
+
+ /**
+ * Get VCS provider instance
+ */
+ getVCSProvider(): VersionControlProvider {
+ return this.vcsProvider
+ }
+}
diff --git a/src/lib/SessionSummaryService.test.ts b/src/lib/SessionSummaryService.test.ts
index 67d5cdcb..f6fa68b3 100644
--- a/src/lib/SessionSummaryService.test.ts
+++ b/src/lib/SessionSummaryService.test.ts
@@ -169,7 +169,7 @@ describe('SessionSummaryService', () => {
})
// Verify provider was created and comment was posted
- expect(IssueManagementProviderFactory.create).toHaveBeenCalledWith('github')
+ expect(IssueManagementProviderFactory.create).toHaveBeenCalledWith('github', defaultSettings)
expect(mockIssueProvider.createComment).toHaveBeenCalledWith({
number: '123',
body: '## iloom Session Summary\n\n**Key Themes:**\n- Theme one about testing\n- Theme two about implementation\n\n### Key Insights\n- Test insight one\n- Test insight two',
@@ -246,16 +246,18 @@ describe('SessionSummaryService', () => {
})
it('should use correct issue management provider based on settings', async () => {
- vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue({
+ const mockSettingsValue: IloomSettings = {
...defaultSettings,
issueManagement: {
provider: 'linear',
},
- })
+ };
+
+ vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue(mockSettingsValue)
await service.generateAndPostSummary(defaultInput)
- expect(IssueManagementProviderFactory.create).toHaveBeenCalledWith('linear')
+ expect(IssueManagementProviderFactory.create).toHaveBeenCalledWith('linear', mockSettingsValue)
})
it('should skip when Claude returns empty result', async () => {
diff --git a/src/lib/SessionSummaryService.ts b/src/lib/SessionSummaryService.ts
index 94f9af4e..e5049fea 100644
--- a/src/lib/SessionSummaryService.ts
+++ b/src/lib/SessionSummaryService.ts
@@ -400,7 +400,7 @@ export class SessionSummaryService {
): Promise {
// Get the issue management provider from settings
const providerType = (settings.issueManagement?.provider ?? 'github') as IssueProvider
- const provider = IssueManagementProviderFactory.create(providerType)
+ const provider = IssueManagementProviderFactory.create(providerType, settings)
// Apply attribution if configured
const finalSummary = await this.applyAttributionWithSettings(summary, settings, worktreePath)
diff --git a/src/lib/SettingsManager.test.ts b/src/lib/SettingsManager.test.ts
index a187a458..4f472ba2 100644
--- a/src/lib/SettingsManager.test.ts
+++ b/src/lib/SettingsManager.test.ts
@@ -12,6 +12,10 @@ vi.mock('../utils/logger.js', () => ({
},
}))
+const defaultSettings = {
+ git: { commitTimeout: 60000 },
+}
+
describe('SettingsManager', () => {
let settingsManager: SettingsManager
@@ -50,7 +54,7 @@ describe('SettingsManager', () => {
const result = await settingsManager.loadSettings(projectRoot)
// sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly'
- expect(result).toEqual({ ...validSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly' })
+ expect(result).toEqual({ ...validSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings })
})
it('should return empty object when settings file does not exist', async () => {
@@ -66,7 +70,7 @@ describe('SettingsManager', () => {
const result = await settingsManager.loadSettings(projectRoot)
// sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly'
- expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly' })
+ expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings })
})
it('should return empty object when .iloom directory does not exist', async () => {
@@ -82,7 +86,7 @@ describe('SettingsManager', () => {
const result = await settingsManager.loadSettings(projectRoot)
// sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly'
- expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly' })
+ expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings })
})
it('should throw error for malformed JSON in settings file', async () => {
@@ -136,7 +140,7 @@ describe('SettingsManager', () => {
const result = await settingsManager.loadSettings(projectRoot)
// sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly'
- expect(result).toEqual({ ...emptyAgentsSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly' })
+ expect(result).toEqual({ ...emptyAgentsSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings })
})
it('should handle settings file with null agents value', async () => {
@@ -156,7 +160,7 @@ describe('SettingsManager', () => {
const result = await settingsManager.loadSettings(projectRoot)
// sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly'
- expect(result).toEqual({ ...nullAgentsSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly' })
+ expect(result).toEqual({ ...nullAgentsSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings })
})
it('should use process.cwd() when projectRoot not provided', async () => {
@@ -179,7 +183,7 @@ describe('SettingsManager', () => {
const result = await settingsManager.loadSettings()
// sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly'
- expect(result).toEqual({ ...validSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly' })
+ expect(result).toEqual({ ...validSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings })
})
it('should load settings with mainBranch field', async () => {
@@ -1522,7 +1526,7 @@ describe('SettingsManager', () => {
const result = await settingsManager.loadSettings(projectRoot)
// sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly'
- expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly' })
+ expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings })
})
it('should deep merge workflows with partial overrides', async () => {
@@ -2612,6 +2616,85 @@ const error: { code?: string; message: string } = {
})
})
+ describe('bitbucket reviewers configuration', () => {
+ it('should accept valid usernames in reviewers array', async () => {
+ const projectRoot = '/test/project'
+ const validSettings = {
+ versionControl: {
+ provider: 'bitbucket',
+ bitbucket: {
+ username: 'testuser',
+ apiToken: 'test-token',
+ reviewers: ['alice', 'bob_smith'],
+ },
+ },
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(validSettings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ const result = await settingsManager.loadSettings(projectRoot)
+ expect(result.versionControl?.bitbucket?.reviewers).toEqual(['alice', 'bob_smith'])
+ })
+
+ it('should allow empty reviewers array', async () => {
+ const projectRoot = '/test/project'
+ const validSettings = {
+ versionControl: {
+ provider: 'bitbucket',
+ bitbucket: {
+ username: 'testuser',
+ apiToken: 'test-token',
+ reviewers: [],
+ },
+ },
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(validSettings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ const result = await settingsManager.loadSettings(projectRoot)
+ expect(result.versionControl?.bitbucket?.reviewers).toEqual([])
+ })
+
+ it('should allow missing reviewers field', async () => {
+ const projectRoot = '/test/project'
+ const validSettings = {
+ versionControl: {
+ provider: 'bitbucket',
+ bitbucket: {
+ username: 'testuser',
+ apiToken: 'test-token',
+ },
+ },
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(validSettings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ const result = await settingsManager.loadSettings(projectRoot)
+ expect(result.versionControl?.bitbucket?.reviewers).toBeUndefined()
+ })
+ })
+
describe('getSpinModel', () => {
it('should return opus by default when spin not configured', () => {
const settings = { sourceEnvOnStart: false }
@@ -2645,4 +2728,171 @@ const error: { code?: string; message: string } = {
expect(result).toBe('opus')
})
})
+
+ describe('git.commitTimeout configuration', () => {
+ it('should return undefined git section when not specified in settings', async () => {
+ const projectRoot = '/test/project'
+ const settings = {
+ mainBranch: 'main',
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ const result = await settingsManager.loadSettings(projectRoot)
+
+ // git section is optional, so it should be undefined when not provided
+ expect(result.git).toEqual(defaultSettings.git)
+ })
+
+ it('should apply default commitTimeout value (60000) when git section exists but commitTimeout not specified', async () => {
+ const projectRoot = '/test/project'
+ const settings = {
+ mainBranch: 'main',
+ ...defaultSettings
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ const result = await settingsManager.loadSettings(projectRoot)
+
+ // Default should be applied by Zod schema when git object exists
+ expect(result.git?.commitTimeout).toBe(60000)
+ })
+
+ it('should accept custom commitTimeout value', async () => {
+ const projectRoot = '/test/project'
+ const settings = {
+ mainBranch: 'main',
+ git: {
+ commitTimeout: 120000,
+ },
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ const result = await settingsManager.loadSettings(projectRoot)
+
+ expect(result.git?.commitTimeout).toBe(120000)
+ })
+
+ it('should reject commitTimeout below minimum (1000ms)', async () => {
+ const projectRoot = '/test/project'
+ const settings = {
+ mainBranch: 'main',
+ git: {
+ commitTimeout: 500, // Below minimum
+ },
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ await expect(settingsManager.loadSettings(projectRoot)).rejects.toThrow(
+ /Commit timeout must be at least 1000ms/
+ )
+ })
+
+ it('should reject commitTimeout above maximum (600000ms)', async () => {
+ const projectRoot = '/test/project'
+ const settings = {
+ mainBranch: 'main',
+ git: {
+ commitTimeout: 700000, // Above maximum
+ },
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ await expect(settingsManager.loadSettings(projectRoot)).rejects.toThrow(
+ /Commit timeout cannot exceed 600000ms/
+ )
+ })
+
+ it('should accept minimum valid commitTimeout (1000ms)', async () => {
+ const projectRoot = '/test/project'
+ const settings = {
+ mainBranch: 'main',
+ git: {
+ commitTimeout: 1000, // Exact minimum
+ },
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ const result = await settingsManager.loadSettings(projectRoot)
+
+ expect(result.git?.commitTimeout).toBe(1000)
+ })
+
+ it('should accept maximum valid commitTimeout (600000ms)', async () => {
+ const projectRoot = '/test/project'
+ const settings = {
+ mainBranch: 'main',
+ git: {
+ commitTimeout: 600000, // Exact maximum
+ },
+ }
+
+ const error: { code?: string; message: string } = {
+ code: 'ENOENT',
+ message: 'ENOENT: no such file or directory',
+ }
+
+ vi.mocked(readFile)
+ .mockRejectedValueOnce(error) // global settings
+ .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json
+ .mockRejectedValueOnce(error) // settings.local.json
+
+ const result = await settingsManager.loadSettings(projectRoot)
+
+ expect(result.git?.commitTimeout).toBe(600000)
+ })
+ })
})
diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts
index f862b440..78ae3e0e 100644
--- a/src/lib/SettingsManager.ts
+++ b/src/lib/SettingsManager.ts
@@ -314,7 +314,7 @@ export const IloomSettingsSchema = z.object({
databaseProviders: DatabaseProvidersSettingsSchema.describe('Database provider configurations'),
issueManagement: z
.object({
- provider: z.enum(['github', 'linear']).optional().default('github').describe('Issue tracker provider (github, linear)'),
+ provider: z.enum(['github', 'linear', 'jira']).optional().default('github').describe('Issue tracker provider (github, linear, jira)'),
github: z
.object({
remote: z
@@ -339,16 +339,75 @@ export const IloomSettingsSchema = z.object({
.describe('Linear API token (lin_api_...). SECURITY: Store in settings.local.json only, never commit to source control.'),
})
.optional(),
+ jira: z
+ .object({
+ host: z
+ .string()
+ .min(1, 'Jira host cannot be empty')
+ .describe('Jira instance URL (e.g., "https://yourcompany.atlassian.net")'),
+ username: z
+ .string()
+ .min(1, 'Jira username/email cannot be empty')
+ .describe('Jira username or email address'),
+ apiToken: z
+ .string()
+ .optional()
+ .describe('Jira API token. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://id.atlassian.com/manage-profile/security/api-tokens'),
+ projectKey: z
+ .string()
+ .min(1, 'Project key cannot be empty')
+ .describe('Jira project key (e.g., "PROJ", "ENG")'),
+ boardId: z
+ .string()
+ .optional()
+ .describe('Jira board ID for sprint/workflow operations (optional)'),
+ transitionMappings: z
+ .record(z.string(), z.string())
+ .optional()
+ .describe('Map iloom states to Jira transition names (e.g., {"In Review": "Start Review"})'),
+ })
+ .optional(),
})
.optional()
.describe('Issue management configuration'),
+ versionControl: z
+ .object({
+ provider: z.enum(['github', 'bitbucket']).optional().default('github').describe('Version control provider (github, bitbucket)'),
+ bitbucket: z
+ .object({
+ username: z
+ .string()
+ .min(1, 'BitBucket username cannot be empty')
+ .describe('BitBucket username'),
+ apiToken: z
+ .string()
+ .optional()
+ .describe('BitBucket API token. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://bitbucket.org/account/settings/app-passwords/ (Note: App passwords deprecated Sep 2025, use API tokens)'),
+ workspace: z
+ .string()
+ .optional()
+ .describe('BitBucket workspace (optional, auto-detected from git remote if not provided)'),
+ repoSlug: z
+ .string()
+ .optional()
+ .describe('BitBucket repository slug (optional, auto-detected from git remote if not provided)'),
+ reviewers: z
+ .array(z.string().describe('Reviewer username'))
+ .optional()
+ .describe('List of usernames to add as PR reviewers. Usernames are resolved to Bitbucket account IDs at PR creation time.'),
+ })
+ .optional(),
+ })
+ .optional()
+ .describe('Version control provider configuration'),
mergeBehavior: z
.object({
- mode: z.enum(['local', 'github-pr', 'github-draft-pr']).default('local'),
+ mode: z.enum(['local', 'github-pr', 'github-draft-pr', 'bitbucket-pr']).default('local'),
remote: z.string().optional(),
+ prTitlePrefix: z.boolean().default(true).optional().describe('Prefix PR titles with the issue number (e.g., "QLH-123: Title"). Default: true'),
})
.optional()
- .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), or github-draft-pr (create draft PR at start, mark ready on finish)'),
+ .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), github-draft-pr (create draft PR at start, mark ready on finish), or bitbucket-pr (create BitBucket PR)'),
ide: z
.object({
type: z
@@ -392,6 +451,17 @@ export const IloomSettingsSchema = z.object({
'"upstreamOnly" - only show for contributions to external repositories (e.g., open source). ' +
'"on" - always show attribution.'
),
+ git: z
+ .object({
+ commitTimeout: z
+ .number()
+ .min(1000, 'Commit timeout must be at least 1000ms')
+ .max(600000, 'Commit timeout cannot exceed 600000ms (10 minutes)')
+ .default(60000)
+ .describe('Timeout in milliseconds for git commit operations. Increase for long-running pre-commit hooks.'),
+ })
+ .default({ }) // ensures the object always exists and uses default for the inner properties
+ .describe('Git operation settings'),
})
/**
@@ -489,7 +559,7 @@ export const IloomSettingsSchemaNoDefaults = z.object({
databaseProviders: DatabaseProvidersSettingsSchema.describe('Database provider configurations'),
issueManagement: z
.object({
- provider: z.enum(['github', 'linear']).optional().describe('Issue tracker provider (github, linear)'),
+ provider: z.enum(['github', 'linear', 'jira']).optional().describe('Issue tracker provider (github, linear, jira)'),
github: z
.object({
remote: z
@@ -514,16 +584,75 @@ export const IloomSettingsSchemaNoDefaults = z.object({
.describe('Linear API token (lin_api_...). SECURITY: Store in settings.local.json only, never commit to source control.'),
})
.optional(),
+ jira: z
+ .object({
+ host: z
+ .string()
+ .min(1, 'Jira host cannot be empty')
+ .describe('Jira instance URL (e.g., "https://yourcompany.atlassian.net")'),
+ username: z
+ .string()
+ .min(1, 'Jira username/email cannot be empty')
+ .describe('Jira username or email address'),
+ apiToken: z
+ .string()
+ .optional()
+ .describe('Jira API token. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://id.atlassian.com/manage-profile/security/api-tokens'),
+ projectKey: z
+ .string()
+ .min(1, 'Project key cannot be empty')
+ .describe('Jira project key (e.g., "PROJ", "ENG")'),
+ boardId: z
+ .string()
+ .optional()
+ .describe('Jira board ID for sprint/workflow operations (optional)'),
+ transitionMappings: z
+ .record(z.string(), z.string())
+ .optional()
+ .describe('Map iloom states to Jira transition names (e.g., {"In Review": "Start Review"})'),
+ })
+ .optional(),
})
.optional()
.describe('Issue management configuration'),
+ versionControl: z
+ .object({
+ provider: z.enum(['github', 'bitbucket']).optional().describe('Version control provider (github, bitbucket)'),
+ bitbucket: z
+ .object({
+ username: z
+ .string()
+ .min(1, 'BitBucket username cannot be empty')
+ .describe('BitBucket username'),
+ apiToken: z
+ .string()
+ .optional()
+ .describe('BitBucket API token. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://bitbucket.org/account/settings/app-passwords/ (Note: App passwords deprecated Sep 2025, use API tokens)'),
+ workspace: z
+ .string()
+ .optional()
+ .describe('BitBucket workspace (optional, auto-detected from git remote if not provided)'),
+ repoSlug: z
+ .string()
+ .optional()
+ .describe('BitBucket repository slug (optional, auto-detected from git remote if not provided)'),
+ reviewers: z
+ .array(z.string().describe('Reviewer username'))
+ .optional()
+ .describe('List of usernames to add as PR reviewers. Usernames are resolved to Bitbucket account IDs at PR creation time.'),
+ })
+ .optional(),
+ })
+ .optional()
+ .describe('Version control provider configuration'),
mergeBehavior: z
.object({
- mode: z.enum(['local', 'github-pr', 'github-draft-pr']).optional(),
+ mode: z.enum(['local', 'github-pr', 'github-draft-pr', 'bitbucket-pr']).optional(),
remote: z.string().optional(),
+ prTitlePrefix: z.boolean().optional(),
})
.optional()
- .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), or github-draft-pr (create draft PR at start, mark ready on finish)'),
+ .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), github-draft-pr (create draft PR at start, mark ready on finish), or bitbucket-pr (create BitBucket PR)'),
ide: z
.object({
type: z
@@ -566,6 +695,17 @@ export const IloomSettingsSchemaNoDefaults = z.object({
'"upstreamOnly" - only show for contributions to external repositories (e.g., open source). ' +
'"on" - always show attribution.'
),
+ git: z
+ .object({
+ commitTimeout: z
+ .number()
+ .min(1000, 'Commit timeout must be at least 1000ms')
+ .max(600000, 'Commit timeout cannot exceed 600000ms (10 minutes)')
+ .optional()
+ .describe('Timeout in milliseconds for git commit operations. Increase for long-running pre-commit hooks.'),
+ })
+ .optional()
+ .describe('Git operation settings'),
})
/**
diff --git a/src/lib/VCSProviderFactory.test.ts b/src/lib/VCSProviderFactory.test.ts
new file mode 100644
index 00000000..000e7761
--- /dev/null
+++ b/src/lib/VCSProviderFactory.test.ts
@@ -0,0 +1,254 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { VCSProviderFactory } from './VCSProviderFactory.js'
+import { BitBucketVCSProvider } from './providers/bitbucket/index.js'
+import type { IloomSettings } from './SettingsManager.js'
+
+// Mock the BitBucketVCSProvider
+vi.mock('./providers/bitbucket/index.js', () => ({
+ BitBucketVCSProvider: vi.fn().mockImplementation((config) => ({
+ providerName: 'bitbucket',
+ config,
+ })),
+}))
+
+// Mock the logger
+vi.mock('../utils/logger-context.js', () => ({
+ getLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}))
+
+describe('VCSProviderFactory', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('create', () => {
+ it('should return null for github provider', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'github',
+ },
+ }
+
+ const result = VCSProviderFactory.create(settings)
+ expect(result).toBeNull()
+ })
+
+ it('should return null when no provider is configured', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ }
+
+ const result = VCSProviderFactory.create(settings)
+ expect(result).toBeNull()
+ })
+
+ it('should create BitBucketVCSProvider with basic config', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'bitbucket',
+ bitbucket: {
+ username: 'testuser',
+ apiToken: 'test-token',
+ },
+ },
+ }
+
+ VCSProviderFactory.create(settings)
+
+ expect(BitBucketVCSProvider).toHaveBeenCalledWith({
+ username: 'testuser',
+ apiToken: 'test-token',
+ })
+ })
+
+ it('should pass workspace and repoSlug when configured', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'bitbucket',
+ bitbucket: {
+ username: 'testuser',
+ apiToken: 'test-token',
+ workspace: 'my-workspace',
+ repoSlug: 'my-repo',
+ },
+ },
+ }
+
+ VCSProviderFactory.create(settings)
+
+ expect(BitBucketVCSProvider).toHaveBeenCalledWith({
+ username: 'testuser',
+ apiToken: 'test-token',
+ workspace: 'my-workspace',
+ repoSlug: 'my-repo',
+ })
+ })
+
+ it('should pass reviewers when configured', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'bitbucket',
+ bitbucket: {
+ username: 'testuser',
+ apiToken: 'test-token',
+ reviewers: ['alice@example.com', 'bob@example.com'],
+ },
+ },
+ }
+
+ VCSProviderFactory.create(settings)
+
+ expect(BitBucketVCSProvider).toHaveBeenCalledWith({
+ username: 'testuser',
+ apiToken: 'test-token',
+ reviewers: ['alice@example.com', 'bob@example.com'],
+ })
+ })
+
+ it('should pass all config options together', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'bitbucket',
+ bitbucket: {
+ username: 'testuser',
+ apiToken: 'test-token',
+ workspace: 'my-workspace',
+ repoSlug: 'my-repo',
+ reviewers: ['alice@example.com'],
+ },
+ },
+ }
+
+ VCSProviderFactory.create(settings)
+
+ expect(BitBucketVCSProvider).toHaveBeenCalledWith({
+ username: 'testuser',
+ apiToken: 'test-token',
+ workspace: 'my-workspace',
+ repoSlug: 'my-repo',
+ reviewers: ['alice@example.com'],
+ })
+ })
+
+ it('should throw when bitbucket username is missing', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'bitbucket',
+ bitbucket: {
+ username: '',
+ apiToken: 'test-token',
+ },
+ },
+ }
+
+ expect(() => VCSProviderFactory.create(settings)).toThrow(
+ 'BitBucket username is required'
+ )
+ })
+
+ it('should throw when bitbucket apiToken is missing', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'bitbucket',
+ bitbucket: {
+ username: 'testuser',
+ },
+ },
+ }
+
+ expect(() => VCSProviderFactory.create(settings)).toThrow(
+ 'BitBucket API token is required'
+ )
+ })
+ })
+
+ describe('isConfigured', () => {
+ it('should return true for bitbucket provider', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'bitbucket',
+ },
+ }
+
+ expect(VCSProviderFactory.isConfigured(settings)).toBe(true)
+ })
+
+ it('should return false for github provider', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'github',
+ },
+ }
+
+ expect(VCSProviderFactory.isConfigured(settings)).toBe(false)
+ })
+
+ it('should return false when no provider is configured', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ }
+
+ expect(VCSProviderFactory.isConfigured(settings)).toBe(false)
+ })
+ })
+
+ describe('getProviderName', () => {
+ it('should return bitbucket when configured', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'bitbucket',
+ },
+ }
+
+ expect(VCSProviderFactory.getProviderName(settings)).toBe('bitbucket')
+ })
+
+ it('should return github when configured', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ versionControl: {
+ provider: 'github',
+ },
+ }
+
+ expect(VCSProviderFactory.getProviderName(settings)).toBe('github')
+ })
+
+ it('should return undefined when no provider is configured', () => {
+ const settings: IloomSettings = {
+ sourceEnvOnStart: false,
+ attribution: 'upstreamOnly',
+ }
+
+ expect(VCSProviderFactory.getProviderName(settings)).toBeUndefined()
+ })
+ })
+})
diff --git a/src/lib/VCSProviderFactory.ts b/src/lib/VCSProviderFactory.ts
new file mode 100644
index 00000000..871a05dc
--- /dev/null
+++ b/src/lib/VCSProviderFactory.ts
@@ -0,0 +1,95 @@
+// VCSProviderFactory - creates appropriate VersionControlProvider based on settings
+// Follows pattern from IssueTrackerFactory
+
+import type { VersionControlProvider } from './VersionControlProvider.js'
+import { BitBucketVCSProvider, type BitBucketVCSConfig } from './providers/bitbucket/index.js'
+import type { IloomSettings } from './SettingsManager.js'
+import { getLogger } from '../utils/logger-context.js'
+
+export type VCSProviderType = 'github' | 'bitbucket'
+
+/**
+ * Factory for creating VersionControlProvider instances based on settings
+ *
+ * Note: GitHub VCS operations still use PRManager with gh CLI for now.
+ * This factory is primarily for BitBucket and future VCS providers.
+ */
+export class VCSProviderFactory {
+ /**
+ * Create a VersionControlProvider instance based on settings configuration
+ *
+ * @param settings - iloom settings containing versionControl.provider
+ * @returns VersionControlProvider instance configured for the specified provider
+ * @throws Error if provider type is not supported or required config is missing
+ */
+ static create(settings: IloomSettings): VersionControlProvider | null {
+ const provider = settings.versionControl?.provider
+
+ // If no versionControl config, return null (use legacy PRManager for GitHub)
+ if (!provider) {
+ getLogger().debug('VCSProviderFactory: No versionControl.provider configured, using legacy PRManager')
+ return null
+ }
+
+ getLogger().debug(`VCSProviderFactory: Creating VCS provider for "${provider}"`)
+
+ switch (provider) {
+ case 'github':
+ // GitHub still uses PRManager with gh CLI
+ getLogger().debug('VCSProviderFactory: GitHub uses legacy PRManager, returning null')
+ return null
+
+ case 'bitbucket': {
+ const bbSettings = settings.versionControl?.bitbucket
+
+ if (!bbSettings?.username) {
+ throw new Error('BitBucket username is required. Configure versionControl.bitbucket.username in .iloom/settings.json')
+ }
+ if (!bbSettings?.apiToken) {
+ throw new Error('BitBucket API token is required. Configure versionControl.bitbucket.apiToken in .iloom/settings.local.json')
+ }
+
+ const bbConfig: BitBucketVCSConfig = {
+ username: bbSettings.username,
+ apiToken: bbSettings.apiToken,
+ }
+
+ if (bbSettings.workspace) {
+ bbConfig.workspace = bbSettings.workspace
+ }
+ if (bbSettings.repoSlug) {
+ bbConfig.repoSlug = bbSettings.repoSlug
+ }
+ if (bbSettings.reviewers) {
+ bbConfig.reviewers = bbSettings.reviewers
+ }
+
+ getLogger().debug(`VCSProviderFactory: Creating BitBucketVCSProvider for user: ${bbSettings.username}`)
+ return new BitBucketVCSProvider(bbConfig)
+ }
+
+ default:
+ throw new Error(`Unsupported VCS provider: ${provider}`)
+ }
+ }
+
+ /**
+ * Check if a VCS provider is configured
+ *
+ * @param settings - iloom settings
+ * @returns true if versionControl provider is configured
+ */
+ static isConfigured(settings: IloomSettings): boolean {
+ return settings.versionControl?.provider !== undefined && settings.versionControl?.provider !== 'github'
+ }
+
+ /**
+ * Get the configured provider name from settings
+ *
+ * @param settings - iloom settings
+ * @returns Provider type string or undefined if not configured
+ */
+ static getProviderName(settings: IloomSettings): VCSProviderType | undefined {
+ return settings.versionControl?.provider as VCSProviderType | undefined
+ }
+}
diff --git a/src/lib/VersionControlProvider.ts b/src/lib/VersionControlProvider.ts
new file mode 100644
index 00000000..0c1e757b
--- /dev/null
+++ b/src/lib/VersionControlProvider.ts
@@ -0,0 +1,69 @@
+// VersionControlProvider interface definition
+// Generic interface for version control providers (GitHub, BitBucket, GitLab, etc.)
+
+import type { PullRequest } from '../types/index.js'
+
+/**
+ * Result of PR creation operation
+ */
+export interface PRCreationResult {
+ url: string
+ number: number
+ wasExisting: boolean
+}
+
+/**
+ * Existing PR information
+ */
+export interface ExistingPR {
+ number: number
+ url: string
+}
+
+/**
+ * VersionControlProvider interface - abstraction for VCS providers
+ *
+ * Design Philosophy:
+ * - Focuses exclusively on PR/MR (Pull Request/Merge Request) operations
+ * - Separates version control concerns from issue tracking
+ * - Identifiers use number for PR numbers (consistent with most VCS systems)
+ * - Providers expose capabilities via metadata fields
+ */
+export interface VersionControlProvider {
+ // Metadata - provider identification and capabilities
+ readonly providerName: string
+ readonly supportsForks: boolean
+ readonly supportsDraftPRs: boolean
+
+ // PR operations - core functionality all providers must support
+ checkForExistingPR(branchName: string, cwd?: string): Promise
+ createPR(
+ branchName: string,
+ title: string,
+ body: string,
+ baseBranch: string,
+ cwd?: string
+ ): Promise
+ createDraftPR?(
+ branchName: string,
+ title: string,
+ body: string,
+ baseBranch: string,
+ cwd?: string
+ ): Promise
+ markPRReadyForReview?(prNumber: number, cwd?: string): Promise
+
+ // PR metadata and state
+ fetchPR(prNumber: number, cwd?: string): Promise
+ getPRUrl(prNumber: number, cwd?: string): Promise
+
+ // PR comments
+ createPRComment(prNumber: number, body: string, cwd?: string): Promise
+
+ // Remote and repository detection
+ detectRepository(cwd?: string): Promise<{ owner: string; repo: string } | null>
+ getTargetRemote(cwd?: string): Promise
+
+ // PR body generation (optional, can delegate to external service)
+ generatePRBody?(issueNumber: string | number | undefined, worktreePath: string): Promise
+}
diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.test.ts b/src/lib/providers/bitbucket/BitBucketApiClient.test.ts
new file mode 100644
index 00000000..cffa6d34
--- /dev/null
+++ b/src/lib/providers/bitbucket/BitBucketApiClient.test.ts
@@ -0,0 +1,445 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { BitBucketApiClient, type BitBucketConfig } from './BitBucketApiClient.js'
+
+// Mock the https module
+vi.mock('node:https', () => ({
+ default: {
+ request: vi.fn(),
+ },
+}))
+
+// Mock the logger
+vi.mock('../../../utils/logger-context.js', () => ({
+ getLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}))
+
+describe('BitBucketApiClient', () => {
+ let client: BitBucketApiClient
+ const config: BitBucketConfig = {
+ username: 'testuser',
+ apiToken: 'test-api-token',
+ workspace: 'test-workspace',
+ repoSlug: 'test-repo',
+ }
+
+ beforeEach(() => {
+ client = new BitBucketApiClient(config)
+ })
+
+ describe('createPullRequest', () => {
+ it('should include reviewers in payload when provided', async () => {
+ const https = await import('node:https')
+ let capturedPayload: string | undefined
+
+ // Mock the request to capture the payload
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ const mockResponse = {
+ statusCode: 201,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ handler(JSON.stringify({
+ id: 123,
+ title: 'Test PR',
+ links: { html: { href: 'https://bitbucket.org/test/pr/123' } },
+ }))
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: (data: string) => { capturedPayload = data },
+ end: vi.fn(),
+ }
+ })
+
+ await client.createPullRequest(
+ 'workspace',
+ 'repo',
+ 'Test PR',
+ 'Test description',
+ 'feature-branch',
+ 'main',
+ ['account-id-1', 'account-id-2']
+ )
+
+ expect(capturedPayload).toBeDefined()
+ const payload = JSON.parse(capturedPayload!)
+ expect(payload.reviewers).toEqual([
+ { account_id: 'account-id-1' },
+ { account_id: 'account-id-2' },
+ ])
+ })
+
+ it('should not include reviewers in payload when not provided', async () => {
+ const https = await import('node:https')
+ let capturedPayload: string | undefined
+
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ const mockResponse = {
+ statusCode: 201,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ handler(JSON.stringify({
+ id: 123,
+ title: 'Test PR',
+ links: { html: { href: 'https://bitbucket.org/test/pr/123' } },
+ }))
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: (data: string) => { capturedPayload = data },
+ end: vi.fn(),
+ }
+ })
+
+ await client.createPullRequest(
+ 'workspace',
+ 'repo',
+ 'Test PR',
+ 'Test description',
+ 'feature-branch',
+ 'main'
+ )
+
+ expect(capturedPayload).toBeDefined()
+ const payload = JSON.parse(capturedPayload!)
+ expect(payload.reviewers).toBeUndefined()
+ })
+
+ it('should not include reviewers when array is empty', async () => {
+ const https = await import('node:https')
+ let capturedPayload: string | undefined
+
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ const mockResponse = {
+ statusCode: 201,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ handler(JSON.stringify({
+ id: 123,
+ title: 'Test PR',
+ links: { html: { href: 'https://bitbucket.org/test/pr/123' } },
+ }))
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: (data: string) => { capturedPayload = data },
+ end: vi.fn(),
+ }
+ })
+
+ await client.createPullRequest(
+ 'workspace',
+ 'repo',
+ 'Test PR',
+ 'Test description',
+ 'feature-branch',
+ 'main',
+ []
+ )
+
+ expect(capturedPayload).toBeDefined()
+ const payload = JSON.parse(capturedPayload!)
+ expect(payload.reviewers).toBeUndefined()
+ })
+ })
+
+ describe('findUsersByUsername', () => {
+ it('should return map of username to account_id for matched users', async () => {
+ const https = await import('node:https')
+
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ const mockResponse = {
+ statusCode: 200,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ handler(JSON.stringify({
+ values: [
+ { user: { account_id: 'acc-1', display_name: 'Alice Test', uuid: 'uuid-1', nickname: 'alice' } },
+ { user: { account_id: 'acc-2', display_name: 'Bob Example', uuid: 'uuid-2', nickname: 'bob' } },
+ ],
+ }))
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ }
+ })
+
+ const result = await client.findUsersByUsername('workspace', ['alice', 'bob'])
+
+ expect(result.get('alice')).toBe('acc-1')
+ expect(result.get('bob')).toBe('acc-2')
+ })
+
+ it('should return empty map when no users match', async () => {
+ const https = await import('node:https')
+
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ const mockResponse = {
+ statusCode: 200,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ handler(JSON.stringify({
+ values: [
+ { user: { account_id: 'acc-1', display_name: 'Charlie Different', uuid: 'uuid-1', nickname: 'charlie' } },
+ ],
+ }))
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ }
+ })
+
+ const result = await client.findUsersByUsername('workspace', ['alice'])
+
+ expect(result.size).toBe(0)
+ })
+
+ it('should handle API errors by throwing', async () => {
+ const https = await import('node:https')
+
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ const mockResponse = {
+ statusCode: 403,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ handler(JSON.stringify({ error: { message: 'Access denied' } }))
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ }
+ })
+
+ // Should throw on API error
+ await expect(client.findUsersByUsername('workspace', ['alice'])).rejects.toThrow('BitBucket API error')
+ })
+
+ it('should handle pagination when fetching workspace members', async () => {
+ const https = await import('node:https')
+ let requestCount = 0
+ const requestPaths: string[] = []
+
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ requestCount++
+ // Capture the path used in each request to verify no URL duplication
+ requestPaths.push((options as { path: string }).path)
+ const mockResponse = {
+ statusCode: 200,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ // First request returns first page with 'next' URL
+ if (requestCount === 1) {
+ handler(JSON.stringify({
+ values: [
+ { user: { account_id: 'acc-1', display_name: 'Alice Test', uuid: 'uuid-1', nickname: 'alice' } },
+ ],
+ next: 'https://api.bitbucket.org/2.0/workspaces/workspace/members?page=2',
+ }))
+ } else {
+ // Second request returns second page without 'next'
+ handler(JSON.stringify({
+ values: [
+ { user: { account_id: 'acc-2', display_name: 'Bob Example', uuid: 'uuid-2', nickname: 'bob' } },
+ ],
+ }))
+ }
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ }
+ })
+
+ const result = await client.findUsersByUsername('workspace', ['alice', 'bob'])
+
+ // Should have made 2 requests (one for each page)
+ expect(requestCount).toBe(2)
+ // Should have found both users from different pages
+ expect(result.get('alice')).toBe('acc-1')
+ expect(result.get('bob')).toBe('acc-2')
+ // Verify no URL path duplication (bug fix verification)
+ // First request should be the initial endpoint
+ expect(requestPaths[0]).toBe('/2.0/workspaces/workspace/members')
+ // Second request should be the pagination path (not /2.0/2.0/...)
+ expect(requestPaths[1]).toBe('/2.0/workspaces/workspace/members?page=2')
+ })
+
+ it('should match by display_name when nickname does not match', async () => {
+ const https = await import('node:https')
+
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ const mockResponse = {
+ statusCode: 200,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ handler(JSON.stringify({
+ values: [
+ { user: { account_id: 'acc-1', display_name: 'alice', uuid: 'uuid-1', nickname: 'alice123' } },
+ ],
+ }))
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ }
+ })
+
+ const result = await client.findUsersByUsername('workspace', ['alice'])
+
+ expect(result.get('alice')).toBe('acc-1')
+ })
+ })
+
+ describe('getCurrentUser', () => {
+ it('should return current user data from /user endpoint', async () => {
+ const https = await import('node:https')
+
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ const mockResponse = {
+ statusCode: 200,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ handler(JSON.stringify({
+ account_id: 'acc-current-user',
+ display_name: 'Current User',
+ nickname: 'currentuser',
+ }))
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ }
+ })
+
+ const user = await client.getCurrentUser()
+
+ expect(user.account_id).toBe('acc-current-user')
+ expect(user.display_name).toBe('Current User')
+ expect(user.nickname).toBe('currentuser')
+ })
+
+ it('should throw on API error', async () => {
+ const https = await import('node:https')
+
+ vi.mocked(https.default.request).mockImplementation((options, callback) => {
+ const mockResponse = {
+ statusCode: 401,
+ on: vi.fn((event, handler) => {
+ if (event === 'data') {
+ handler(JSON.stringify({ error: { message: 'Unauthorized' } }))
+ }
+ if (event === 'end') {
+ handler()
+ }
+ return mockResponse
+ }),
+ }
+ // @ts-expect-error - Mock callback
+ callback(mockResponse)
+ return {
+ on: vi.fn(),
+ write: vi.fn(),
+ end: vi.fn(),
+ }
+ })
+
+ await expect(client.getCurrentUser()).rejects.toThrow('BitBucket API error')
+ })
+ })
+
+ describe('getWorkspace', () => {
+ it('should return configured workspace', () => {
+ expect(client.getWorkspace()).toBe('test-workspace')
+ })
+ })
+
+ describe('getRepoSlug', () => {
+ it('should return configured repoSlug', () => {
+ expect(client.getRepoSlug()).toBe('test-repo')
+ })
+ })
+})
diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.ts b/src/lib/providers/bitbucket/BitBucketApiClient.ts
new file mode 100644
index 00000000..aa21ace2
--- /dev/null
+++ b/src/lib/providers/bitbucket/BitBucketApiClient.ts
@@ -0,0 +1,391 @@
+// BitBucketApiClient - REST API wrapper for BitBucket operations
+// Handles authentication and common API request patterns
+
+import https from 'node:https'
+import { getLogger } from '../../../utils/logger-context.js'
+
+/**
+ * BitBucket API configuration
+ */
+export interface BitBucketConfig {
+ username: string
+ apiToken: string // API token from BitBucket settings
+ workspace?: string // Optional, can be auto-detected from git remote
+ repoSlug?: string // Optional, can be auto-detected from git remote
+}
+
+/**
+ * BitBucket pull request response from API
+ */
+export interface BitBucketPullRequest {
+ id: number
+ title: string
+ description: string
+ state: 'OPEN' | 'MERGED' | 'DECLINED' | 'SUPERSEDED'
+ author: {
+ display_name: string
+ uuid: string
+ }
+ source: {
+ branch: {
+ name: string
+ }
+ }
+ destination: {
+ branch: {
+ name: string
+ }
+ }
+ created_on: string
+ updated_on: string
+ links: {
+ html: {
+ href: string
+ }
+ }
+ [key: string]: unknown
+}
+
+/**
+ * BitBucket workspace member response from API
+ * Used for resolving usernames to account IDs
+ */
+export interface BitBucketWorkspaceMember {
+ user: {
+ account_id: string
+ display_name: string
+ uuid: string
+ nickname?: string
+ }
+}
+
+/**
+ * BitBucket repository response from API
+ */
+export interface BitBucketRepository {
+ slug: string
+ name: string
+ full_name: string
+ workspace: {
+ slug: string
+ }
+ links: {
+ html: {
+ href: string
+ }
+ }
+ [key: string]: unknown
+}
+
+interface BitBucketWorkspaceMembersResponse { values: BitBucketWorkspaceMember[]; next?: string }
+
+/**
+ * BitBucket current user response from /user endpoint
+ */
+export interface BitBucketCurrentUser {
+ account_id: string
+ display_name: string
+ nickname?: string
+}
+
+/**
+ * BitBucketApiClient provides low-level REST API access to BitBucket
+ *
+ * Authentication: Basic Auth with username and API token
+ * API Reference: https://developer.atlassian.com/cloud/bitbucket/rest/intro/
+ *
+ * Note: As of September 9, 2025, BitBucket app passwords can no longer be created.
+ * Use API tokens with scopes instead. All existing app passwords will be disabled on June 9, 2026.
+ */
+export class BitBucketApiClient {
+ private readonly baseUrl = 'https://api.bitbucket.org/2.0'
+ private readonly authHeader: string
+ private readonly workspace: string | undefined
+ private readonly repoSlug: string | undefined
+
+ constructor(config: BitBucketConfig) {
+ // Create Basic Auth header with API token
+ const credentials = Buffer.from(`${config.username}:${config.apiToken}`).toString('base64')
+ this.authHeader = `Basic ${credentials}`
+
+ this.workspace = config.workspace
+ this.repoSlug = config.repoSlug
+ }
+
+ /**
+ * Make an HTTP request to BitBucket API
+ */
+ private async request(
+ method: 'GET' | 'POST',
+ endpoint: string,
+ body?: unknown
+ ): Promise {
+ // If endpoint is already a full URL, use it directly; otherwise prepend baseUrl
+ const url = endpoint.startsWith('http://') || endpoint.startsWith('https://')
+ ? new URL(endpoint)
+ : new URL(`${this.baseUrl}${endpoint}`)
+ getLogger().debug(`BitBucket API ${method} request`, { url: url.toString() })
+
+ return new Promise((resolve, reject) => {
+ const options: https.RequestOptions = {
+ hostname: url.hostname,
+ port: url.port || 443,
+ path: url.pathname + url.search,
+ method,
+ headers: {
+ 'Authorization': this.authHeader,
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ }
+
+ const req = https.request(options, (res) => {
+ let data = ''
+
+ res.on('data', (chunk) => {
+ data += chunk
+ })
+
+ res.on('end', () => {
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
+ reject(new Error(`BitBucket API error (${res.statusCode}): ${data}`))
+ return
+ }
+
+ // Handle empty response
+ if (res.statusCode === 204 || !data) {
+ resolve({} as T)
+ return
+ }
+
+ try {
+ resolve(JSON.parse(data) as T)
+ } catch (error) {
+ reject(new Error(`Failed to parse BitBucket API response: ${error}`))
+ }
+ })
+ })
+
+ req.on('error', (error) => {
+ reject(new Error(`BitBucket API request failed: ${error.message}`))
+ })
+
+ if (body) {
+ req.write(JSON.stringify(body))
+ }
+
+ req.end()
+ })
+ }
+
+ /**
+ * Make a GET request to BitBucket API
+ */
+ private async get(endpoint: string): Promise {
+ return this.request('GET', endpoint)
+ }
+
+ /**
+ * Make a POST request to BitBucket API
+ */
+ private async post(endpoint: string, body: unknown): Promise {
+ return this.request('POST', endpoint, body)
+ }
+
+ /**
+ * Get repository information
+ */
+ async getRepository(workspace: string, repoSlug: string): Promise {
+ return this.get(`/repositories/${workspace}/${repoSlug}`)
+ }
+
+ /**
+ * Get a pull request by ID
+ */
+ async getPullRequest(
+ workspace: string,
+ repoSlug: string,
+ prId: number
+ ): Promise {
+ return this.get(
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`
+ )
+ }
+
+ /**
+ * List open pull requests for a branch
+ *
+ * Note: BitBucket uses BBQL (BitBucket Query Language) for filtering.
+ * The q parameter must use the format: q=source.branch.name="branch-name"
+ * When using BBQL, we include state filter in the query to ensure it's applied.
+ * See: https://developer.atlassian.com/cloud/bitbucket/rest/intro/#filtering
+ */
+ async listPullRequests(
+ workspace: string,
+ repoSlug: string,
+ sourceBranch?: string
+ ): Promise {
+ let endpoint = `/repositories/${workspace}/${repoSlug}/pullrequests`
+
+ if (sourceBranch) {
+ // Use BBQL query syntax for filtering by source branch AND state
+ // Include state="OPEN" in the query to exclude DECLINED/MERGED/SUPERSEDED PRs
+ const query = `state="OPEN" AND source.branch.name="${sourceBranch}"`
+ endpoint += `?q=${encodeURIComponent(query)}`
+ } else {
+ // No branch filter, just filter by state
+ endpoint += `?state=OPEN`
+ }
+
+ const response = await this.get<{ values: BitBucketPullRequest[] }>(endpoint)
+ return response.values
+ }
+
+ /**
+ * Create a pull request
+ */
+ async createPullRequest(
+ workspace: string,
+ repoSlug: string,
+ title: string,
+ description: string,
+ sourceBranch: string,
+ destinationBranch: string,
+ reviewerAccountIds?: string[]
+ ): Promise {
+ const payload: Record = {
+ title,
+ description,
+ source: {
+ branch: {
+ name: sourceBranch,
+ },
+ },
+ destination: {
+ branch: {
+ name: destinationBranch,
+ },
+ },
+ }
+
+ // Add reviewers if provided
+ if (reviewerAccountIds && reviewerAccountIds.length > 0) {
+ payload.reviewers = reviewerAccountIds.map(id => ({ account_id: id }))
+ }
+
+ return this.post(
+ `/repositories/${workspace}/${repoSlug}/pullrequests`,
+ payload
+ )
+ }
+
+ /**
+ * Add a comment to a pull request
+ */
+ async addPRComment(
+ workspace: string,
+ repoSlug: string,
+ prId: number,
+ content: string
+ ): Promise {
+ await this.post(
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`,
+ {
+ content: {
+ raw: content,
+ },
+ }
+ )
+ }
+
+ /**
+ * Find workspace members by usernames
+ * Returns a map of username -> account_id for resolved users
+ * Handles pagination to fetch all workspace members
+ */
+ async findUsersByUsername(
+ workspace: string,
+ usernames: string[]
+ ): Promise