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> { + const result = new Map() + + // Fetch all workspace members with pagination + const allMembers = await this.getAllWorkspaceMembers(workspace) + + getLogger().debug(`Resolving ${usernames.length} usernames against ${allMembers.length} workspace members`, { allMembers}) + + // Match usernames against fetched members + for (const username of usernames) { + const usernameLower = username.toLowerCase() + const member = allMembers.find(m => + m.user.nickname?.toLowerCase() === usernameLower || + m.user.display_name.toLowerCase() === usernameLower + ) + + if (member) { + result.set(username, member.user.account_id) + getLogger().debug(`Resolved reviewer ${username} to account ID ${member.user.account_id}`) + } else { + getLogger().warn(`Could not resolve reviewer ${username} to a BitBucket account ID`) + } + } + + return result + } + + /** + * Fetch all workspace members with pagination + */ + private async getAllWorkspaceMembers(workspace: string): Promise { + const allMembers: BitBucketWorkspaceMember[] = [] + let nextUrl: string | null = `/workspaces/${workspace}/members` + + while (nextUrl) { + const response: BitBucketWorkspaceMembersResponse = + await this.get(nextUrl) + + allMembers.push(...response.values) + + // BitBucket pagination uses 'next' field with full URL + // Use it directly since request() now handles full URLs + nextUrl = response.next ?? null + } + + getLogger().debug(`Fetched ${allMembers.length} workspace members from BitBucket`) + return allMembers + } + + /** + * Get the currently authenticated user + */ + async getCurrentUser(): Promise { + return this.get('/user') + } + + /** + * Test connection to BitBucket API + */ + async testConnection(): Promise { + try { + await this.getCurrentUser() + return true + } catch (error) { + getLogger().error('BitBucket connection test failed', { error }) + return false + } + } + + /** + * Get configured workspace + */ + getWorkspace(): string | undefined { + return this.workspace + } + + /** + * Get configured repository slug + */ + getRepoSlug(): string | undefined { + return this.repoSlug + } +} diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts new file mode 100644 index 00000000..3ce770b9 --- /dev/null +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { BitBucketVCSProvider, type BitBucketVCSConfig } from './BitBucketVCSProvider.js' +import { BitBucketApiClient } from './BitBucketApiClient.js' + +// Mock the BitBucketApiClient +vi.mock('./BitBucketApiClient.js', () => ({ + BitBucketApiClient: vi.fn().mockImplementation(() => ({ + getWorkspace: vi.fn().mockReturnValue('test-workspace'), + getRepoSlug: vi.fn().mockReturnValue('test-repo'), + createPullRequest: vi.fn(), + findUsersByUsername: vi.fn(), + getCurrentUser: vi.fn(), + listPullRequests: vi.fn(), + getPullRequest: vi.fn(), + addPRComment: vi.fn(), + })), +})) + +// Mock the logger +vi.mock('../../../utils/logger-context.js', () => ({ + getLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +// Mock the remote parser +vi.mock('../../../utils/remote.js', () => ({ + parseGitRemotes: vi.fn().mockResolvedValue([]), +})) + +describe('BitBucketVCSProvider', () => { + let provider: BitBucketVCSProvider + let mockClient: { + getWorkspace: ReturnType + getRepoSlug: ReturnType + createPullRequest: ReturnType + findUsersByUsername: ReturnType + getCurrentUser: ReturnType + listPullRequests: ReturnType + getPullRequest: ReturnType + addPRComment: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + // Get the mock client instance + mockClient = { + getWorkspace: vi.fn().mockReturnValue('test-workspace'), + getRepoSlug: vi.fn().mockReturnValue('test-repo'), + createPullRequest: vi.fn(), + findUsersByUsername: vi.fn(), + getCurrentUser: vi.fn().mockResolvedValue({ + account_id: 'acc-current-user', + display_name: 'Current User', + nickname: 'currentuser', + }), + listPullRequests: vi.fn(), + getPullRequest: vi.fn(), + addPRComment: vi.fn(), + } + vi.mocked(BitBucketApiClient).mockImplementation(() => mockClient as unknown as BitBucketApiClient) + }) + + describe('createPR with reviewers', () => { + it('should resolve reviewer usernames and pass account IDs to createPullRequest', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice', 'bob'], + } + provider = new BitBucketVCSProvider(config) + + // Mock username resolution + mockClient.findUsersByUsername.mockResolvedValue( + new Map([ + ['alice', 'acc-alice'], + ['bob', 'acc-bob'], + ]) + ) + + // Mock PR creation + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + description: 'Test body', + state: 'OPEN', + author: { display_name: 'Test', uuid: 'uuid' }, + source: { branch: { name: 'feature' } }, + destination: { branch: { name: 'main' } }, + created_on: '2024-01-01', + updated_on: '2024-01-01', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + const url = await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // Verify findUsersByUsername was called with the configured usernames + expect(mockClient.findUsersByUsername).toHaveBeenCalledWith( + 'test-workspace', + ['alice', 'bob'] + ) + + // Verify createPullRequest was called with resolved account IDs + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + ['acc-alice', 'acc-bob'] + ) + + expect(url).toBe('https://bitbucket.org/test/pr/123') + }) + + it('should continue with partial reviewers when some usernames cannot be resolved', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice', 'unknown_user'], + } + provider = new BitBucketVCSProvider(config) + + // Only alice resolves + mockClient.findUsersByUsername.mockResolvedValue( + new Map([['alice', 'acc-alice']]) + ) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // Should only pass the resolved reviewer + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + ['acc-alice'] + ) + }) + + it('should not pass reviewers when no usernames can be resolved', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['unknown_user'], + } + provider = new BitBucketVCSProvider(config) + + // No usernames resolve + mockClient.findUsersByUsername.mockResolvedValue(new Map()) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // Should pass empty array for reviewers + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + [] + ) + }) + + it('should not resolve reviewers when none are configured', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + // No reviewers configured + } + provider = new BitBucketVCSProvider(config) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // findUsersByUsername should not be called + expect(mockClient.findUsersByUsername).not.toHaveBeenCalled() + + // createPullRequest should be called without reviewers + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + undefined + ) + }) + + it('should not resolve reviewers when array is empty', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: [], + } + provider = new BitBucketVCSProvider(config) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // findUsersByUsername should not be called + expect(mockClient.findUsersByUsername).not.toHaveBeenCalled() + + // createPullRequest should be called without reviewers + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + undefined + ) + }) + + it('should filter out the current user from reviewers list', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice', 'currentuser'], // currentuser is the PR author + } + provider = new BitBucketVCSProvider(config) + + // Current user has account_id 'acc-current-user' (set in beforeEach) + mockClient.findUsersByUsername.mockResolvedValue( + new Map([ + ['alice', 'acc-alice'], + ['currentuser', 'acc-current-user'], // Same as current user + ]) + ) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // getCurrentUser should be called to get the current user's account ID + expect(mockClient.getCurrentUser).toHaveBeenCalled() + + // createPullRequest should be called with only alice (current user filtered out) + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + ['acc-alice'] + ) + }) + + it('should pass all reviewers when current user is not in the list', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice', 'bob'], + } + provider = new BitBucketVCSProvider(config) + + mockClient.findUsersByUsername.mockResolvedValue( + new Map([ + ['alice', 'acc-alice'], + ['bob', 'acc-bob'], + ]) + ) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // All reviewers should be passed (none filtered) + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + ['acc-alice', 'acc-bob'] + ) + }) + + it('should pass empty array when current user is the only reviewer', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['currentuser'], + } + provider = new BitBucketVCSProvider(config) + + mockClient.findUsersByUsername.mockResolvedValue( + new Map([['currentuser', 'acc-current-user']]) + ) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // createPullRequest should be called with empty array (current user filtered out) + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + [] + ) + }) + }) + + describe('provider properties', () => { + it('should have correct provider name', () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + } + provider = new BitBucketVCSProvider(config) + expect(provider.providerName).toBe('bitbucket') + }) + + it('should not support draft PRs', () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + } + provider = new BitBucketVCSProvider(config) + expect(provider.supportsDraftPRs).toBe(false) + }) + + it('should support forks', () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + } + provider = new BitBucketVCSProvider(config) + expect(provider.supportsForks).toBe(true) + }) + }) +}) diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts new file mode 100644 index 00000000..c876f44a --- /dev/null +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts @@ -0,0 +1,276 @@ +// BitBucketVCSProvider - Implements VersionControlProvider for BitBucket +// Provides PR/VCS operations via BitBucket REST API + +import type { VersionControlProvider, ExistingPR } from '../../VersionControlProvider.js' +import type { PullRequest } from '../../../types/index.js' +import { BitBucketApiClient, type BitBucketConfig, type BitBucketPullRequest } from './BitBucketApiClient.js' +import { getLogger } from '../../../utils/logger-context.js' +import { parseGitRemotes } from '../../../utils/remote.js' + +/** + * BitBucket-specific configuration + * Extends BitBucketConfig with username, appPassword, workspace, and repoSlug + */ +export interface BitBucketVCSConfig extends BitBucketConfig { + reviewers?: string[] // Usernames of reviewers to add to PRs +} + +/** + * BitBucketVCSProvider implements VersionControlProvider for BitBucket + * + * Key differences from GitHub: + * - Uses workspace/repository slug instead of owner/repo + * - PR states are different (OPEN, MERGED, DECLINED, SUPERSEDED) + * - No native draft PR support + */ +export class BitBucketVCSProvider implements VersionControlProvider { + readonly providerName = 'bitbucket' + readonly supportsForks = true + readonly supportsDraftPRs = false // BitBucket doesn't have draft PRs + + private readonly client: BitBucketApiClient + private readonly reviewerUsernames?: string[] + + constructor(config: BitBucketVCSConfig) { + this.client = new BitBucketApiClient(config) + if (config.reviewers) { + this.reviewerUsernames = config.reviewers + } + } + + /** + * Check if a PR already exists for the given branch + */ + async checkForExistingPR(branchName: string, cwd?: string): Promise { + try { + // Get workspace and repo slug from config or detect from git remote + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + const prs = await this.client.listPullRequests(workspace, repoSlug, branchName) + + if (prs.length > 0 && prs[0]) { + const pr = prs[0] + return { + number: pr.id, + url: pr.links.html.href, + } + } + + return null + } catch (error) { + getLogger().debug('Error checking for existing PR', { error }) + return null + } + } + + /** + * Create a pull request + */ + async createPR( + branchName: string, + title: string, + body: string, + baseBranch: string, + cwd?: string + ): Promise { + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + // Log the target repository so users can verify it's correct + getLogger().info(`Creating BitBucket PR in ${workspace}/${repoSlug}`) + getLogger().debug('PR details', { branchName, title, baseBranch }) + + // Resolve reviewer usernames to account IDs if configured + let reviewerIds: string[] | undefined + if (this.reviewerUsernames && this.reviewerUsernames.length > 0) { + reviewerIds = await this.resolveReviewerUsernames(workspace, this.reviewerUsernames) + + // Filter out the current user from reviewers (BitBucket doesn't allow PR author as reviewer) + if (reviewerIds.length > 0) { + const currentUser = await this.client.getCurrentUser() + const originalCount = reviewerIds.length + reviewerIds = reviewerIds.filter(id => id !== currentUser.account_id) + + if (reviewerIds.length < originalCount) { + getLogger().debug( + `Removed current user (${currentUser.display_name}) from reviewers list - PR author cannot be a reviewer` + ) + } + } + } + + const pr = await this.client.createPullRequest( + workspace, + repoSlug, + title, + body, + branchName, + baseBranch, + reviewerIds + ) + + // Validate the response structure + if (!pr?.id || !pr?.links?.html?.href) { + getLogger().error('Invalid BitBucket API response', { pr }) + throw new Error( + `BitBucket API returned invalid PR response. ` + + `Expected PR with id and links.html.href, got: ${JSON.stringify(pr)}` + ) + } + + getLogger().info(`BitBucket PR #${pr.id} created successfully`) + return pr.links.html.href + } + + /** + * Fetch PR details + */ + async fetchPR(prNumber: number, cwd?: string): Promise { + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + const bbPR = await this.client.getPullRequest(workspace, repoSlug, prNumber) + return this.mapBitBucketPRToPullRequest(bbPR) + } + + /** + * Get PR URL + */ + async getPRUrl(prNumber: number, cwd?: string): Promise { + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + const bbPR = await this.client.getPullRequest(workspace, repoSlug, prNumber) + return bbPR.links.html.href + } + + /** + * Create a comment on a PR + */ + async createPRComment(prNumber: number, body: string, cwd?: string): Promise { + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + getLogger().debug('Creating BitBucket PR comment', { workspace, repoSlug, prNumber }) + + await this.client.addPRComment(workspace, repoSlug, prNumber, body) + } + + /** + * Detect repository from git remote + */ + async detectRepository(cwd?: string): Promise<{ owner: string; repo: string } | null> { + try { + const remotes = await parseGitRemotes(cwd) + + // Look for bitbucket.org remote + const bbRemote = remotes.find(r => + r.url.includes('bitbucket.org') + ) + + if (!bbRemote) { + return null + } + + // BitBucket URLs: https://bitbucket.org/workspace/repo.git + // or git@bitbucket.org:workspace/repo.git + return { + owner: bbRemote.owner, // workspace + repo: bbRemote.repo, + } + } catch (error) { + getLogger().error('Failed to detect BitBucket repository', { error }) + return null + } + } + + /** + * Get target remote for PR operations + */ + async getTargetRemote(_cwd?: string): Promise { + // For BitBucket, we typically use 'origin' + // Fork workflows are less common in BitBucket + return 'origin' + } + + /** + * Get workspace and repository slug from config or git remote + */ + private async getWorkspaceAndRepo(cwd?: string): Promise<{ workspace: string; repoSlug: string }> { + let workspace = this.client.getWorkspace() + let repoSlug = this.client.getRepoSlug() + + // If not configured, try to detect from git remote + if (!workspace || !repoSlug) { + const detected = await this.detectRepository(cwd) + if (!detected) { + throw new Error( + 'Could not determine BitBucket workspace/repository. ' + + 'Either configure them in settings or ensure git remote points to bitbucket.org' + ) + } + + workspace = workspace ?? detected.owner + repoSlug = repoSlug ?? detected.repo + } + + return { workspace, repoSlug } + } + + /** + * Resolve reviewer usernames to BitBucket account IDs + * Warns for any usernames that cannot be resolved but continues with partial list + */ + private async resolveReviewerUsernames(workspace: string, usernames: string[]): Promise { + getLogger().debug(`Resolving ${usernames.length} reviewer username(s) to BitBucket account IDs`) + + const usernameToAccountId = await this.client.findUsersByUsername(workspace, usernames) + + const resolvedIds: string[] = [] + const unresolvedUsernames: string[] = [] + + for (const username of usernames) { + const accountId = usernameToAccountId.get(username) + if (accountId) { + resolvedIds.push(accountId) + } else { + unresolvedUsernames.push(username) + } + } + + if (unresolvedUsernames.length > 0) { + getLogger().warn( + `Could not resolve ${unresolvedUsernames.length} reviewer username(s) to BitBucket account IDs: ${unresolvedUsernames.join(', ')}. ` + + `These reviewers will not be added to the PR.` + ) + } + + if (resolvedIds.length > 0) { + getLogger().info(`Resolved ${resolvedIds.length} reviewer(s) for PR`) + } + + return resolvedIds + } + + /** + * Map BitBucket PR to generic PullRequest type + */ + private mapBitBucketPRToPullRequest(bbPR: BitBucketPullRequest): PullRequest { + // Map BitBucket states to generic states + let state: 'open' | 'closed' | 'merged' + if (bbPR.state === 'OPEN') { + state = 'open' + } else if (bbPR.state === 'MERGED') { + state = 'merged' + } else { + state = 'closed' // DECLINED or SUPERSEDED + } + + return { + number: bbPR.id, + title: bbPR.title, + body: bbPR.description, + state, + branch: bbPR.source.branch.name, + baseBranch: bbPR.destination.branch.name, + url: bbPR.links.html.href, + isDraft: false, // BitBucket doesn't have draft PRs + } + } +} diff --git a/src/lib/providers/bitbucket/index.ts b/src/lib/providers/bitbucket/index.ts new file mode 100644 index 00000000..c3d4791b --- /dev/null +++ b/src/lib/providers/bitbucket/index.ts @@ -0,0 +1,3 @@ +// BitBucket provider exports +export { BitBucketApiClient, type BitBucketConfig, type BitBucketPullRequest, type BitBucketRepository } from './BitBucketApiClient.js' +export { BitBucketVCSProvider, type BitBucketVCSConfig } from './BitBucketVCSProvider.js' diff --git a/src/lib/providers/jira/AdfMarkdownConverter.test.ts b/src/lib/providers/jira/AdfMarkdownConverter.test.ts new file mode 100644 index 00000000..5643a3dc --- /dev/null +++ b/src/lib/providers/jira/AdfMarkdownConverter.test.ts @@ -0,0 +1,663 @@ +import { describe, test, expect } from 'vitest' +import { convertDetailsToExpandSyntax, markdownToAdf } from './AdfMarkdownConverter.js' + +// Type definition for ADF nodes used in tests +interface AdfNode { + type: string + content?: AdfNode[] + marks?: Array<{ type: string; attrs?: Record }> + text?: string + attrs?: Record +} + +// Helper function to find text nodes with code marks in ADF tree +function findTextNodesWithCodeMark(node: AdfNode): AdfNode[] { + const results: AdfNode[] = [] + + if (node.type === 'text' && node.marks?.some((mark) => mark.type === 'code')) { + results.push(node) + } + + if (node.content && Array.isArray(node.content)) { + for (const child of node.content) { + results.push(...findTextNodesWithCodeMark(child)) + } + } + + return results +} + +describe('AdfMarkdownConverter', () => { + describe('convertDetailsToExpandSyntax', () => { + test('converts basic details/summary block', () => { + const input = `
+Header +CONTENT +
` + + const expected = `~~~expand title="Header" +CONTENT +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles multiple details blocks', () => { + const input = `First block: +
+First Header +First content +
+ +Some text in between + +
+Second Header +Second content +
` + + const expected = `First block: +~~~expand title="First Header" +First content +~~~ + +Some text in between + +~~~expand title="Second Header" +Second content +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles extra whitespace before and after content', () => { + const input = `
+Header + + +Content with extra newlines + + +
` + + const expected = `~~~expand title="Header" +Content with extra newlines +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles empty content', () => { + const input = `
+Header +
` + + const expected = `~~~expand title="Header" +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles content with code blocks', () => { + const input = `
+Error Details + +\`\`\`typescript +const error = new Error('test') +console.log(error) +\`\`\` + +
` + + const expected = `~~~expand title="Error Details" +\`\`\`typescript +const error = new Error('test') +console.log(error) +\`\`\` +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles nested details blocks (2-level)', () => { + const input = `
+Outer Header + +This is some outer content + +
+Inner Header +This is some inner content +
+ +
` + + const expected = `~~~expand title="Outer Header" +This is some outer content + +~~~expand title="Inner Header" +This is some inner content +~~~ +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles nested details blocks (3-level)', () => { + const input = `
+Level 1 + +Content at level 1 + +
+Level 2 + +Content at level 2 + +
+Level 3 +Content at level 3 +
+ +
+ +
` + + const expected = `~~~expand title="Level 1" +Content at level 1 + +~~~expand title="Level 2" +Content at level 2 + +~~~expand title="Level 3" +Content at level 3 +~~~ +~~~ +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles mixed nested and non-nested blocks', () => { + const input = `
+First Block +Simple content +
+ +Some text in between + +
+Nested Block + +Outer content + +
+Inner Block +Inner content +
+ +
` + + const expected = `~~~expand title="First Block" +Simple content +~~~ + +Some text in between + +~~~expand title="Nested Block" +Outer content + +~~~expand title="Inner Block" +Inner content +~~~ +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles details tag with attributes', () => { + const input = `
+Expanded by Default +Content here +
` + + const expected = `~~~expand title="Expanded by Default" +Content here +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles summary tag with attributes', () => { + const input = `
+Header +Content here +
` + + const expected = `~~~expand title="Header" +Content here +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles HTML entities in summary', () => { + const input = `
+<Component> Details +Content here +
` + + const expected = `~~~expand title=" Details" +Content here +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles case-insensitive HTML tags', () => { + const input = `
+Header +Content +
` + + const expected = `~~~expand title="Header" +Content +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('returns original text if no details blocks', () => { + const input = `Just some regular text +with multiple lines +and no details blocks` + + expect(convertDetailsToExpandSyntax(input)).toBe(input) + }) + + test('returns empty string for empty input', () => { + expect(convertDetailsToExpandSyntax('')).toBe('') + }) + + test('returns null for null input', () => { + expect(convertDetailsToExpandSyntax(null as unknown as string)).toBe(null) + }) + + test('returns undefined for undefined input', () => { + expect(convertDetailsToExpandSyntax(undefined as unknown as string)).toBe(undefined) + }) + + test('handles malformed HTML gracefully - missing closing tag', () => { + const input = '
HeaderContent' // Missing closing tag + + // Should not throw, just return original text + expect(() => convertDetailsToExpandSyntax(input)).not.toThrow() + expect(convertDetailsToExpandSyntax(input)).toBe(input) + }) + + test('handles malformed HTML gracefully - missing summary tag', () => { + const input = '
Content without summary
' + + // Should not throw, just return original text + expect(() => convertDetailsToExpandSyntax(input)).not.toThrow() + expect(convertDetailsToExpandSyntax(input)).toBe(input) + }) + + test('handles unicode characters', () => { + const input = `
+Unicode Test 🚀 +Content with émojis 🎉 and àccénts +
` + + const expected = `~~~expand title="Unicode Test 🚀" +Content with émojis 🎉 and àccénts +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('real-world workflow example', () => { + const input = `## Implementation Progress + +
+📋 Complete Context & Details (click to expand) + +### Phase 1: Setup +- [x] Create files +- [x] Write tests + +### Phase 2: Testing +- [ ] Run tests + +
+ +Last updated: 2025-01-16` + + const expected = `## Implementation Progress + +~~~expand title="📋 Complete Context & Details (click to expand)" +### Phase 1: Setup +- [x] Create files +- [x] Write tests + +### Phase 2: Testing +- [ ] Run tests +~~~ + +Last updated: 2025-01-16` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('normalizes excessive blank lines in content', () => { + const input = `
+Header + +Content line 1 + + + +Content line 2 + + + + +Content line 3 + +
` + + const expected = `~~~expand title="Header" +Content line 1 + +Content line 2 + +Content line 3 +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles special characters in content', () => { + const input = `
+Special Chars +!@#$%^&*()_+-=[]{}|;':",./<>? +
` + + const expected = `~~~expand title="Special Chars" +!@#$%^&*()_+-=[]{}|;':",./<>? +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles content with HTML tags that should be preserved', () => { + const input = `
+HTML Content +Some text with bold and italic +
` + + const expected = `~~~expand title="HTML Content" +Some text with bold and italic +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles extremely long content', () => { + const longContent = 'Line\n'.repeat(1000) + const input = `
+Long Content +${longContent} +
` + + const result = convertDetailsToExpandSyntax(input) + expect(result).toContain('~~~expand title="Long Content"') + expect(result).toContain('~~~') + expect(result.length).toBeGreaterThan(longContent.length) + }) + + test('handles all HTML entity types', () => { + const input = `
+<div> & "quotes" 'apostrophe' +Content +
` + + const expected = `~~~expand title="
& "quotes" 'apostrophe'" +Content +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + }) + + describe('markdownToAdf', () => { + test('returns empty doc for empty input', () => { + expect(markdownToAdf('')).toEqual({ type: 'doc', version: 1, content: [] }) + }) + + test('returns empty doc for null input', () => { + expect(markdownToAdf(null as unknown as string)).toEqual({ type: 'doc', version: 1, content: [] }) + }) + + test('returns empty doc for undefined input', () => { + expect(markdownToAdf(undefined as unknown as string)).toEqual({ type: 'doc', version: 1, content: [] }) + }) + + test('converts plain text to ADF', () => { + const result = markdownToAdf('Hello world') + expect(result).toHaveProperty('type', 'doc') + expect(result).toHaveProperty('version', 1) + expect(result).toHaveProperty('content') + }) + + test('converts details/summary to ADF expand node', () => { + const input = `
+Click to expand +Hidden content +
` + + const result = markdownToAdf(input) + expect(result).toHaveProperty('type', 'doc') + expect(result).toHaveProperty('content') + + // The ADF should contain an expand node (since the preprocessing converts to expand syntax) + const content = (result as { content: unknown[] }).content + expect(content.length).toBeGreaterThan(0) + + // Find the expand node in the content + const hasExpandNode = content.some((node: unknown) => { + return (node as { type: string }).type === 'expand' + }) + expect(hasExpandNode).toBe(true) + }) + + test('converts nested details/summary correctly', () => { + const input = `
+Outer +Outer content +
+Inner +Inner content +
+
` + + const result = markdownToAdf(input) + expect(result).toHaveProperty('type', 'doc') + + // Should have expand nodes + const content = (result as { content: unknown[] }).content + const expandNodes = content.filter((node: unknown) => (node as { type: string }).type === 'expand') + expect(expandNodes.length).toBeGreaterThanOrEqual(1) + }) + + test('preserves regular markdown content alongside details blocks', () => { + const input = `# Heading + +Some regular text + +
+Expandable +Hidden content +
+ +More text` + + const result = markdownToAdf(input) + expect(result).toHaveProperty('type', 'doc') + + const content = (result as { content: unknown[] }).content + // Should have multiple content nodes (heading, paragraphs, expand) + expect(content.length).toBeGreaterThan(1) + }) + + test('handles markdown with emoji in summary', () => { + const input = `
+📋 Complete Context +Content here +
` + + const result = markdownToAdf(input) + expect(result).toHaveProperty('type', 'doc') + + // Should have an expand node with the title including emoji + const content = (result as { content: unknown[] }).content + const expandNode = content.find((node: unknown) => (node as { type: string }).type === 'expand') + expect(expandNode).toBeDefined() + expect((expandNode as { attrs: { title: string } }).attrs.title).toBe('📋 Complete Context') + }) + + // Tests for code mark sanitization - ADF spec requires code marks to be standalone + describe('code mark sanitization', () => { + test('code mark only remains unchanged', () => { + const input = 'Some `code` here' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code') + }) + + test('code with bold mark - removes bold, keeps only code', () => { + const input = '**bold `code`**' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code') + }) + + test('code with italic mark - removes italic, keeps only code', () => { + const input = '*italic `code`*' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code') + }) + + test('code with multiple marks (bold + italic) - removes all, keeps only code', () => { + const input = '***bold italic `code`***' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code') + }) + + test('code inside link - removes link mark, keeps only code', () => { + const input = '[`code link`](https://example.com)' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code link') + }) + + test('nested content with code marks is recursively sanitized', () => { + // Blockquote with bold code inside + const input = `> **bold \`code\`** in blockquote` + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + }) + + test('no code marks - text remains unchanged', () => { + const input = '**bold** and *italic* text' + const result = markdownToAdf(input) as AdfNode + + // Should have bold and italic marks, but no code + const codeNodes = findTextNodesWithCodeMark(result) + expect(codeNodes.length).toBe(0) + + // The bold and italic text should still have their marks + const content = result.content || [] + expect(content.length).toBeGreaterThan(0) + }) + + test('multiple text nodes - only code-marked nodes are affected', () => { + const input = '**bold** and `code` and *italic*' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + // Only one code node + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + + // Other marks should be preserved + const content = result.content || [] + const paragraph = content[0] + const textNodes = (paragraph?.content || []) as AdfNode[] + + // Find the bold text node + const boldNode = textNodes.find( + (node) => node.type === 'text' && node.marks?.some((m) => m.type === 'strong') + ) + expect(boldNode).toBeDefined() + + // Find the italic text node + const italicNode = textNodes.find( + (node) => node.type === 'text' && node.marks?.some((m) => m.type === 'em') + ) + expect(italicNode).toBeDefined() + }) + + test('handles text with no marks array', () => { + const input = 'Plain text with no formatting' + const result = markdownToAdf(input) as AdfNode + + // Should not throw and should have content + expect(result.type).toBe('doc') + expect(result.content?.length).toBeGreaterThan(0) + }) + + test('handles deeply nested structure with code marks', () => { + // List with nested content containing code + const input = `- Item with **\`code\`** inside` + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + }) + + test('mixed content - code inside various formatting preserved correctly', () => { + const input = `Text with **bold \`code1\`** and *italic \`code2\`* and plain \`code3\`` + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + // All three code nodes should only have code marks + expect(codeNodes.length).toBe(3) + for (const node of codeNodes) { + expect(node.marks).toEqual([{ type: 'code' }]) + } + }) + }) + }) +}) diff --git a/src/lib/providers/jira/AdfMarkdownConverter.ts b/src/lib/providers/jira/AdfMarkdownConverter.ts new file mode 100644 index 00000000..36f8ef46 --- /dev/null +++ b/src/lib/providers/jira/AdfMarkdownConverter.ts @@ -0,0 +1,118 @@ +// AdfMarkdownConverter - Converts between Atlassian Document Format (ADF) and Markdown +// Uses extended-markdown-adf-parser for bidirectional conversion + +import { ADFDocument, Parser } from 'extended-markdown-adf-parser' + +const parser = new Parser() + +/** + * Represents a node in the ADF tree structure + */ +interface AdfNode { + type: string + content?: AdfNode[] + marks?: Array<{ type: string; attrs?: Record }> + text?: string + attrs?: Record +} + +/** + * Recursively traverse ADF tree and ensure code-marked text only has the code mark. + * ADF specification requires that code marks are standalone - no other marks allowed. + */ +function sanitizeCodeMarks(node: AdfNode): AdfNode { + // If node has marks and one of them is 'code', keep only the code mark + if (node.marks?.some((mark) => mark.type === 'code')) { + node.marks = [{ type: 'code' }] + } + + // Recursively process child nodes + if (node.content && Array.isArray(node.content)) { + node.content = node.content.map((child) => sanitizeCodeMarks(child)) + } + + return node +} + +/** + * Convert HTML details/summary blocks to ADF expand fence syntax + * The extended-markdown-adf-parser library supports ~~~expand title="..."~~~ syntax + * but not HTML
tags + * + * @param markdown - Markdown string potentially containing HTML details/summary blocks + * @returns Markdown with details/summary converted to ADF expand fence syntax + */ +export function convertDetailsToExpandSyntax(markdown: string): string { + if (!markdown) return markdown + + // Process from innermost to outermost to handle nesting correctly + let previousText = '' + let currentText = markdown + + while (previousText !== currentText) { + previousText = currentText + // Match
blocks with optional attributes on the tags + currentText = currentText.replace( + /]*>\s*]*>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gi, + (_match, summary, content) => { + // Clean up the summary - trim whitespace and decode HTML entities + const cleanSummary = summary + .trim() + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + + // Clean up the content - trim and normalize excessive blank lines + let cleanContent = content.trim() + cleanContent = cleanContent.replace(/\n{3,}/g, '\n\n') + + // Build ADF expand fence syntax + if (cleanContent) { + return `~~~expand title="${cleanSummary}"\n${cleanContent}\n~~~` + } else { + return `~~~expand title="${cleanSummary}"\n~~~` + } + } + ) + } + + return currentText +} + +/** + * Convert ADF (Atlassian Document Format) to Markdown + * Used when reading issue descriptions and comments from Jira + * + * @param adf - ADF object, string, null, or undefined + * @returns Markdown string + */ +export function adfToMarkdown(adf: unknown): string { + // Handle null/undefined + if (!adf) return '' + + // Handle plain string (already text, not ADF) + if (typeof adf === 'string') return adf + + // Convert ADF object to markdown + return parser.adfToMarkdown(adf as ADFDocument) +} + +/** + * Convert Markdown to ADF (Atlassian Document Format) + * Used when writing issue descriptions and comments to Jira + * + * @param markdown - Markdown string + * @returns ADF object suitable for Jira API v3 + */ +export function markdownToAdf(markdown: string): object { + if (!markdown) { + return { type: 'doc', version: 1, content: [] } + } + // Convert HTML details/summary to ADF expand syntax before parsing + const preprocessed = convertDetailsToExpandSyntax(markdown) + const adf = parser.markdownToAdf(preprocessed) + // Sanitize code marks - ensure code-marked text only has code mark + return sanitizeCodeMarks(adf as AdfNode) +} diff --git a/src/lib/providers/jira/JiraApiClient.ts b/src/lib/providers/jira/JiraApiClient.ts new file mode 100644 index 00000000..063d4141 --- /dev/null +++ b/src/lib/providers/jira/JiraApiClient.ts @@ -0,0 +1,271 @@ +// JiraApiClient - REST API wrapper for Jira operations +// Handles authentication and common API request patterns + +import https from 'node:https' +import { getLogger } from '../../../utils/logger-context.js' +import { markdownToAdf } from './AdfMarkdownConverter.js' + +/** + * Jira API configuration + */ +export interface JiraConfig { + host: string // e.g., "https://yourcompany.atlassian.net" + username: string // email address or username + apiToken: string // API token from Atlassian account +} + +/** + * Jira issue response from API + */ +export interface JiraIssue { + id: string + key: string + fields: { + summary: string + description: string | null | unknown // Can be string, ADF object, or null + status: { + name: string + } + issuetype: { + name: string + } + project: { + key: string + name: string + } + assignee: { + displayName: string + emailAddress: string + accountId: string + } | null + reporter: { + displayName: string + emailAddress: string + accountId: string + } + labels: string[] + created: string + updated: string + [key: string]: unknown // Allow additional fields + } + [key: string]: unknown // Allow additional top-level fields +} + +/** + * Jira comment response from API + */ +export interface JiraComment { + id: string + author: { + displayName: string + emailAddress: string + accountId: string + } + body: string | unknown // Can be string or ADF object + created: string + updated: string + [key: string]: unknown +} + +/** + * Jira transition response from API + */ +export interface JiraTransition { + id: string + name: string + to: { + id: string + name: string + } +} + +/** + * JiraApiClient provides low-level REST API access to Jira + * + * Authentication: Basic Auth with username and API token + * API Reference: https://developer.atlassian.com/cloud/jira/platform/rest/v3/ + */ +export class JiraApiClient { + private readonly baseUrl: string + private readonly authHeader: string + + constructor(config: JiraConfig) { + this.baseUrl = `${config.host.replace(/\/$/, '')}/rest/api/3` + + // Create Basic Auth header + const credentials = Buffer.from(`${config.username}:${config.apiToken}`).toString('base64') + this.authHeader = `Basic ${credentials}` + } + + /** + * Make an HTTP request to Jira API + */ + private async request( + method: 'GET' | 'POST' | 'PUT', + endpoint: string, + body?: unknown + ): Promise { + const url = new URL(`${this.baseUrl}${endpoint}`) + getLogger().debug(`Jira 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(`Jira API error (${res.statusCode}): ${data}`)) + return + } + + // Handle empty response (e.g., 204 No Content) + if (res.statusCode === 204 || !data) { + resolve({} as T) + return + } + + try { + resolve(JSON.parse(data) as T) + } catch (error) { + reject(new Error(`Failed to parse Jira API response: ${error}`)) + } + }) + }) + + req.on('error', (error) => { + reject(new Error(`Jira API request failed: ${error.message}`)) + }) + + if (body) { + req.write(JSON.stringify(body)) + } + + req.end() + }) + } + + /** + * Make a GET request to Jira API + */ + private async get(endpoint: string): Promise { + return this.request('GET', endpoint) + } + + /** + * Make a POST request to Jira API + */ + private async post(endpoint: string, body: unknown): Promise { + return this.request('POST', endpoint, body) + } + + /** + * Make a PUT request to Jira API + */ + private async put(endpoint: string, body: unknown): Promise { + return this.request('PUT', endpoint, body) + } + + /** + * Fetch an issue by key (e.g., "PROJ-123") + */ + async getIssue(issueKey: string): Promise { + return this.get(`/issue/${issueKey}`) + } + + /** + * Add a comment to an issue + * Accepts Markdown content which is converted to ADF for Jira + */ + async addComment(issueKey: string, body: string): Promise { + const adfBody = markdownToAdf(body); + getLogger().debug('Adding comment to Jira issue', { issueKey, body, adfBody }) + return this.post(`/issue/${issueKey}/comment`, { + body: adfBody + }) + } + + /** + * Get all comments for an issue + */ + async getComments(issueKey: string): Promise { + const response = await this.get<{ comments: JiraComment[] }>(`/issue/${issueKey}/comment`) + return response.comments + } + + /** + * Update a comment on an issue + * Accepts Markdown content which is converted to ADF for Jira + */ + async updateComment(issueKey: string, commentId: string, body: string): Promise { + return this.put(`/issue/${issueKey}/comment/${commentId}`, { + body: markdownToAdf(body), + }) + } + + /** + * Get available transitions for an issue + */ + async getTransitions(issueKey: string): Promise { + const response = await this.get<{ transitions: JiraTransition[] }>(`/issue/${issueKey}/transitions`) + return response.transitions + } + + /** + * Transition an issue to a new state + */ + async transitionIssue(issueKey: string, transitionId: string): Promise { + await this.post(`/issue/${issueKey}/transitions`, { + transition: { + id: transitionId, + }, + }) + } + + /** + * Create a new issue + * Accepts Markdown description which is converted to ADF for Jira + */ + async createIssue(projectKey: string, summary: string, description: string, issueType = 'Task'): Promise { + return this.post('/issue', { + fields: { + project: { + key: projectKey, + }, + summary, + description: markdownToAdf(description), + issuetype: { + name: issueType, + }, + }, + }) + } + + /** + * Test connection to Jira API + */ + async testConnection(): Promise { + try { + await this.get('/myself') + return true + } catch (error) { + getLogger().error('Jira connection test failed', { error }) + return false + } + } +} diff --git a/src/lib/providers/jira/JiraIssueTracker.ts b/src/lib/providers/jira/JiraIssueTracker.ts new file mode 100644 index 00000000..b0bb441d --- /dev/null +++ b/src/lib/providers/jira/JiraIssueTracker.ts @@ -0,0 +1,347 @@ +// JiraIssueTracker - Implements IssueTracker interface for Jira +// Provides issue management operations via Jira REST API + +import type { IssueTracker } from '../../IssueTracker.js' +import type { Issue, IssueTrackerInputDetection } from '../../../types/index.js' +import { JiraApiClient, type JiraConfig, type JiraIssue, type JiraTransition } from './JiraApiClient.js' +import { getLogger } from '../../../utils/logger-context.js' +import { adfToMarkdown } from './AdfMarkdownConverter.js' + +/** + * Jira-specific configuration + */ +export interface JiraTrackerConfig extends JiraConfig { + projectKey: string + transitionMappings?: Record // Map iloom states to Jira transition names +} + +/** + * JiraIssueTracker implements IssueTracker for Jira + * + * Key differences from GitHub/Linear: + * - Issue identifiers are strings (e.g., "PROJ-123") + * - No issue prefix (unlike GitHub's "#") + * - State changes require workflow transitions (not direct status updates) + * - Content uses Atlassian Document Format (ADF), converted to/from Markdown + */ +export class JiraIssueTracker implements IssueTracker { + readonly providerName = 'jira' + readonly supportsPullRequests = false + + private readonly client: JiraApiClient + private readonly config: JiraTrackerConfig + + constructor(config: JiraTrackerConfig) { + this.config = config + this.client = new JiraApiClient({ + host: config.host, + username: config.username, + apiToken: config.apiToken, + }) + } + + /** + * Normalize identifier to canonical uppercase form + * Jira issue keys are case-sensitive in the API (must be uppercase) + */ + normalizeIdentifier(identifier: string | number): string { + return String(identifier).toUpperCase() + } + + /** + * Detect input type from user input + * Jira issues follow pattern: PROJECTKEY-123 (case-insensitive) + */ + async detectInputType(input: string): Promise { + // Pattern: PROJECTKEY-123 (case-insensitive to accept lowercase from branch names or user input) + const jiraPattern = /^([A-Z][A-Z0-9]+)-(\d+)$/i + const match = input.match(jiraPattern) + + if (!match) { + return { type: 'unknown', identifier: null, rawInput: input } + } + + const issueKey = this.normalizeIdentifier(input) + getLogger().debug('Checking if input is a Jira issue', { issueKey }) + + // Verify the issue exists + try { + await this.client.getIssue(issueKey) + return { type: 'issue', identifier: issueKey, rawInput: input } + } catch (error) { + getLogger().debug('Issue not found', { issueKey, error }) + return { type: 'unknown', identifier: null, rawInput: input } + } + } + + /** + * Fetch issue details + */ + async fetchIssue(identifier: string | number): Promise { + const issueKey = this.normalizeIdentifier(identifier) + getLogger().debug('Fetching Jira issue', { issueKey }) + + const jiraIssue = await this.client.getIssue(issueKey) + return this.mapJiraIssueToIssue(jiraIssue) + } + + /** + * Check if issue exists (silent validation) + */ + async isValidIssue(identifier: string | number): Promise { + try { + return await this.fetchIssue(identifier) + } catch (error) { + getLogger().debug('Issue validation failed', { identifier, error }) + return false + } + } + + /** + * Validate issue state + * Note: Jira doesn't have a simple "closed" state - depends on workflow + */ + async validateIssueState(issue: Issue): Promise { + // Jira state validation is workflow-specific + // For now, we'll just log the state + getLogger().debug('Jira issue state', { issueKey: issue.number, state: issue.state }) + + // Could add custom validation logic here based on config + // For example, warn if issue is in "Done" state + if (issue.state.toLowerCase() === 'done') { + getLogger().warn('Issue is already in Done state', { issueKey: issue.number }) + } + } + + /** + * Create a new issue + */ + async createIssue( + title: string, + body: string, + _repository?: string, + _labels?: string[] + ): Promise<{ number: string | number; url: string }> { + getLogger().debug('Creating Jira issue', { title, projectKey: this.config.projectKey }) + + // Convert markdown body to plain text for Jira description + // Note: Jira API expects Atlassian Document Format (ADF) + // We use a simplified plain text approach here + const jiraIssue = await this.client.createIssue( + this.config.projectKey, + title, + body + ) + + return { + number: jiraIssue.key, + url: `${this.config.host}/browse/${jiraIssue.key}`, + } + } + + /** + * Get issue URL + */ + async getIssueUrl(identifier: string | number): Promise { + const issueKey = this.normalizeIdentifier(identifier) + return `${this.config.host}/browse/${issueKey}` + } + + /** + * Move issue to "In Progress" state + * Uses configured transition mapping or default transition name + */ + async moveIssueToInProgress(identifier: string | number): Promise { + const issueKey = this.normalizeIdentifier(identifier) + getLogger().debug('Moving Jira issue to In Progress', { issueKey }) + + // Get available transitions + const transitions = await this.client.getTransitions(issueKey) + + // Look for the transition in config mapping or use default names + const transitionName = this.config.transitionMappings?.['In Progress'] + ?? this.findTransitionByName(transitions, ['In Progress', 'Start Progress', 'Start']) + + if (!transitionName) { + throw new Error( + `Could not find "In Progress" transition for ${issueKey}. ` + + `Available transitions: ${transitions.map(t => t.name).join(', ')}. ` + + `Configure custom mapping in settings.json: issueManagement.jira.transitionMappings` + ) + } + + // Find transition ID + const transition = transitions.find(t => t.name === transitionName) + if (!transition) { + throw new Error(`Transition "${transitionName}" not found`) + } + + await this.client.transitionIssue(issueKey, transition.id) + getLogger().info('Issue transitioned successfully', { issueKey, transition: transitionName }) + } + + /** + * Move issue to "Ready for Review" state + * Uses configured transition mapping or default transition name + */ + async moveIssueToReadyForReview(identifier: string | number): Promise { + const issueKey = this.normalizeIdentifier(identifier) + getLogger().debug('Moving Jira issue to Ready for Review', { issueKey }) + + // Get available transitions + const transitions = await this.client.getTransitions(issueKey) + + // Look for the transition in config mapping or use default names + const transitionName = this.config.transitionMappings?.['Ready for Review'] + ?? this.findTransitionByName(transitions, ['Ready for Review', 'In Review', 'Code Review', 'Review']) + + if (!transitionName) { + throw new Error( + `Could not find "Ready for Review" transition for ${issueKey}. ` + + `Available transitions: ${transitions.map(t => t.name).join(', ')}. ` + + `Configure custom mapping in settings.json: issueManagement.jira.transitionMappings` + ) + } + + // Find transition ID + const transition = transitions.find(t => t.name === transitionName) + if (!transition) { + throw new Error(`Transition "${transitionName}" not found`) + } + + await this.client.transitionIssue(issueKey, transition.id) + getLogger().info('Issue transitioned to Ready for Review', { issueKey, transition: transitionName }) + } + + /** + * Extract context from issue for AI prompts + */ + extractContext(entity: Issue): string { + return `Issue: ${entity.number} +Title: ${entity.title} +Status: ${entity.state} +URL: ${entity.url} + +Description: +${entity.body} + +${entity.labels.length > 0 ? `Labels: ${entity.labels.join(', ')}` : ''} +${entity.assignees.length > 0 ? `Assignees: ${entity.assignees.join(', ')}` : ''}` + } + + /** + * Get issue details (alias for fetchIssue for MCP compatibility) + */ + async getIssue(identifier: string | number): Promise { + return this.fetchIssue(identifier) + } + + /** + * Get all comments for an issue + */ + async getComments(identifier: string | number): Promise> { + const issueKey = this.normalizeIdentifier(identifier) + getLogger().debug('Fetching Jira comments', { issueKey }) + + const comments = await this.client.getComments(issueKey) + + // Map to expected format + return comments.map(comment => ({ + id: comment.id, + body: adfToMarkdown(comment.body), + author: comment.author, + createdAt: comment.created, + updatedAt: comment.updated, + })) + } + + /** + * Add a comment to an issue + */ + async addComment(identifier: string | number, body: string): Promise<{ id: string }> { + const issueKey = this.normalizeIdentifier(identifier) + getLogger().debug('Adding Jira comment', { issueKey }) + + const comment = await this.client.addComment(issueKey, body) + return { id: comment.id } + } + + /** + * Update an existing comment + */ + async updateComment(identifier: string | number, commentId: string, body: string): Promise { + const issueKey = this.normalizeIdentifier(identifier) + getLogger().debug('Updating Jira comment', { issueKey, commentId }) + + await this.client.updateComment(issueKey, commentId, body) + } + + /** + * Get configuration (for MCP provider) + */ + getConfig(): JiraTrackerConfig { + return this.config + } + + /** + * Map Jira API issue to generic Issue type + */ + private mapJiraIssueToIssue(jiraIssue: JiraIssue): Issue & { + id?: string + key?: string + author?: { + displayName: string + emailAddress: string + accountId: string + } + assignee?: { + displayName: string + emailAddress: string + accountId: string + } | null + issueType?: string + status?: string + } { + // Extract description - handle ADF format or plain string + const description = adfToMarkdown(jiraIssue.fields.description) + + return { + id: jiraIssue.id, + key: jiraIssue.key, + number: jiraIssue.key, + title: jiraIssue.fields.summary, + body: description, + state: jiraIssue.fields.status.name.toLowerCase() as 'open' | 'closed', + labels: jiraIssue.fields.labels, + assignees: jiraIssue.fields.assignee + ? [jiraIssue.fields.assignee.displayName] + : [], + assignee: jiraIssue.fields.assignee, + author: jiraIssue.fields.reporter, + url: `${this.config.host}/browse/${jiraIssue.key}`, + issueType: jiraIssue.fields.issuetype.name, + status: jiraIssue.fields.status.name, + } + } + + /** + * Find a transition by name, trying multiple possible names + */ + private findTransitionByName(transitions: JiraTransition[], names: string[]): string | null { + for (const name of names) { + const transition = transitions.find(t => + t.name.toLowerCase() === name.toLowerCase() + ) + if (transition) { + return transition.name + } + } + return null + } +} diff --git a/src/lib/providers/jira/index.ts b/src/lib/providers/jira/index.ts new file mode 100644 index 00000000..d6f1ae13 --- /dev/null +++ b/src/lib/providers/jira/index.ts @@ -0,0 +1,4 @@ +// Jira provider exports +export { JiraApiClient, type JiraConfig, type JiraIssue, type JiraComment, type JiraTransition } from './JiraApiClient.js' +export { JiraIssueTracker, type JiraTrackerConfig } from './JiraIssueTracker.js' +export { adfToMarkdown, markdownToAdf } from './AdfMarkdownConverter.js' diff --git a/src/mcp/IssueManagementProviderFactory.ts b/src/mcp/IssueManagementProviderFactory.ts index 384d171b..794d2a6e 100644 --- a/src/mcp/IssueManagementProviderFactory.ts +++ b/src/mcp/IssueManagementProviderFactory.ts @@ -5,6 +5,8 @@ import type { IssueManagementProvider, IssueProvider } from './types.js' import { GitHubIssueManagementProvider } from './GitHubIssueManagementProvider.js' import { LinearIssueManagementProvider } from './LinearIssueManagementProvider.js' +import { JiraIssueManagementProvider } from './JiraIssueManagementProvider.js' +import type { IloomSettings } from '../lib/SettingsManager.js' /** * Factory class for creating issue management providers @@ -12,13 +14,20 @@ import { LinearIssueManagementProvider } from './LinearIssueManagementProvider.j export class IssueManagementProviderFactory { /** * Create an issue management provider based on the provider type + * @param provider - The provider type (github, linear, jira) + * @param settings - Required for Jira provider, optional for others */ - static create(provider: IssueProvider): IssueManagementProvider { + static create(provider: IssueProvider, settings?: IloomSettings): IssueManagementProvider { switch (provider) { case 'github': return new GitHubIssueManagementProvider() case 'linear': return new LinearIssueManagementProvider() + case 'jira': + if (!settings) { + throw new Error('Settings required for Jira provider') + } + return new JiraIssueManagementProvider(settings) default: throw new Error(`Unsupported issue management provider: ${provider}`) } diff --git a/src/mcp/JiraIssueManagementProvider.ts b/src/mcp/JiraIssueManagementProvider.ts new file mode 100644 index 00000000..d1605af9 --- /dev/null +++ b/src/mcp/JiraIssueManagementProvider.ts @@ -0,0 +1,257 @@ +/** + * Jira implementation of Issue Management Provider + * Uses JiraIssueTracker for all operations + * Normalizes Jira-specific fields to provider-agnostic core fields + */ + +import type { + IssueManagementProvider, + GetIssueInput, + GetCommentInput, + CreateCommentInput, + UpdateCommentInput, + CreateIssueInput, + CreateIssueResult, + IssueResult, + CommentDetailResult, + CommentResult, + FlexibleAuthor, +} from './types.js' +import { JiraIssueTracker } from '../lib/providers/jira/JiraIssueTracker.js' +import type { JiraTrackerConfig } from '../lib/providers/jira/JiraIssueTracker.js' +import type { Issue } from '../types/index.js' +import { SettingsManager } from '../lib/SettingsManager.js' +import type { IloomSettings } from '../lib/SettingsManager.js' + +/** + * Normalize Jira author to FlexibleAuthor format + */ +function normalizeAuthor(author: { displayName?: string; emailAddress?: string; accountId?: string } | null | undefined): FlexibleAuthor | null { + if (!author) return null + + return { + id: author.accountId ?? author.emailAddress ?? 'unknown', + displayName: author.displayName ?? author.emailAddress ?? 'Unknown', + ...(author.emailAddress && { email: author.emailAddress }), + ...(author.accountId && { accountId: author.accountId }), + } +} +/** + * Extract Jira configuration from settings (for cli usage) or environment variables (in mcp server) + */ +const getJiraTrackerConfig = (settings: IloomSettings): JiraTrackerConfig => { + const jiraSettings = settings.issueManagement?.jira + + if (jiraSettings?.host && jiraSettings?.username && jiraSettings?.apiToken && jiraSettings?.projectKey) { + const config: JiraTrackerConfig = { + host: jiraSettings.host, + username: jiraSettings.username, + apiToken: jiraSettings.apiToken, + projectKey: jiraSettings.projectKey, + } + + if (jiraSettings.transitionMappings) { + config.transitionMappings = jiraSettings.transitionMappings + } + + return config; + } + + if (process.env.JIRA_HOST && process.env.JIRA_USERNAME && process.env.JIRA_API_TOKEN && process.env.JIRA_PROJECT_KEY) { + const config: JiraTrackerConfig = { + host: process.env.JIRA_HOST, + username: process.env.JIRA_USERNAME, + apiToken: process.env.JIRA_API_TOKEN, + projectKey: process.env.JIRA_PROJECT_KEY, + } + + if (process.env.JIRA_TRANSITION_MAPPINGS) { + try { + config.transitionMappings = JSON.parse(process.env.JIRA_TRANSITION_MAPPINGS) + } catch { + throw new Error('Invalid JSON in JIRA_TRANSITION_MAPPINGS environment variable') + } + } + + return config + } + + throw new Error( + 'Missing required Jira settings: issueManagement.jira.{host, username, apiToken, projectKey} or corresponding environment variables' + ) +} + +/** + * Jira-specific implementation of IssueManagementProvider + */ +export class JiraIssueManagementProvider implements IssueManagementProvider { + readonly providerName = 'jira' + readonly issuePrefix = '' + private tracker: JiraIssueTracker + + constructor(settings: IloomSettings) { + const config = getJiraTrackerConfig(settings); + + this.tracker = new JiraIssueTracker(config) + } + + /** + * Static factory for convenience when settings aren't pre-loaded + */ + static async create(): Promise { + const settingsManager = new SettingsManager() + const settings = await settingsManager.loadSettings() + return new JiraIssueManagementProvider(settings) + } + + /** + * Fetch issue details using JiraIssueTracker + */ + async getIssue(input: GetIssueInput): Promise { + const { number, includeComments = true } = input + + // Fetch issue from Jira + const issue = await this.tracker.getIssue(number) + const issueExt = issue as Issue & { + id?: string + key?: string + author?: { + displayName?: string + emailAddress?: string + accountId?: string + } + issueType?: string + priority?: string + status?: string + } + + // Normalize to IssueResult format + const result: IssueResult = { + id: issueExt.id ?? String(issue.number), + title: issue.title, + body: issue.body, + state: issue.state, + url: issue.url, + provider: 'jira', + author: normalizeAuthor(issueExt.author), + number: issue.number, + key: issueExt.key, + // Preserve Jira-specific fields + ...(issueExt.issueType && { issueType: issueExt.issueType }), + ...(issueExt.priority && { priority: issueExt.priority }), + ...(issueExt.status && { status: issueExt.status }), + } + + // Add labels if present + if (issue.labels && issue.labels.length > 0) { + result.labels = issue.labels.map(label => ({ name: label })) + } + + // Add assignees if present - Issue type uses assignees array of strings + if (issue.assignees && issue.assignees.length > 0) { + result.assignees = issue.assignees.map(name => ({ + id: name, + displayName: name, + })) + } + + // Fetch and add comments if requested + if (includeComments) { + const comments = await this.tracker.getComments(number) + result.comments = comments.map((comment: { + id: string + body: string + author: { displayName: string; emailAddress: string; accountId: string } + createdAt: string + updatedAt: string + }) => ({ + id: comment.id, + body: comment.body, + author: normalizeAuthor(comment.author), + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + })) + } + + return result + } + + /** + * Fetch a specific comment by ID + */ + async getComment(input: GetCommentInput): Promise { + const { commentId, number } = input + + // Fetch all comments and find the specific one + const comments = await this.tracker.getComments(number) + const comment = comments.find(c => c.id === commentId) + + if (!comment) { + throw new Error(`Comment ${commentId} not found on issue ${number}`) + } + + return { + id: comment.id, + body: comment.body, + author: normalizeAuthor(comment.author), + created_at: comment.createdAt, + updated_at: comment.updatedAt, + } + } + + /** + * Create a new comment on an issue + */ + async createComment(input: CreateCommentInput): Promise { + const { number, body } = input + const normalizedKey = this.tracker.normalizeIdentifier(number) + + // Jira doesn't distinguish between issue and PR comments + const comment = await this.tracker.addComment(normalizedKey, body) + + return { + id: comment.id, + url: `${this.tracker.getConfig().host}/browse/${normalizedKey}?focusedCommentId=${comment.id}`, + created_at: new Date().toISOString(), + } + } + + /** + * Update an existing comment + */ + async updateComment(input: UpdateCommentInput): Promise { + const { commentId, number, body } = input + const normalizedKey = this.tracker.normalizeIdentifier(number) + + // Update comment via tracker + await this.tracker.updateComment(normalizedKey, commentId, body) + + return { + id: commentId, + url: `${this.tracker.getConfig().host}/browse/${normalizedKey}?focusedCommentId=${commentId}`, + updated_at: new Date().toISOString(), + } + } + + /** + * Create a new issue + */ + async createIssue(input: CreateIssueInput): Promise { + const { title, body } = input + + // Create issue via tracker (labels not supported in current implementation) + const issue = await this.tracker.createIssue(title, body) + + const result: CreateIssueResult = { + id: String(issue.number), + url: issue.url, + } + + // Only add number if it's actually a number + if (typeof issue.number === 'number') { + result.number = issue.number + } + + return result + } +} diff --git a/src/mcp/issue-management-server.ts b/src/mcp/issue-management-server.ts index 23a14ddd..e8556937 100644 --- a/src/mcp/issue-management-server.ts +++ b/src/mcp/issue-management-server.ts @@ -10,6 +10,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' import { IssueManagementProviderFactory } from './IssueManagementProviderFactory.js' +import { SettingsManager } from '../lib/SettingsManager.js' +import type { IloomSettings } from '../lib/SettingsManager.js' import type { IssueProvider, GetIssueInput, @@ -19,6 +21,9 @@ import type { CreateIssueInput, } from './types.js' +// Module-level settings loaded at startup +let settings: IloomSettings | undefined + // Validate required environment variables function validateEnvironment(): IssueProvider { const provider = process.env.ISSUE_PROVIDER as IssueProvider | undefined @@ -27,8 +32,8 @@ function validateEnvironment(): IssueProvider { process.exit(1) } - if (provider !== 'github' && provider !== 'linear') { - console.error(`Invalid ISSUE_PROVIDER: ${provider}. Must be 'github' or 'linear'`) + if (provider !== 'github' && provider !== 'linear' && provider !== 'jira') { + console.error(`Invalid ISSUE_PROVIDER: ${provider}. Must be 'github', 'linear', or 'jira'`) process.exit(1) } @@ -53,6 +58,19 @@ function validateEnvironment(): IssueProvider { } } + // Jira requires host, username, API token, and project key + if (provider === 'jira') { + const required = ['JIRA_HOST', 'JIRA_USERNAME', 'JIRA_API_TOKEN', 'JIRA_PROJECT_KEY'] + const missing = required.filter((key) => !process.env[key]) + + if (missing.length > 0) { + console.error( + `Missing required environment variables for Jira provider: ${missing.join(', ')}` + ) + process.exit(1) + } + } + return provider } @@ -91,7 +109,7 @@ server.registerTool( body: z.string().describe('Issue body/description'), state: z.string().describe('Issue state (open, closed, etc.)'), url: z.string().describe('Issue URL'), - provider: z.enum(['github', 'linear']).describe('Issue management provider'), + provider: z.enum(['github', 'linear', 'jira']).describe('Issue management provider'), // Flexible author - core fields + passthrough author: flexibleAuthorSchema.nullable().describe( @@ -122,7 +140,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.getIssue({ number, includeComments }) @@ -172,7 +191,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.getComment({ commentId, number }) @@ -221,7 +241,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.createComment({ number, body, type }) @@ -270,7 +291,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.updateComment({ commentId, number, body }) @@ -322,7 +344,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.createIssue({ title, body, labels, teamKey }) @@ -350,6 +373,11 @@ server.registerTool( async function main(): Promise { console.error('Starting Issue Management MCP Server...') + // Load settings for providers that need them + const settingsManager = new SettingsManager() + settings = await settingsManager.loadSettings() + console.error('Settings loaded') + // Validate environment and get provider const provider = validateEnvironment() console.error('Environment validated') diff --git a/src/mcp/types.ts b/src/mcp/types.ts index b8938c7b..73502d2e 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -5,7 +5,7 @@ /** * Supported issue management providers */ -export type IssueProvider = 'github' | 'linear' +export type IssueProvider = 'github' | 'linear' | 'jira' /** * Environment variables required by MCP server diff --git a/src/types/index.ts b/src/types/index.ts index 27d4ac50..8b3aad02 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -181,6 +181,7 @@ export interface FinishOptions { noBrowser?: boolean // --no-browser - Skip opening PR in browser (github-pr mode only) cleanup?: boolean // --cleanup / --no-cleanup - Control worktree cleanup after finishing json?: boolean // --json - Output result as JSON + skipToPr?: boolean // --skip-to-pr - Skip rebase/validation/commit, go directly to PR creation (debug) } /** @@ -331,6 +332,7 @@ export interface CommitOptions { skipVerify?: boolean // Skip pre-commit hooks (--no-verify flag) skipVerifySilent?: boolean // Skip without warning (for --wip-commit) trailerType?: 'Refs' | 'Fixes' // Trailer type: "Refs" references issue, "Fixes" closes it (default: 'Fixes' for backward compat) + timeout?: number // Timeout in milliseconds for commit operation } /** diff --git a/src/utils/claude.test.ts b/src/utils/claude.test.ts index f2f678f0..c1efe788 100644 --- a/src/utils/claude.test.ts +++ b/src/utils/claude.test.ts @@ -334,7 +334,7 @@ describe('claude utils', () => { expect(execa).toHaveBeenCalledWith( 'claude', - ['-p', '--output-format', 'stream-json', '--verbose', '--add-dir', '/tmp'], + ['-p', '--output-format', 'stream-json', '--verbose', '--add-dir', '/tmp', '--debug'], expect.objectContaining({ input: prompt, timeout: 0, diff --git a/src/utils/claude.ts b/src/utils/claude.ts index a06b0681..25b2e025 100644 --- a/src/utils/claude.ts +++ b/src/utils/claude.ts @@ -191,11 +191,15 @@ export async function launchClaude( if (sessionId) { args.push('--session-id', sessionId) } + const isDebugMode = logger.isDebugEnabled() + + if (isDebugMode) { + args.push('--debug') // Enable debug mode for more detailed logs + } try { if (headless) { // Headless mode: capture and return output - const isDebugMode = logger.isDebugEnabled() // Set up execa options based on debug mode const execaOptions = { diff --git a/src/utils/git.ts b/src/utils/git.ts index 76c78c57..5ed7ba86 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -26,12 +26,12 @@ export class GitCommandError extends Error { */ export async function executeGitCommand( args: string[], - options?: { cwd?: string; timeout?: number; stdio?: 'inherit' | 'pipe'; env?: NodeJS.ProcessEnv } + options?: { cwd?: string; timeout?: number | undefined; stdio?: 'inherit' | 'pipe'; env?: NodeJS.ProcessEnv } ): Promise { try { const result = await execa('git', args, { cwd: options?.cwd ?? process.cwd(), - timeout: options?.timeout ?? 30000, + timeout: options?.timeout ?? 60000, encoding: 'utf8', stdio: options?.stdio ?? 'pipe', verbose: logger.isDebugEnabled(), diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts index 72084725..0930a8d9 100644 --- a/src/utils/mcp.ts +++ b/src/utils/mcp.ts @@ -18,7 +18,7 @@ import type { LoomMetadata } from '../lib/MetadataManager.js' export async function generateIssueManagementMcpConfig( contextType?: 'issue' | 'pr', repo?: string, - provider: 'github' | 'linear' = 'github', + provider: 'github' | 'linear' | 'jira' = 'github', settings?: IloomSettings, draftPrNumber?: number ): Promise[]> { @@ -74,7 +74,7 @@ export async function generateIssueManagementMcpConfig( githubEventName: githubEventName ?? 'auto-detect', draftPrNumber: draftPrNumber ?? undefined, }) - } else { + } else if (provider === 'linear') { // Linear needs API token passed through const apiToken = settings?.issueManagement?.linear?.apiToken ?? process.env.LINEAR_API_TOKEN @@ -95,6 +95,32 @@ export async function generateIssueManagementMcpConfig( hasTeamKey: !!teamKey, contextType: contextType ?? 'auto-detect', }) + } else if (provider === 'jira') { + // Jira configuration - pass credentials via environment variables + const jiraSettings = settings?.issueManagement?.jira + + if (jiraSettings?.host) { + envVars.JIRA_HOST = jiraSettings.host + } + if (jiraSettings?.username) { + envVars.JIRA_USERNAME = jiraSettings.username + } + if (jiraSettings?.apiToken) { + envVars.JIRA_API_TOKEN = jiraSettings.apiToken + } + if (jiraSettings?.projectKey) { + envVars.JIRA_PROJECT_KEY = jiraSettings.projectKey + } + if (jiraSettings?.transitionMappings) { + envVars.JIRA_TRANSITION_MAPPINGS = JSON.stringify(jiraSettings.transitionMappings) + } + + logger.debug('Generated MCP config for Jira issue management', { + provider, + hasApiToken: !!jiraSettings?.apiToken, + projectKey: jiraSettings?.projectKey, + contextType: contextType ?? 'auto-detect', + }) } // Generate single MCP server config diff --git a/src/utils/remote.ts b/src/utils/remote.ts index 9f4c2ac2..31c4a223 100644 --- a/src/utils/remote.ts +++ b/src/utils/remote.ts @@ -54,23 +54,35 @@ export async function parseGitRemotes(cwd?: string): Promise { } /** - * Extract owner and repo from GitHub URL - * Supports both HTTPS and SSH formats + * Extract owner and repo from Git remote URL + * Supports both HTTPS and SSH formats for GitHub and BitBucket */ function extractOwnerRepoFromUrl(url: string): { owner: string; repo: string } | null { // Remove .git suffix if present const cleanUrl = url.replace(/\.git$/, '') - // HTTPS format: https://github.com/owner/repo - const httpsMatch = cleanUrl.match(/https?:\/\/github\.com\/([^/]+)\/([^/]+)/) - if (httpsMatch?.[1] && httpsMatch?.[2]) { - return { owner: httpsMatch[1], repo: httpsMatch[2] } + // GitHub HTTPS format: https://github.com/owner/repo + const githubHttpsMatch = cleanUrl.match(/https?:\/\/github\.com\/([^/]+)\/([^/]+)/) + if (githubHttpsMatch?.[1] && githubHttpsMatch?.[2]) { + return { owner: githubHttpsMatch[1], repo: githubHttpsMatch[2] } } - // SSH format: git@github.com:owner/repo - const sshMatch = cleanUrl.match(/git@github\.com:([^/]+)\/(.+)/) - if (sshMatch?.[1] && sshMatch?.[2]) { - return { owner: sshMatch[1], repo: sshMatch[2] } + // GitHub SSH format: git@github.com:owner/repo + const githubSshMatch = cleanUrl.match(/git@github\.com:([^/]+)\/(.+)/) + if (githubSshMatch?.[1] && githubSshMatch?.[2]) { + return { owner: githubSshMatch[1], repo: githubSshMatch[2] } + } + + // BitBucket HTTPS format: https://bitbucket.org/workspace/repo + const bitbucketHttpsMatch = cleanUrl.match(/https?:\/\/bitbucket\.org\/([^/]+)\/([^/]+)/) + if (bitbucketHttpsMatch?.[1] && bitbucketHttpsMatch?.[2]) { + return { owner: bitbucketHttpsMatch[1], repo: bitbucketHttpsMatch[2] } + } + + // BitBucket SSH format: git@bitbucket.org:workspace/repo + const bitbucketSshMatch = cleanUrl.match(/git@bitbucket\.org:([^/]+)\/(.+)/) + if (bitbucketSshMatch?.[1] && bitbucketSshMatch?.[2]) { + return { owner: bitbucketSshMatch[1], repo: bitbucketSshMatch[2] } } return null diff --git a/templates/agents/iloom-issue-analyze-and-plan.md b/templates/agents/iloom-issue-analyze-and-plan.md index a276bc28..ab06bffa 100644 --- a/templates/agents/iloom-issue-analyze-and-plan.md +++ b/templates/agents/iloom-issue-analyze-and-plan.md @@ -216,6 +216,20 @@ Based on the lightweight analysis, create a detailed plan following the project' IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-analyzer.md b/templates/agents/iloom-issue-analyzer.md index 4bde6292..c119b47a 100644 --- a/templates/agents/iloom-issue-analyzer.md +++ b/templates/agents/iloom-issue-analyzer.md @@ -289,6 +289,20 @@ Use domain-specific MCP tools when available (Figma MCP, Database MCPs, etc.) as IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-complexity-evaluator.md b/templates/agents/iloom-issue-complexity-evaluator.md index 4d27bd29..b052824a 100644 --- a/templates/agents/iloom-issue-complexity-evaluator.md +++ b/templates/agents/iloom-issue-complexity-evaluator.md @@ -170,6 +170,20 @@ Estimate the following metrics: IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-enhancer.md b/templates/agents/iloom-issue-enhancer.md index 205a3149..5188aa35 100644 --- a/templates/agents/iloom-issue-enhancer.md +++ b/templates/agents/iloom-issue-enhancer.md @@ -94,6 +94,20 @@ Before asking questions, perform minimal research to avoid questions whose answe IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-implementer.md b/templates/agents/iloom-issue-implementer.md index 60b0b539..de22d340 100644 --- a/templates/agents/iloom-issue-implementer.md +++ b/templates/agents/iloom-issue-implementer.md @@ -18,6 +18,20 @@ This enables the recap panel to show quick-reference links to artifacts created IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-planner.md b/templates/agents/iloom-issue-planner.md index 33089dd8..ba53527c 100644 --- a/templates/agents/iloom-issue-planner.md +++ b/templates/agents/iloom-issue-planner.md @@ -34,6 +34,20 @@ Your primary task is to: IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-reviewer.md b/templates/agents/iloom-issue-reviewer.md index 13554515..a5331191 100644 --- a/templates/agents/iloom-issue-reviewer.md +++ b/templates/agents/iloom-issue-reviewer.md @@ -46,6 +46,20 @@ This enables the recap panel to show quick-reference links to artifacts created IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/prompts/session-summary-prompt.txt b/templates/prompts/session-summary-prompt.txt index 61a5fbd5..5213d12b 100644 --- a/templates/prompts/session-summary-prompt.txt +++ b/templates/prompts/session-summary-prompt.txt @@ -89,6 +89,20 @@ The reader doesn't care about your internal process. They care about: - Any explanation of what you're doing - Any text after the closing `
` tag +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + **Output ONLY the markdown content below, starting with `## iloom Session Summary` and ending with `
`.** Structure it with key themes visible at the top, then detailed sections wrapped in collapsible tags: