diff --git a/.husky/pre-commit b/.husky/pre-commit index ab37b58..edbd8e8 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/DEBUGGING_MCP.md b/DEBUGGING_MCP.md new file mode 100644 index 0000000..24de5a2 --- /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 e116a65..46461c2 100644 --- a/README.md +++ b/README.md @@ -439,12 +439,61 @@ 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. | + +### 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", + "doneStatuses": ["Done", "Closed"], + "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 +- `doneStatuses`: (Optional) Status names to exclude from `il issues` lists (default: `["Done"]`). Set to match your Jira workflow, e.g., `["Done", "Closed", "Verified"]` +- `transitionMappings`: (Optional) Map iloom states to your Jira workflow transition names ### IDE Support diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index 3bf23ec..0b385dd 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -1242,6 +1242,8 @@ il issues [options] [project-path] |------|-------------|---------| | `--json` | Output as JSON (default behavior) | `true` | | `--limit ` | Maximum number of items to return (combined issues + PRs) | `100` | +| `--sprint ` | **Jira only:** filter by sprint name (e.g., `"Sprint 17"`) or `"current"` for the active sprint | - | +| `--mine` | **Jira only:** show only issues assigned to the authenticated user | `false` | **Output Format:** ```json @@ -1279,6 +1281,11 @@ il issues [options] [project-path] - Works from worktrees (resolves settings from the correct project root) - For issues on GitHub: uses `gh issue list` with `--search sort:updated-desc` - For issues on Linear: uses `@linear/sdk` with team key filter from settings +- For issues on Jira: uses Jira REST API with JQL, excluding statuses listed in `doneStatuses` (default: `["Done"]`) +- `--sprint` and `--mine` flags are Jira-only. When used with other providers, a warning is logged and the flags are ignored. +- `--sprint current` uses Jira's `openSprints()` JQL function to match the active sprint +- `--sprint "Sprint 17"` filters to a specific named sprint +- `--mine` uses Jira's `currentUser()` JQL function to filter by the authenticated user - For PRs: uses `gh pr list --state open` with draft filtering **Examples:** @@ -1298,6 +1305,18 @@ il issues | jq '.[] | select(.type == "pr")' # Filter to only issues using jq il issues | jq '.[] | select(.type == "issue")' + +# Jira: show issues in the current sprint +il issues --sprint current + +# Jira: show issues in a specific sprint +il issues --sprint "Sprint 17" + +# Jira: show only my issues +il issues --mine + +# Jira: my issues in the current sprint +il issues --sprint current --mine ``` **Notes:** diff --git a/package.json b/package.json index c85853c..7a0df3a 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 08fd8e3..dc18ea2 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 @@ -582,6 +585,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==} @@ -601,12 +607,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} @@ -723,9 +738,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'} @@ -756,6 +782,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==} @@ -814,6 +843,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'} @@ -826,6 +858,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==} @@ -915,6 +950,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'} @@ -933,6 +971,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'} @@ -991,6 +1036,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 @@ -1086,6 +1135,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'} @@ -1106,9 +1162,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'} @@ -1151,6 +1213,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'} @@ -1321,6 +1387,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==} @@ -1375,6 +1445,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==} @@ -1420,6 +1493,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==} @@ -1436,10 +1512,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'} @@ -1458,6 +1573,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'} @@ -1715,6 +1917,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'} @@ -1963,6 +2184,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'} @@ -2030,6 +2254,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'} @@ -2048,6 +2287,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} @@ -2172,6 +2417,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': @@ -2512,6 +2760,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': @@ -2534,6 +2786,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 @@ -2542,6 +2800,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 @@ -2715,6 +2975,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 @@ -2722,6 +2986,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 @@ -2742,6 +3013,8 @@ snapshots: assertion-error@2.0.1: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -2816,6 +3089,8 @@ snapshots: callsites@3.1.0: {} + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -2831,6 +3106,8 @@ snapshots: chalk@5.6.2: {} + character-entities@2.0.2: {} + chardet@2.1.0: {} check-error@2.1.1: {} @@ -2894,6 +3171,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: {} @@ -2906,6 +3187,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 @@ -2973,6 +3260,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 @@ -3115,6 +3404,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 @@ -3135,10 +3440,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 @@ -3187,6 +3498,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + format@0.2.2: {} + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -3346,6 +3659,8 @@ snapshots: is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} is-stream@3.0.0: {} @@ -3400,6 +3715,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: {} @@ -3443,6 +3760,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: {} @@ -3461,8 +3780,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: @@ -3480,6 +3914,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 @@ -3701,6 +4333,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: {} @@ -3974,6 +4652,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 @@ -4040,6 +4720,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: {} @@ -4052,6 +4761,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 @@ -4176,3 +4895,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 80bd77c..9a3cf03 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1332,7 +1332,9 @@ program .argument('[project-path]', 'Path to project root (auto-detected if omitted)') .option('--json', 'Output as JSON (default behavior)') .option('--limit ', 'Max issues to return', '100') - .action(async (projectPath?: string, options?: { json?: boolean; limit?: string }) => { + .option('--sprint ', 'Jira only: filter by sprint name (e.g., "Sprint 17") or "current" for active sprint') + .option('--mine', 'Jira only: show only issues assigned to me') + .action(async (projectPath?: string, options?: { json?: boolean; limit?: string; sprint?: string; mine?: boolean }) => { try { const { IssuesCommand } = await import('./commands/issues.js') const command = new IssuesCommand() @@ -1341,6 +1343,8 @@ program const result = await command.execute({ ...(projectPath ? { projectPath } : {}), limit, + sprint: options?.sprint, + mine: options?.mine, }) console.log(JSON.stringify(result, null, 2)) } catch (error) { @@ -1892,6 +1896,83 @@ program } }) +// Test command for Jira integration +const testJiraCommand = program + .command('test-jira') + .description('Test Jira integration methods against a real Jira instance') + +testJiraCommand + .command('child-issue') + .description('Create a test child issue under a parent') + .argument('', 'Parent issue key (e.g., PROJ-123)') + .action(async (parentKey: string) => { + try { + const { TestJiraCommand } = await import('./commands/test-jira.js') + await new TestJiraCommand().createChildIssue(parentKey) + } catch (error) { + logger.error(`Failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + process.exit(1) + } + }) + +testJiraCommand + .command('create-dep') + .description('Create a "Blocks" dependency between two issues') + .argument('', 'Issue key that blocks (e.g., PROJ-100)') + .argument('', 'Issue key being blocked (e.g., PROJ-200)') + .action(async (blockingKey: string, blockedKey: string) => { + try { + const { TestJiraCommand } = await import('./commands/test-jira.js') + await new TestJiraCommand().createDependency(blockingKey, blockedKey) + } catch (error) { + logger.error(`Failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + process.exit(1) + } + }) + +testJiraCommand + .command('get-deps') + .description('Fetch and print dependencies for an issue') + .argument('', 'Issue key (e.g., PROJ-123)') + .action(async (issueKey: string) => { + try { + const { TestJiraCommand } = await import('./commands/test-jira.js') + await new TestJiraCommand().getDependencies(issueKey) + } catch (error) { + logger.error(`Failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + process.exit(1) + } + }) + +testJiraCommand + .command('remove-dep') + .description('Remove a "Blocks" dependency between two issues') + .argument('', 'Issue key that blocks (e.g., PROJ-100)') + .argument('', 'Issue key being blocked (e.g., PROJ-200)') + .action(async (blockingKey: string, blockedKey: string) => { + try { + const { TestJiraCommand } = await import('./commands/test-jira.js') + await new TestJiraCommand().removeDependency(blockingKey, blockedKey) + } catch (error) { + logger.error(`Failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + process.exit(1) + } + }) + +testJiraCommand + .command('get-children') + .description('List child issues of a parent') + .argument('', 'Parent issue key (e.g., PROJ-123)') + .action(async (issueKey: string) => { + try { + const { TestJiraCommand } = await import('./commands/test-jira.js') + await new TestJiraCommand().getChildIssues(issueKey) + } catch (error) { + logger.error(`Failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + process.exit(1) + } + }) + // Test command for Neon integration program .command('test-neon') diff --git a/src/commands/commit.ts b/src/commands/commit.ts index 6410264..b688430 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) @@ -262,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', } } @@ -281,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', } } @@ -295,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/finish.ts b/src/commands/finish.ts index cd783af..ff824b7 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,102 +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, - timeout: settings.git?.commitTimeout, - } + 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 @@ -932,7 +963,7 @@ 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, { @@ -1060,6 +1091,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 diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 39a1b59..1c23bdf 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -7,6 +7,8 @@ import { IssueTrackerFactory } from '../lib/IssueTrackerFactory.js' import { findMainWorktreePathWithSettings } from '../utils/git.js' import { fetchGitHubIssueList, fetchGitHubPRList } from '../utils/github.js' import { fetchLinearIssueList } from '../utils/linear.js' +import { fetchJiraIssueList } from '../utils/jira.js' +import { JiraApiClient } from '../lib/providers/jira/index.js' import { getLogger } from '../utils/logger-context.js' /** @@ -27,7 +29,7 @@ export interface IssueListItem { interface IssuesCacheFile { timestamp: number // Date.now() when cached projectPath: string // for verification - provider: string // 'github' | 'linear' + provider: string // 'github' | 'linear' | 'jira' data: IssueListItem[] } @@ -38,8 +40,8 @@ const CACHE_DIR = path.join(os.homedir(), '.config', 'iloom-ai', 'cache') /** * Generate a deterministic cache file path from project path + provider */ -function getCacheFilePath(projectPath: string, provider: string, limit: number): string { - const hash = crypto.createHash('md5').update(`${projectPath}:${provider}:${limit}`).digest('hex').slice(0, 12) +function getCacheFilePath(projectPath: string, provider: string, limit: number, sprint?: string, mine?: boolean): string { + const hash = crypto.createHash('md5').update(`${projectPath}:${provider}:${limit}:${sprint ?? ''}:${mine ? 'mine' : ''}`).digest('hex').slice(0, 12) return path.join(CACHE_DIR, `issues-${hash}.json`) } @@ -81,6 +83,8 @@ async function writeCacheFile( export interface IssuesCommandOptions { projectPath?: string | undefined limit?: number | undefined + sprint?: string | undefined + mine?: boolean | undefined } /** @@ -104,6 +108,8 @@ export class IssuesCommand { async execute(options?: IssuesCommandOptions): Promise { const logger = getLogger() const limit = options?.limit ?? 100 + const sprint = options?.sprint + const mine = options?.mine // 1. Resolve project root let resolvedProjectPath: string @@ -124,8 +130,13 @@ export class IssuesCommand { // 3. Determine provider const provider = IssueTrackerFactory.getProviderName(settings) + // Warn if Jira-only flags used with non-Jira provider + if (provider !== 'jira' && (sprint || mine)) { + logger.warn('--sprint and --mine flags are only supported with the Jira issue tracker. Ignoring.') + } + // 4. Check file-based cache - const cacheFilePath = getCacheFilePath(resolvedProjectPath, provider, limit) + const cacheFilePath = getCacheFilePath(resolvedProjectPath, provider, limit, sprint, mine) const cached = await readCacheFile(cacheFilePath) if (cached !== null) { logger.debug(`Returning cached issues (${cached.length} items)`) @@ -153,6 +164,35 @@ export class IssuesCommand { limit, ...(apiToken ? { apiToken } : {}), }) + } else if (provider === 'jira') { + const jiraSettings = settings.issueManagement?.jira + const host = jiraSettings?.host + if (!host) { + throw new Error( + 'Jira host not configured. Set issueManagement.jira.host in your settings.json.', + ) + } + const username = jiraSettings?.username + if (!username) { + throw new Error( + 'Jira username not configured. Set issueManagement.jira.username in your settings.json.', + ) + } + const apiToken = jiraSettings?.apiToken + if (!apiToken) { + throw new Error( + 'Jira API token not configured. Set issueManagement.jira.apiToken in your settings.json or settings.local.json.', + ) + } + const projectKey = jiraSettings?.projectKey + if (!projectKey) { + throw new Error( + 'Jira project key not configured. Set issueManagement.jira.projectKey in your settings.json.', + ) + } + const doneStatuses = jiraSettings?.doneStatuses + const client = new JiraApiClient({ host, username, apiToken }) + results = await fetchJiraIssueList(client, { host, projectKey, doneStatuses, limit, sprint, mine }) } else { throw new Error(`Unsupported issue tracker provider: ${provider}`) } @@ -161,6 +201,7 @@ export class IssuesCommand { results.forEach(item => { item.type = 'issue' }) // 6. Fetch PRs from GitHub (PRs are a GitHub concept regardless of issue tracker) + // TODO(bitbucket): detect bitbucket configuration and fetch PRs from Bitbucket instead of GitHub when relevant try { const prs = await fetchGitHubPRList({ limit, diff --git a/src/commands/summary.ts b/src/commands/summary.ts index 03202e0..895931d 100644 --- a/src/commands/summary.ts +++ b/src/commands/summary.ts @@ -186,7 +186,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), } } @@ -214,7 +214,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}`) @@ -228,8 +228,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] } @@ -290,7 +290,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}`) @@ -318,7 +318,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), } } } @@ -331,8 +331,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/commands/test-jira.ts b/src/commands/test-jira.ts new file mode 100644 index 0000000..ca29b80 --- /dev/null +++ b/src/commands/test-jira.ts @@ -0,0 +1,103 @@ +import { logger } from '../utils/logger.js' +import { SettingsManager } from '../lib/SettingsManager.js' +import { IssueManagementProviderFactory } from '../mcp/IssueManagementProviderFactory.js' +import type { IssueManagementProvider } from '../mcp/types.js' + +/** + * Test command for Jira integration + * Tests various Jira API operations against a real Jira instance + */ +export class TestJiraCommand { + private readonly settingsManager: SettingsManager + + constructor(settingsManager?: SettingsManager) { + this.settingsManager = settingsManager ?? new SettingsManager() + } + + private async createProvider(): Promise { + const settings = await this.settingsManager.loadSettings() + return IssueManagementProviderFactory.create('jira', settings) + } + + async createChildIssue(parentKey: string): Promise { + const provider = await this.createProvider() + + logger.info(`Creating test child issue under ${parentKey}...`) + const result = await provider.createChildIssue({ + parentId: parentKey, + title: `[Test] Child issue of ${parentKey}`, + body: 'This is a test child issue created by iloom test-jira.', + }) + + logger.success(`Child issue created: ${result.id}`) + logger.info(` URL: ${result.url}`) + } + + async createDependency(blockingKey: string, blockedKey: string): Promise { + const provider = await this.createProvider() + + logger.info(`Creating dependency: ${blockingKey} blocks ${blockedKey}...`) + await provider.createDependency({ + blockingIssue: blockingKey, + blockedIssue: blockedKey, + }) + + logger.success(`Dependency created: ${blockingKey} blocks ${blockedKey}`) + } + + async getDependencies(issueKey: string): Promise { + const provider = await this.createProvider() + + logger.info(`Fetching dependencies for ${issueKey}...`) + const result = await provider.getDependencies({ + number: issueKey, + direction: 'both', + }) + + logger.info(`\nBlocking (${issueKey} blocks):`) + if (result.blocking.length === 0) { + logger.info(' (none)') + } else { + for (const dep of result.blocking) { + logger.info(` ${dep.id}: ${dep.title} [${dep.state}]`) + } + } + + logger.info(`\nBlocked by (blocks ${issueKey}):`) + if (result.blockedBy.length === 0) { + logger.info(' (none)') + } else { + for (const dep of result.blockedBy) { + logger.info(` ${dep.id}: ${dep.title} [${dep.state}]`) + } + } + } + + async removeDependency(blockingKey: string, blockedKey: string): Promise { + const provider = await this.createProvider() + + logger.info(`Removing dependency: ${blockingKey} blocks ${blockedKey}...`) + await provider.removeDependency({ + blockingIssue: blockingKey, + blockedIssue: blockedKey, + }) + + logger.success(`Dependency removed: ${blockingKey} no longer blocks ${blockedKey}`) + } + + async getChildIssues(issueKey: string): Promise { + const provider = await this.createProvider() + + logger.info(`Fetching child issues of ${issueKey}...`) + const children = await provider.getChildIssues({ number: issueKey }) + + if (children.length === 0) { + logger.info('No child issues found') + } else { + logger.info(`\nChild issues (${children.length}):`) + for (const child of children) { + logger.info(` ${child.id}: ${child.title} [${child.state}] ${child.url}`) + } + } + } +} diff --git a/src/lib/ClaudeContextManager.test.ts b/src/lib/ClaudeContextManager.test.ts index d1355ee..9cad1b3 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 a041ee2..0ad74f9 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/GitHubService.ts b/src/lib/GitHubService.ts index ba0d853..1a8ce8e 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 76a8648..bc0c458 100644 --- a/src/lib/IssueEnhancementService.ts +++ b/src/lib/IssueEnhancementService.ts @@ -85,20 +85,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 a562bf9..f7af80e 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 8307667..04fed00 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 f26d045..e7aed9b 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 677b9f0..70657b6 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -433,6 +433,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, @@ -1160,8 +1161,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 @@ -1400,6 +1402,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 89822e7..5f215ed 100644 --- a/src/lib/MetadataManager.test.ts +++ b/src/lib/MetadataManager.test.ts @@ -313,6 +313,7 @@ describe('MetadataManager', () => { branchName: 'issue-42__auth-fix', worktreePath: '/Users/jane/dev/repo', issueType: 'issue', + issueKey: null, issue_numbers: ['42'], pr_numbers: [], issueTracker: 'github', @@ -408,6 +409,7 @@ describe('MetadataManager', () => { branchName: null, worktreePath: null, issueType: null, + issueKey: null, issue_numbers: [], pr_numbers: [], issueTracker: null, @@ -728,6 +730,7 @@ describe('MetadataManager', () => { branchName: 'issue-1__feat', worktreePath: '/Users/alice/project1', issueType: 'issue', + issueKey: null, issue_numbers: ['1'], pr_numbers: [], issueTracker: 'github', @@ -747,6 +750,7 @@ describe('MetadataManager', () => { branchName: 'issue-2__fix', worktreePath: '/Users/bob/project2', issueType: 'issue', + issueKey: null, issue_numbers: ['2'], pr_numbers: [], issueTracker: 'github', @@ -850,6 +854,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 c1d26e0..3f1087f 100644 --- a/src/lib/MetadataManager.ts +++ b/src/lib/MetadataManager.ts @@ -17,6 +17,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 @@ -47,6 +48,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 @@ -78,6 +80,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 @@ -128,6 +131,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, @@ -206,6 +210,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/SettingsManager.ts b/src/lib/SettingsManager.ts index 21b93b4..99fddbe 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -352,7 +352,7 @@ export const IloomSettingsSchema = z.object({ issueManagement: z .object({ // SYNC: If this default changes, update displayDefaultsBox() in src/utils/first-run-setup.ts - 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 @@ -377,6 +377,39 @@ 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"})'), + doneStatuses: z + .array(z.string()) + .optional() + .default(['Done']) + .describe('Status names to exclude from issue lists (e.g., ["Done", "Closed", "Verify"])'), + }) + .optional(), }) .optional() .describe('Issue management configuration'), @@ -555,7 +588,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 @@ -580,6 +613,39 @@ 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"})'), + doneStatuses: z + .array(z.string()) + .optional() + .default(['Done']) + .describe('Status names to exclude from issue lists (e.g., ["Done", "Closed", "Verify"])'), + }) + .optional(), }) .optional() .describe('Issue management configuration'), diff --git a/src/lib/providers/jira/AdfMarkdownConverter.test.ts b/src/lib/providers/jira/AdfMarkdownConverter.test.ts new file mode 100644 index 0000000..5643a3d --- /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 0000000..36f8ef4 --- /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 0000000..461b5a3 --- /dev/null +++ b/src/lib/providers/jira/JiraApiClient.ts @@ -0,0 +1,382 @@ +// 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 + */ +/** + * Jira issue link (relationship between issues) + */ +export interface JiraIssueLink { + id: string + type: { + id: string + name: string + inward: string + outward: string + } + inwardIssue?: { + id: string + key: string + fields: { + summary: string + status: { name: string } + } + } + outwardIssue?: { + id: string + key: string + fields: { + summary: string + status: { name: string } + } + } +} + +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 + issuelinks?: JiraIssueLink[] + parent?: { + id: string + key: string + fields: { + summary: string + status: { name: 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' | 'DELETE', + 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) + } + + /** + * Make a DELETE request to Jira API + */ + async delete(endpoint: string): Promise { + await this.request('DELETE', endpoint) + } + + /** + * 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, + }, + }, + }) + } + + /** + * Create an issue with a parent (subtask or child issue) + * Accepts Markdown description which is converted to ADF for Jira + */ + async createIssueWithParent( + projectKey: string, + summary: string, + description: string, + parentKey: string, + issueType = 'Sub-task' + ): Promise { + return this.post('/issue', { + fields: { + project: { + key: projectKey, + }, + summary, + description: markdownToAdf(description), + issuetype: { + name: issueType, + }, + parent: { + key: parentKey, + }, + }, + }) + } + + /** + * Create an issue link (dependency/relationship between issues) + * @param inwardKey - The issue key for the inward side (e.g., the blocked issue) + * @param outwardKey - The issue key for the outward side (e.g., the blocking issue) + * @param linkType - The link type name (e.g., "Blocks") + */ + async createIssueLink(inwardKey: string, outwardKey: string, linkType: string): Promise { + await this.post('/issueLink', { + type: { + name: linkType, + }, + inwardIssue: { + key: inwardKey, + }, + outwardIssue: { + key: outwardKey, + }, + }) + } + + /** + * Delete an issue link by ID + */ + async deleteIssueLink(linkId: string): Promise { + await this.delete(`/issueLink/${linkId}`) + } + + /** + * Search issues using JQL + */ + async searchIssues(jql: string): Promise { + const response = await this.post<{ issues: JiraIssue[] }>( + '/search/jql', + { jql, fields: ['*all'] } + ) + return response.issues + } + + /** + * 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 0000000..0c3fa50 --- /dev/null +++ b/src/lib/providers/jira/JiraIssueTracker.ts @@ -0,0 +1,354 @@ +// 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 the underlying API client (for direct API access by MCP provider) + */ + getApiClient(): JiraApiClient { + return this.client + } + + /** + * 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 0000000..d6f1ae1 --- /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 384d171..794d2a6 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 0000000..2e591f1 --- /dev/null +++ b/src/mcp/JiraIssueManagementProvider.ts @@ -0,0 +1,404 @@ +/** + * Jira implementation of Issue Management Provider + * Uses JiraIssueTracker for all operations + * Normalizes Jira-specific fields to provider-agnostic core fields + */ + +import type { + IssueManagementProvider, + GetIssueInput, + GetPRInput, + PRResult, + GetCommentInput, + CreateCommentInput, + UpdateCommentInput, + CreateIssueInput, + CreateChildIssueInput, + CreateDependencyInput, + GetDependenciesInput, + DependenciesResult, + RemoveDependencyInput, + GetChildIssuesInput, + ChildIssueResult, + 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 + private projectKey: string + + constructor(settings: IloomSettings) { + const config = getJiraTrackerConfig(settings); + + this.tracker = new JiraIssueTracker(config) + this.projectKey = config.projectKey + } + + /** + * 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 + } + + /** + * Fetch pull request details + * Jira does not have pull requests - throw like Linear does + */ + async getPR(_input: GetPRInput): Promise { + throw new Error( + 'Jira does not support pull requests. PRs exist only on GitHub. Use the GitHub provider for PR operations.' + ) + } + + /** + * Create a child issue linked to a parent issue + * Uses Jira's parent field to create a subtask + */ + async createChildIssue(input: CreateChildIssueInput): Promise { + const { parentId, title, body } = input + const parentKey = this.tracker.normalizeIdentifier(parentId) + + const jiraIssue = await this.tracker.getApiClient().createIssueWithParent( + this.projectKey, + title, + body, + parentKey + ) + + return { + id: jiraIssue.key, + url: `${this.tracker.getConfig().host}/browse/${jiraIssue.key}`, + } + } + + /** + * Create a blocking dependency between two issues + * Uses Jira issue links with "Blocks" link type + */ + async createDependency(input: CreateDependencyInput): Promise { + const blockingKey = this.tracker.normalizeIdentifier(input.blockingIssue) + const blockedKey = this.tracker.normalizeIdentifier(input.blockedIssue) + + // In Jira "Blocks" link type: outward = "blocks", inward = "is blocked by" + // outwardIssue blocks inwardIssue + await this.tracker.getApiClient().createIssueLink(blockedKey, blockingKey, 'Blocks') + } + + /** + * Get dependencies for an issue + * Parses issue links of type "Blocks" + */ + async getDependencies(input: GetDependenciesInput): Promise { + const issueKey = this.tracker.normalizeIdentifier(input.number) + const host = this.tracker.getConfig().host + + const issue = await this.tracker.getApiClient().getIssue(issueKey) + const links = issue.fields.issuelinks ?? [] + + const blocking: DependenciesResult['blocking'] = [] + const blockedBy: DependenciesResult['blockedBy'] = [] + + for (const link of links) { + if (link.type.name !== 'Blocks') continue + + // inwardIssue present = the other issue is the inward ("is blocked by") side + // โ†’ this issue blocks that issue โ†’ blocking + if (link.inwardIssue) { + blocking.push({ + id: link.inwardIssue.key, + title: link.inwardIssue.fields.summary, + url: `${host}/browse/${link.inwardIssue.key}`, + state: link.inwardIssue.fields.status.name.toLowerCase(), + }) + } + + // outwardIssue present = the other issue is the outward ("blocks") side + // โ†’ that issue blocks this issue โ†’ blockedBy + if (link.outwardIssue) { + blockedBy.push({ + id: link.outwardIssue.key, + title: link.outwardIssue.fields.summary, + url: `${host}/browse/${link.outwardIssue.key}`, + state: link.outwardIssue.fields.status.name.toLowerCase(), + }) + } + } + + if (input.direction === 'blocking') { + return { blocking, blockedBy: [] } + } + if (input.direction === 'blocked_by') { + return { blocking: [], blockedBy } + } + return { blocking, blockedBy } + } + + /** + * Remove a blocking dependency between two issues + * Finds the matching "Blocks" link and deletes it + */ + async removeDependency(input: RemoveDependencyInput): Promise { + const blockingKey = this.tracker.normalizeIdentifier(input.blockingIssue) + const blockedKey = this.tracker.normalizeIdentifier(input.blockedIssue) + + // Fetch the blocked issue to find the link + const issue = await this.tracker.getApiClient().getIssue(blockedKey) + const links = issue.fields.issuelinks ?? [] + + const matchingLink = links.find(link => + link.type.name === 'Blocks' && link.outwardIssue?.key === blockingKey + ) + + if (!matchingLink) { + throw new Error( + `No "Blocks" dependency found from ${blockingKey} to ${blockedKey}` + ) + } + + await this.tracker.getApiClient().deleteIssueLink(matchingLink.id) + } + + /** + * Get child issues of a parent issue + * Uses JQL search: parent = KEY + */ + async getChildIssues(input: GetChildIssuesInput): Promise { + const parentKey = this.tracker.normalizeIdentifier(input.number) + const host = this.tracker.getConfig().host + + const issues = await this.tracker.getApiClient().searchIssues(`parent = ${parentKey}`) + + return issues.map(issue => ({ + id: issue.key, + title: issue.fields.summary, + url: `${host}/browse/${issue.key}`, + state: issue.fields.status.name.toLowerCase(), + })) + } +} diff --git a/src/mcp/issue-management-server.ts b/src/mcp/issue-management-server.ts index b8eaba2..1e09725 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, @@ -25,6 +27,9 @@ import type { RemoveDependencyInput, } 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 @@ -33,8 +38,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) } @@ -59,6 +64,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 } @@ -104,7 +122,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( @@ -135,7 +153,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, repo }) @@ -291,7 +310,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, repo }) @@ -341,7 +361,7 @@ server.registerTool( try { // PR comments must always go to GitHub since PRs only exist on GitHub const providerType = type === 'pr' ? 'github' : (process.env.ISSUE_PROVIDER as IssueProvider) - const provider = IssueManagementProviderFactory.create(providerType) + const provider = IssueManagementProviderFactory.create(providerType, settings) const result = await provider.createComment({ number, body, type }) console.error( @@ -391,7 +411,7 @@ server.registerTool( try { // PR comments must always go to GitHub since PRs only exist on GitHub const providerType = type === 'pr' ? 'github' : (process.env.ISSUE_PROVIDER as IssueProvider) - const provider = IssueManagementProviderFactory.create(providerType) + const provider = IssueManagementProviderFactory.create(providerType, settings) const result = await provider.updateComment({ commentId, number, body }) console.error( @@ -449,7 +469,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, repo }) @@ -759,6 +780,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 7db4542..e0e8a05 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 83e2c64..5ee76b6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -182,6 +182,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) } /** diff --git a/src/utils/jira.ts b/src/utils/jira.ts new file mode 100644 index 0000000..4247738 --- /dev/null +++ b/src/utils/jira.ts @@ -0,0 +1,64 @@ +/** + * Jira utilities for the issues command + * Follows the pattern of fetchGitHubIssueList and fetchLinearIssueList + */ + +import type { JiraApiClient, JiraIssue } from '../lib/providers/jira/index.js' + +export interface JiraIssueListItem { + id: string // issue key e.g. "PROJ-123" + title: string // fields.summary + updatedAt: string // fields.updated (ISO string) + url: string // {host}/browse/{key} + state: string // fields.status.name +} + +/** + * Fetch a list of Jira issues for a project, excluding done statuses + * @param client - Configured JiraApiClient instance + * @param options - Fetch options + * @returns Array of issues sorted by updated date + */ +export async function fetchJiraIssueList( + client: JiraApiClient, + options: { + host: string + projectKey: string + doneStatuses?: string[] + limit?: number + sprint?: string | undefined + mine?: boolean | undefined + }, +): Promise { + const { host, projectKey, doneStatuses = ['Done'], limit = 100, sprint, mine } = options + + // Build JQL with status exclusion + const statusExclusions = doneStatuses.map((s) => `"${s}"`).join(', ') + let jql = `project = "${projectKey}" AND status NOT IN (${statusExclusions})` + + // Add sprint filter + if (sprint === 'current') { + jql += ' AND sprint in openSprints()' + } else if (sprint) { + jql += ` AND sprint = "${sprint}"` + } + + // Add assignee filter + if (mine) { + jql += ' AND assignee = currentUser()' + } + + jql += ' ORDER BY updated DESC' + + const issues: JiraIssue[] = await client.searchIssues(jql) + + const baseUrl = host.replace(/\/$/, '') + + return issues.slice(0, limit).map((issue) => ({ + id: issue.key, + title: issue.fields.summary, + updatedAt: issue.fields.updated, + url: `${baseUrl}/browse/${issue.key}`, + state: issue.fields.status.name, + })) +} diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts index 7208472..0930a8d 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/templates/agents/iloom-issue-analyze-and-plan.md b/templates/agents/iloom-issue-analyze-and-plan.md index a722dd3..8150491 100644 --- a/templates/agents/iloom-issue-analyze-and-plan.md +++ b/templates/agents/iloom-issue-analyze-and-plan.md @@ -231,6 +231,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 70bda08..1a3cd33 100644 --- a/templates/agents/iloom-issue-analyzer.md +++ b/templates/agents/iloom-issue-analyzer.md @@ -304,6 +304,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 f334678..bfe294f 100644 --- a/templates/agents/iloom-issue-complexity-evaluator.md +++ b/templates/agents/iloom-issue-complexity-evaluator.md @@ -207,6 +207,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 d50f12a..4e69778 100644 --- a/templates/agents/iloom-issue-enhancer.md +++ b/templates/agents/iloom-issue-enhancer.md @@ -109,6 +109,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 c045ed1..d806927 100644 --- a/templates/agents/iloom-issue-implementer.md +++ b/templates/agents/iloom-issue-implementer.md @@ -33,6 +33,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 f847a08..2316fee 100644 --- a/templates/agents/iloom-issue-planner.md +++ b/templates/agents/iloom-issue-planner.md @@ -49,6 +49,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/prompts/session-summary-prompt.txt b/templates/prompts/session-summary-prompt.txt index 61a5fbd..5213d12 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: