From d9dc857bac57b20d99e7a401c293f3591781ee71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 02:16:26 +0000 Subject: [PATCH 01/27] Initial plan From b572add9a58c9cc8a643356372534ddc2941c616 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 02:24:04 +0000 Subject: [PATCH 02/27] Add VersionControlProvider interface, ProviderCoordinator, and schema updates for Jira + BitBucket Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- src/lib/ProviderCoordinator.ts | 165 ++++++++++++++++++++++++++++++ src/lib/SettingsManager.ts | 120 ++++++++++++++++++++-- src/lib/VersionControlProvider.ts | 69 +++++++++++++ src/mcp/types.ts | 2 +- 4 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 src/lib/ProviderCoordinator.ts create mode 100644 src/lib/VersionControlProvider.ts diff --git a/src/lib/ProviderCoordinator.ts b/src/lib/ProviderCoordinator.ts new file mode 100644 index 00000000..e49d144d --- /dev/null +++ b/src/lib/ProviderCoordinator.ts @@ -0,0 +1,165 @@ +// ProviderCoordinator - Orchestrates workflows between IssueTracker and VersionControlProvider +// Manages the interaction between issue tracking and version control systems + +import type { IssueTracker } from './IssueTracker.js' +import type { VersionControlProvider } from './VersionControlProvider.js' +import { getLogger } from '../utils/logger-context.js' + +/** + * Options for posting agent output + */ +export interface PostAgentOutputOptions { + issueNumber?: string | number + prNumber?: number + body: string + cwd?: string +} + +/** + * Options for finish workflow + */ +export interface FinishWorkflowOptions { + branchName: string + title: string + body: string + baseBranch: string + issueNumber?: string | number + transitionState?: string + cwd?: string +} + +/** + * Result of finish workflow + */ +export interface FinishWorkflowResult { + prUrl: string + prNumber: number + issueTransitioned: boolean +} + +/** + * ProviderCoordinator orchestrates workflows across issue tracking and version control providers. + * + * Key responsibilities: + * - Route agent output to the correct destination (issue vs PR) + * - Coordinate PR creation with issue state transitions + * - Provide a unified interface for start/finish workflows + * + * Design pattern: + * - Uses composition over inheritance + * - Delegates provider-specific operations to injected providers + * - Handles cross-provider coordination logic + */ +export class ProviderCoordinator { + constructor( + private issueTracker: IssueTracker, + private vcsProvider: VersionControlProvider + ) {} + + /** + * Post agent output to the appropriate destination + * - If PR number provided, post to PR + * - Otherwise, post to issue + */ + async postAgentOutput(options: PostAgentOutputOptions): Promise { + const { issueNumber, prNumber, body, cwd } = options + + if (prNumber) { + // Post to PR via VCS provider + getLogger().debug('Posting agent output to PR', { prNumber }) + await this.vcsProvider.createPRComment(prNumber, body, cwd) + } else if (issueNumber) { + // Post to issue via issue tracker + getLogger().debug('Posting agent output to issue', { issueNumber }) + // Note: This will need the MCP server-based approach or direct API call + // For now, we'll throw since this needs to be integrated with the MCP system + throw new Error('Issue comment posting not yet implemented in coordinator') + } else { + throw new Error('Either issueNumber or prNumber must be provided') + } + } + + /** + * Execute finish workflow: + * 1. Create PR via VCS provider + * 2. Post session summary to PR + * 3. Transition issue to target state (e.g., "In Review") + */ + async executeFinishWorkflow(options: FinishWorkflowOptions): Promise { + const { branchName, title, body, baseBranch, issueNumber, transitionState, cwd } = options + + // Step 1: Create PR + getLogger().debug('Creating PR via VCS provider', { branchName, title }) + const prUrl = await this.vcsProvider.createPR(branchName, title, body, baseBranch, cwd) + + // Extract PR number from URL + const prNumber = this.extractPRNumberFromUrl(prUrl) + + getLogger().info('PR created successfully', { prUrl, prNumber }) + + // Step 2: Post session summary to PR (if provided in body) + // The body already contains the session summary, so this is handled by createPR + + // Step 3: Transition issue if requested + let issueTransitioned = false + if (issueNumber && transitionState) { + try { + // Check if issue tracker supports state transitions + if (this.issueTracker.moveIssueToInProgress) { + getLogger().debug('Transitioning issue state', { issueNumber, transitionState }) + // Note: This is a placeholder - actual transition logic will vary by provider + // For now, we only support moveIssueToInProgress + // TODO: Add more flexible transition support + await this.issueTracker.moveIssueToInProgress(issueNumber) + issueTransitioned = true + getLogger().info('Issue transitioned successfully', { issueNumber, transitionState }) + } else { + getLogger().warn('Issue tracker does not support state transitions', { + provider: this.issueTracker.providerName + }) + } + } catch (error) { + // Don't fail the whole workflow if transition fails + getLogger().error('Failed to transition issue', { error, issueNumber }) + } + } + + return { + prUrl, + prNumber, + issueTransitioned, + } + } + + /** + * Extract PR number from PR URL + * Handles various VCS provider URL formats + */ + private extractPRNumberFromUrl(url: string): number { + // GitHub: https://github.com/owner/repo/pull/123 + // BitBucket: https://bitbucket.org/workspace/repo/pull-requests/123 + const githubMatch = url.match(/\/pull\/(\d+)/) + const bitbucketMatch = url.match(/\/pull-requests\/(\d+)/) + + const match = githubMatch ?? bitbucketMatch + if (match?.[1]) { + return parseInt(match[1], 10) + } + + throw new Error(`Failed to extract PR number from URL: ${url}`) + } + + /** + * Get issue tracker instance + */ + getIssueTracker(): IssueTracker { + return this.issueTracker + } + + /** + * Get VCS provider instance + */ + getVCSProvider(): VersionControlProvider { + return this.vcsProvider + } +} diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index f862b440..930b8ee6 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -314,7 +314,7 @@ export const IloomSettingsSchema = z.object({ databaseProviders: DatabaseProvidersSettingsSchema.describe('Database provider configurations'), issueManagement: z .object({ - provider: z.enum(['github', 'linear']).optional().default('github').describe('Issue tracker provider (github, linear)'), + provider: z.enum(['github', 'linear', 'jira']).optional().default('github').describe('Issue tracker provider (github, linear, jira)'), github: z .object({ remote: z @@ -339,16 +339,70 @@ export const IloomSettingsSchema = z.object({ .describe('Linear API token (lin_api_...). SECURITY: Store in settings.local.json only, never commit to source control.'), }) .optional(), + jira: z + .object({ + host: z + .string() + .min(1, 'Jira host cannot be empty') + .describe('Jira instance URL (e.g., "https://yourcompany.atlassian.net")'), + username: z + .string() + .min(1, 'Jira username/email cannot be empty') + .describe('Jira username or email address'), + apiToken: z + .string() + .optional() + .describe('Jira API token. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://id.atlassian.com/manage-profile/security/api-tokens'), + projectKey: z + .string() + .min(1, 'Project key cannot be empty') + .describe('Jira project key (e.g., "PROJ", "ENG")'), + boardId: z + .string() + .optional() + .describe('Jira board ID for sprint/workflow operations (optional)'), + transitionMappings: z + .record(z.string(), z.string()) + .optional() + .describe('Map iloom states to Jira transition names (e.g., {"In Review": "Start Review"})'), + }) + .optional(), }) .optional() .describe('Issue management configuration'), + versionControl: z + .object({ + provider: z.enum(['github', 'bitbucket']).optional().default('github').describe('Version control provider (github, bitbucket)'), + bitbucket: z + .object({ + username: z + .string() + .min(1, 'BitBucket username cannot be empty') + .describe('BitBucket username'), + appPassword: z + .string() + .optional() + .describe('BitBucket app password. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://bitbucket.org/account/settings/app-passwords/'), + workspace: z + .string() + .optional() + .describe('BitBucket workspace (optional, auto-detected from git remote if not provided)'), + repoSlug: z + .string() + .optional() + .describe('BitBucket repository slug (optional, auto-detected from git remote if not provided)'), + }) + .optional(), + }) + .optional() + .describe('Version control provider configuration'), mergeBehavior: z .object({ - mode: z.enum(['local', 'github-pr', 'github-draft-pr']).default('local'), + mode: z.enum(['local', 'github-pr', 'github-draft-pr', 'bitbucket-pr']).default('local'), remote: z.string().optional(), }) .optional() - .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), or github-draft-pr (create draft PR at start, mark ready on finish)'), + .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), github-draft-pr (create draft PR at start, mark ready on finish), or bitbucket-pr (create BitBucket PR)'), ide: z .object({ type: z @@ -489,7 +543,7 @@ export const IloomSettingsSchemaNoDefaults = z.object({ databaseProviders: DatabaseProvidersSettingsSchema.describe('Database provider configurations'), issueManagement: z .object({ - provider: z.enum(['github', 'linear']).optional().describe('Issue tracker provider (github, linear)'), + provider: z.enum(['github', 'linear', 'jira']).optional().describe('Issue tracker provider (github, linear, jira)'), github: z .object({ remote: z @@ -514,16 +568,70 @@ export const IloomSettingsSchemaNoDefaults = z.object({ .describe('Linear API token (lin_api_...). SECURITY: Store in settings.local.json only, never commit to source control.'), }) .optional(), + jira: z + .object({ + host: z + .string() + .min(1, 'Jira host cannot be empty') + .describe('Jira instance URL (e.g., "https://yourcompany.atlassian.net")'), + username: z + .string() + .min(1, 'Jira username/email cannot be empty') + .describe('Jira username or email address'), + apiToken: z + .string() + .optional() + .describe('Jira API token. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://id.atlassian.com/manage-profile/security/api-tokens'), + projectKey: z + .string() + .min(1, 'Project key cannot be empty') + .describe('Jira project key (e.g., "PROJ", "ENG")'), + boardId: z + .string() + .optional() + .describe('Jira board ID for sprint/workflow operations (optional)'), + transitionMappings: z + .record(z.string(), z.string()) + .optional() + .describe('Map iloom states to Jira transition names (e.g., {"In Review": "Start Review"})'), + }) + .optional(), }) .optional() .describe('Issue management configuration'), + versionControl: z + .object({ + provider: z.enum(['github', 'bitbucket']).optional().describe('Version control provider (github, bitbucket)'), + bitbucket: z + .object({ + username: z + .string() + .min(1, 'BitBucket username cannot be empty') + .describe('BitBucket username'), + appPassword: z + .string() + .optional() + .describe('BitBucket app password. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://bitbucket.org/account/settings/app-passwords/'), + workspace: z + .string() + .optional() + .describe('BitBucket workspace (optional, auto-detected from git remote if not provided)'), + repoSlug: z + .string() + .optional() + .describe('BitBucket repository slug (optional, auto-detected from git remote if not provided)'), + }) + .optional(), + }) + .optional() + .describe('Version control provider configuration'), mergeBehavior: z .object({ - mode: z.enum(['local', 'github-pr', 'github-draft-pr']).optional(), + mode: z.enum(['local', 'github-pr', 'github-draft-pr', 'bitbucket-pr']).optional(), remote: z.string().optional(), }) .optional() - .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), or github-draft-pr (create draft PR at start, mark ready on finish)'), + .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), github-draft-pr (create draft PR at start, mark ready on finish), or bitbucket-pr (create BitBucket PR)'), ide: z .object({ type: z diff --git a/src/lib/VersionControlProvider.ts b/src/lib/VersionControlProvider.ts new file mode 100644 index 00000000..0c1e757b --- /dev/null +++ b/src/lib/VersionControlProvider.ts @@ -0,0 +1,69 @@ +// VersionControlProvider interface definition +// Generic interface for version control providers (GitHub, BitBucket, GitLab, etc.) + +import type { PullRequest } from '../types/index.js' + +/** + * Result of PR creation operation + */ +export interface PRCreationResult { + url: string + number: number + wasExisting: boolean +} + +/** + * Existing PR information + */ +export interface ExistingPR { + number: number + url: string +} + +/** + * VersionControlProvider interface - abstraction for VCS providers + * + * Design Philosophy: + * - Focuses exclusively on PR/MR (Pull Request/Merge Request) operations + * - Separates version control concerns from issue tracking + * - Identifiers use number for PR numbers (consistent with most VCS systems) + * - Providers expose capabilities via metadata fields + */ +export interface VersionControlProvider { + // Metadata - provider identification and capabilities + readonly providerName: string + readonly supportsForks: boolean + readonly supportsDraftPRs: boolean + + // PR operations - core functionality all providers must support + checkForExistingPR(branchName: string, cwd?: string): Promise + createPR( + branchName: string, + title: string, + body: string, + baseBranch: string, + cwd?: string + ): Promise + createDraftPR?( + branchName: string, + title: string, + body: string, + baseBranch: string, + cwd?: string + ): Promise + markPRReadyForReview?(prNumber: number, cwd?: string): Promise + + // PR metadata and state + fetchPR(prNumber: number, cwd?: string): Promise + getPRUrl(prNumber: number, cwd?: string): Promise + + // PR comments + createPRComment(prNumber: number, body: string, cwd?: string): Promise + + // Remote and repository detection + detectRepository(cwd?: string): Promise<{ owner: string; repo: string } | null> + getTargetRemote(cwd?: string): Promise + + // PR body generation (optional, can delegate to external service) + generatePRBody?(issueNumber: string | number | undefined, worktreePath: string): Promise +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts index b8938c7b..73502d2e 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -5,7 +5,7 @@ /** * Supported issue management providers */ -export type IssueProvider = 'github' | 'linear' +export type IssueProvider = 'github' | 'linear' | 'jira' /** * Environment variables required by MCP server From ad30c2509a1fb68ce65d3c3234ce9fb18872c5bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 02:29:33 +0000 Subject: [PATCH 03/27] Implement Jira provider components (ApiClient, CommentFormatter, IssueTracker) Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- src/lib/providers/jira/JiraApiClient.ts | 269 ++++++++++++++++++ .../providers/jira/JiraCommentFormatter.ts | 142 +++++++++ src/lib/providers/jira/JiraIssueTracker.ts | 221 ++++++++++++++ src/lib/providers/jira/index.ts | 4 + 4 files changed, 636 insertions(+) create mode 100644 src/lib/providers/jira/JiraApiClient.ts create mode 100644 src/lib/providers/jira/JiraCommentFormatter.ts create mode 100644 src/lib/providers/jira/JiraIssueTracker.ts create mode 100644 src/lib/providers/jira/index.ts diff --git a/src/lib/providers/jira/JiraApiClient.ts b/src/lib/providers/jira/JiraApiClient.ts new file mode 100644 index 00000000..1a410d6b --- /dev/null +++ b/src/lib/providers/jira/JiraApiClient.ts @@ -0,0 +1,269 @@ +// 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' + +/** + * Jira API configuration + */ +export interface JiraConfig { + host: string // e.g., "https://yourcompany.atlassian.net" + username: string // email address or username + apiToken: string // API token from Atlassian account +} + +/** + * Jira issue response from API + */ +export interface JiraIssue { + id: string + key: string + fields: { + summary: string + description: string | null + status: { + name: string + } + issuetype: { + name: string + } + project: { + key: string + name: string + } + assignee: { + displayName: string + emailAddress: string + accountId: string + } | null + reporter: { + displayName: string + emailAddress: string + accountId: string + } + labels: string[] + created: string + updated: string + [key: string]: unknown // Allow additional fields + } + [key: string]: unknown // Allow additional top-level fields +} + +/** + * Jira comment response from API + */ +export interface JiraComment { + id: string + author: { + displayName: string + emailAddress: string + accountId: string + } + body: string + created: string + updated: string + [key: string]: unknown +} + +/** + * Jira transition response from API + */ +export interface JiraTransition { + id: string + name: string + to: { + id: string + name: string + } +} + +/** + * JiraApiClient provides low-level REST API access to Jira + * + * Authentication: Basic Auth with username and API token + * API Reference: https://developer.atlassian.com/cloud/jira/platform/rest/v3/ + */ +export class JiraApiClient { + private readonly baseUrl: string + private readonly authHeader: string + + constructor(config: JiraConfig) { + this.baseUrl = `${config.host.replace(/\/$/, '')}/rest/api/3` + + // Create Basic Auth header + const credentials = Buffer.from(`${config.username}:${config.apiToken}`).toString('base64') + this.authHeader = `Basic ${credentials}` + } + + /** + * Make an HTTP request to Jira API + */ + private async request( + method: 'GET' | 'POST' | 'PUT', + endpoint: string, + body?: unknown + ): Promise { + const url = new URL(`${this.baseUrl}${endpoint}`) + getLogger().debug(`Jira API ${method} request`, { url: url.toString() }) + + return new Promise((resolve, reject) => { + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname + url.search, + method, + headers: { + 'Authorization': this.authHeader, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + } + + const req = https.request(options, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`Jira API error (${res.statusCode}): ${data}`)) + return + } + + // Handle empty response (e.g., 204 No Content) + if (res.statusCode === 204 || !data) { + resolve({} as T) + return + } + + try { + resolve(JSON.parse(data) as T) + } catch (error) { + reject(new Error(`Failed to parse Jira API response: ${error}`)) + } + }) + }) + + req.on('error', (error) => { + reject(new Error(`Jira API request failed: ${error.message}`)) + }) + + if (body) { + req.write(JSON.stringify(body)) + } + + req.end() + }) + } + + /** + * Make a GET request to Jira API + */ + private async get(endpoint: string): Promise { + return this.request('GET', endpoint) + } + + /** + * Make a POST request to Jira API + */ + private async post(endpoint: string, body: unknown): Promise { + return this.request('POST', endpoint, body) + } + + /** + * 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 + */ + async addComment(issueKey: string, body: string): Promise { + return this.post(`/issue/${issueKey}/comment`, { + body: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 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 + */ + async createIssue(projectKey: string, summary: string, description: string, issueType = 'Task'): Promise { + return this.post('/issue', { + fields: { + project: { + key: projectKey, + }, + summary, + description: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: description, + }, + ], + }, + ], + }, + issuetype: { + name: issueType, + }, + }, + }) + } + + /** + * Test connection to Jira API + */ + async testConnection(): Promise { + try { + await this.get('/myself') + return true + } catch (error) { + getLogger().error('Jira connection test failed', { error }) + return false + } + } +} diff --git a/src/lib/providers/jira/JiraCommentFormatter.ts b/src/lib/providers/jira/JiraCommentFormatter.ts new file mode 100644 index 00000000..15423b55 --- /dev/null +++ b/src/lib/providers/jira/JiraCommentFormatter.ts @@ -0,0 +1,142 @@ +// JiraCommentFormatter - Converts Markdown to Jira Wiki Markup +// Handles common formatting patterns for posting iloom content to Jira + +/** + * JiraCommentFormatter converts Markdown content to Jira Wiki Markup format + * + * Jira Wiki Markup reference: + * https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all + * + * Supported conversions: + * - Headers (# → h1., ## → h2., etc.) + * - Bold (**text** → *text*) + * - Italic (*text* → _text_) + * - Code blocks (``` → {code}) + * - Inline code (`text` → {{text}}) + * - Links ([text](url) → [text|url]) + * - Lists (* item → * item) + */ +export class JiraCommentFormatter { + /** + * Convert Markdown content to Jira Wiki Markup + */ + static markdownToJira(markdown: string): string { + let jira = markdown + + // Convert code blocks (must be done before inline code) + // ```language\ncode\n``` → {code:language}\ncode\n{code} + jira = jira.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => { + if (lang) { + return `{code:${lang}}\n${code.trim()}\n{code}` + } + return `{code}\n${code.trim()}\n{code}` + }) + + // Convert inline code + // `code` → {{code}} + jira = jira.replace(/`([^`]+)`/g, '{{$1}}') + + // Convert headers + // # Header → h1. Header + jira = jira.replace(/^######\s+(.+)$/gm, 'h6. $1') + jira = jira.replace(/^#####\s+(.+)$/gm, 'h5. $1') + jira = jira.replace(/^####\s+(.+)$/gm, 'h4. $1') + jira = jira.replace(/^###\s+(.+)$/gm, 'h3. $1') + jira = jira.replace(/^##\s+(.+)$/gm, 'h2. $1') + jira = jira.replace(/^#\s+(.+)$/gm, 'h1. $1') + + // Convert bold (must be done before italic to handle ***) + // **text** → *text* + jira = jira.replace(/\*\*([^*]+)\*\*/g, '*$1*') + + // Convert italic + // *text* → _text_ + // _text_ → _text_ (already correct) + jira = jira.replace(/(? quote → {quote}quote{quote} + jira = jira.replace(/^>\s+(.+)$/gm, '{quote}$1{quote}') + + // Convert horizontal rules + // --- → ---- + jira = jira.replace(/^---+$/gm, '----') + + return jira + } + + /** + * Convert Jira Wiki Markup to Markdown (reverse operation) + * Useful for displaying Jira content in terminal or logs + */ + static jiraToMarkdown(jira: string): string { + let markdown = jira + + // Convert code blocks + // {code:language}\ncode\n{code} → ```language\ncode\n``` + markdown = markdown.replace(/\{code:(\w+)\}\n([\s\S]*?)\{code\}/g, '```$1\n$2```') + markdown = markdown.replace(/\{code\}\n([\s\S]*?)\{code\}/g, '```\n$1```') + + // Convert inline code + // {{code}} → `code` + markdown = markdown.replace(/\{\{([^}]+)\}\}/g, '`$1`') + + // Convert headers + // h1. Header → # Header + markdown = markdown.replace(/^h6\.\s+(.+)$/gm, '###### $1') + markdown = markdown.replace(/^h5\.\s+(.+)$/gm, '##### $1') + markdown = markdown.replace(/^h4\.\s+(.+)$/gm, '#### $1') + markdown = markdown.replace(/^h3\.\s+(.+)$/gm, '### $1') + markdown = markdown.replace(/^h2\.\s+(.+)$/gm, '## $1') + markdown = markdown.replace(/^h1\.\s+(.+)$/gm, '# $1') + + // Convert bold + // *text* → **text** + markdown = markdown.replace(/\*([^*]+)\*/g, '**$1**') + + // Convert italic + // _text_ → *text* + markdown = markdown.replace(/_([^_]+)_/g, '*$1*') + + // Convert strikethrough + // -text- → ~~text~~ + markdown = markdown.replace(/-([^-]+)-/g, '~~$1~~') + + // Convert links + // [text|url] → [text](url) + markdown = markdown.replace(/\[([^\]|]+)\|([^\]]+)\]/g, '[$1]($2)') + + // Convert ordered lists + // # item → 1. item + let listCounter = 0 + markdown = markdown.replace(/^#\s+(.+)$/gm, () => { + listCounter++ + return `${listCounter}. $1` + }) + + // Convert blockquotes + // {quote}quote{quote} → > quote + markdown = markdown.replace(/\{quote\}(.+?)\{quote\}/g, '> $1') + + // Convert horizontal rules + // ---- → --- + markdown = markdown.replace(/^----+$/gm, '---') + + return markdown + } +} diff --git a/src/lib/providers/jira/JiraIssueTracker.ts b/src/lib/providers/jira/JiraIssueTracker.ts new file mode 100644 index 00000000..4da4cb66 --- /dev/null +++ b/src/lib/providers/jira/JiraIssueTracker.ts @@ -0,0 +1,221 @@ +// 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' + +/** + * 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) + * - Comments use Jira Wiki Markup (not 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, + }) + } + + /** + * Detect input type from user input + * Jira issues follow pattern: PROJECTKEY-123 + */ + async detectInputType(input: string): Promise { + // Pattern: PROJECTKEY-123 + const jiraPattern = /^([A-Z][A-Z0-9]+)-(\d+)$/ + const match = input.match(jiraPattern) + + if (!match) { + return { type: 'unknown', identifier: null, rawInput: input } + } + + const issueKey = 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 = String(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 = String(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 = String(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 }) + } + + /** + * 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(', ')}` : ''}` + } + + /** + * Map Jira API issue to generic Issue type + */ + private mapJiraIssueToIssue(jiraIssue: JiraIssue): Issue { + return { + number: jiraIssue.key, + title: jiraIssue.fields.summary, + body: jiraIssue.fields.description ?? '', + state: jiraIssue.fields.status.name.toLowerCase() as 'open' | 'closed', + labels: jiraIssue.fields.labels, + assignees: jiraIssue.fields.assignee + ? [jiraIssue.fields.assignee.displayName] + : [], + url: `${this.config.host}/browse/${jiraIssue.key}`, + } + } + + /** + * Find a transition by name, trying multiple possible names + */ + private findTransitionByName(transitions: JiraTransition[], names: string[]): string | null { + for (const name of names) { + const transition = transitions.find(t => + t.name.toLowerCase() === name.toLowerCase() + ) + if (transition) { + return transition.name + } + } + return null + } +} diff --git a/src/lib/providers/jira/index.ts b/src/lib/providers/jira/index.ts new file mode 100644 index 00000000..e7354033 --- /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 { JiraCommentFormatter } from './JiraCommentFormatter.js' +export { JiraIssueTracker, type JiraTrackerConfig } from './JiraIssueTracker.js' From 41c8308242b085ee7252a494b18205800b1e7739 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 02:33:17 +0000 Subject: [PATCH 04/27] Implement BitBucket provider components (ApiClient, VCSProvider) Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- .../providers/bitbucket/BitBucketApiClient.ts | 278 ++++++++++++++++++ .../bitbucket/BitBucketVCSProvider.ts | 203 +++++++++++++ src/lib/providers/bitbucket/index.ts | 3 + 3 files changed, 484 insertions(+) create mode 100644 src/lib/providers/bitbucket/BitBucketApiClient.ts create mode 100644 src/lib/providers/bitbucket/BitBucketVCSProvider.ts create mode 100644 src/lib/providers/bitbucket/index.ts diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.ts b/src/lib/providers/bitbucket/BitBucketApiClient.ts new file mode 100644 index 00000000..138aacae --- /dev/null +++ b/src/lib/providers/bitbucket/BitBucketApiClient.ts @@ -0,0 +1,278 @@ +// BitBucketApiClient - REST API wrapper for BitBucket operations +// Handles authentication and common API request patterns + +import https from 'node:https' +import { getLogger } from '../../../utils/logger-context.js' + +/** + * BitBucket API configuration + */ +export interface BitBucketConfig { + username: string + appPassword: string // App password from BitBucket settings + workspace?: string // Optional, can be auto-detected from git remote + repoSlug?: string // Optional, can be auto-detected from git remote +} + +/** + * BitBucket pull request response from API + */ +export interface BitBucketPullRequest { + id: number + title: string + description: string + state: 'OPEN' | 'MERGED' | 'DECLINED' | 'SUPERSEDED' + author: { + display_name: string + uuid: string + } + source: { + branch: { + name: string + } + } + destination: { + branch: { + name: string + } + } + created_on: string + updated_on: string + links: { + html: { + href: string + } + } + [key: string]: unknown +} + +/** + * BitBucket repository response from API + */ +export interface BitBucketRepository { + slug: string + name: string + full_name: string + workspace: { + slug: string + } + links: { + html: { + href: string + } + } + [key: string]: unknown +} + +/** + * BitBucketApiClient provides low-level REST API access to BitBucket + * + * Authentication: Basic Auth with username and app password + * API Reference: https://developer.atlassian.com/cloud/bitbucket/rest/intro/ + */ +export class BitBucketApiClient { + private readonly baseUrl = 'https://api.bitbucket.org/2.0' + private readonly authHeader: string + private readonly workspace: string | undefined + private readonly repoSlug: string | undefined + + constructor(config: BitBucketConfig) { + // Create Basic Auth header + const credentials = Buffer.from(`${config.username}:${config.appPassword}`).toString('base64') + this.authHeader = `Basic ${credentials}` + + this.workspace = config.workspace + this.repoSlug = config.repoSlug + } + + /** + * Make an HTTP request to BitBucket API + */ + private async request( + method: 'GET' | 'POST', + endpoint: string, + body?: unknown + ): Promise { + const url = new URL(`${this.baseUrl}${endpoint}`) + getLogger().debug(`BitBucket API ${method} request`, { url: url.toString() }) + + return new Promise((resolve, reject) => { + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname + url.search, + method, + headers: { + 'Authorization': this.authHeader, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + } + + const req = https.request(options, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`BitBucket API error (${res.statusCode}): ${data}`)) + return + } + + // Handle empty response + if (res.statusCode === 204 || !data) { + resolve({} as T) + return + } + + try { + resolve(JSON.parse(data) as T) + } catch (error) { + reject(new Error(`Failed to parse BitBucket API response: ${error}`)) + } + }) + }) + + req.on('error', (error) => { + reject(new Error(`BitBucket API request failed: ${error.message}`)) + }) + + if (body) { + req.write(JSON.stringify(body)) + } + + req.end() + }) + } + + /** + * Make a GET request to BitBucket API + */ + private async get(endpoint: string): Promise { + return this.request('GET', endpoint) + } + + /** + * Make a POST request to BitBucket API + */ + private async post(endpoint: string, body: unknown): Promise { + return this.request('POST', endpoint, body) + } + + /** + * Get repository information + */ + async getRepository(workspace: string, repoSlug: string): Promise { + return this.get(`/repositories/${workspace}/${repoSlug}`) + } + + /** + * Get a pull request by ID + */ + async getPullRequest( + workspace: string, + repoSlug: string, + prId: number + ): Promise { + return this.get( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}` + ) + } + + /** + * List open pull requests for a branch + */ + async listPullRequests( + workspace: string, + repoSlug: string, + sourceBranch?: string + ): Promise { + let endpoint = `/repositories/${workspace}/${repoSlug}/pullrequests?state=OPEN` + + if (sourceBranch) { + endpoint += `&source.branch.name=${encodeURIComponent(sourceBranch)}` + } + + const response = await this.get<{ values: BitBucketPullRequest[] }>(endpoint) + return response.values + } + + /** + * Create a pull request + */ + async createPullRequest( + workspace: string, + repoSlug: string, + title: string, + description: string, + sourceBranch: string, + destinationBranch: string + ): Promise { + return this.post( + `/repositories/${workspace}/${repoSlug}/pullrequests`, + { + title, + description, + source: { + branch: { + name: sourceBranch, + }, + }, + destination: { + branch: { + name: destinationBranch, + }, + }, + } + ) + } + + /** + * Add a comment to a pull request + */ + async addPRComment( + workspace: string, + repoSlug: string, + prId: number, + content: string + ): Promise { + await this.post( + `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`, + { + content: { + raw: content, + }, + } + ) + } + + /** + * Test connection to BitBucket API + */ + async testConnection(): Promise { + try { + await this.get('/user') + return true + } catch (error) { + getLogger().error('BitBucket connection test failed', { error }) + return false + } + } + + /** + * Get configured workspace + */ + getWorkspace(): string | undefined { + return this.workspace + } + + /** + * Get configured repository slug + */ + getRepoSlug(): string | undefined { + return this.repoSlug + } +} diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts new file mode 100644 index 00000000..d6a5c152 --- /dev/null +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts @@ -0,0 +1,203 @@ +// BitBucketVCSProvider - Implements VersionControlProvider for BitBucket +// Provides PR/VCS operations via BitBucket REST API + +import type { VersionControlProvider, ExistingPR } from '../../VersionControlProvider.js' +import type { PullRequest } from '../../../types/index.js' +import { BitBucketApiClient, type BitBucketConfig, type BitBucketPullRequest } from './BitBucketApiClient.js' +import { getLogger } from '../../../utils/logger-context.js' +import { parseGitRemotes } from '../../../utils/remote.js' + +/** + * BitBucket-specific configuration + * Extends BitBucketConfig with username, appPassword, workspace, and repoSlug + */ +export type BitBucketVCSConfig = BitBucketConfig + +/** + * BitBucketVCSProvider implements VersionControlProvider for BitBucket + * + * Key differences from GitHub: + * - Uses workspace/repository slug instead of owner/repo + * - PR states are different (OPEN, MERGED, DECLINED, SUPERSEDED) + * - No native draft PR support + */ +export class BitBucketVCSProvider implements VersionControlProvider { + readonly providerName = 'bitbucket' + readonly supportsForks = true + readonly supportsDraftPRs = false // BitBucket doesn't have draft PRs + + private readonly client: BitBucketApiClient + + constructor(config: BitBucketVCSConfig) { + this.client = new BitBucketApiClient(config) + } + + /** + * Check if a PR already exists for the given branch + */ + async checkForExistingPR(branchName: string, cwd?: string): Promise { + try { + // Get workspace and repo slug from config or detect from git remote + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + const prs = await this.client.listPullRequests(workspace, repoSlug, branchName) + + if (prs.length > 0 && prs[0]) { + const pr = prs[0] + return { + number: pr.id, + url: pr.links.html.href, + } + } + + return null + } catch (error) { + getLogger().debug('Error checking for existing PR', { error }) + return null + } + } + + /** + * Create a pull request + */ + async createPR( + branchName: string, + title: string, + body: string, + baseBranch: string, + cwd?: string + ): Promise { + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + getLogger().debug('Creating BitBucket PR', { workspace, repoSlug, branchName, title }) + + const pr = await this.client.createPullRequest( + workspace, + repoSlug, + title, + body, + branchName, + baseBranch + ) + + return pr.links.html.href + } + + /** + * Fetch PR details + */ + async fetchPR(prNumber: number, cwd?: string): Promise { + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + const bbPR = await this.client.getPullRequest(workspace, repoSlug, prNumber) + return this.mapBitBucketPRToPullRequest(bbPR) + } + + /** + * Get PR URL + */ + async getPRUrl(prNumber: number, cwd?: string): Promise { + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + const bbPR = await this.client.getPullRequest(workspace, repoSlug, prNumber) + return bbPR.links.html.href + } + + /** + * Create a comment on a PR + */ + async createPRComment(prNumber: number, body: string, cwd?: string): Promise { + const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) + + getLogger().debug('Creating BitBucket PR comment', { workspace, repoSlug, prNumber }) + + await this.client.addPRComment(workspace, repoSlug, prNumber, body) + } + + /** + * Detect repository from git remote + */ + async detectRepository(cwd?: string): Promise<{ owner: string; repo: string } | null> { + try { + const remotes = await parseGitRemotes(cwd) + + // Look for bitbucket.org remote + const bbRemote = remotes.find(r => + r.url.includes('bitbucket.org') + ) + + if (!bbRemote) { + return null + } + + // BitBucket URLs: https://bitbucket.org/workspace/repo.git + // or git@bitbucket.org:workspace/repo.git + return { + owner: bbRemote.owner, // workspace + repo: bbRemote.repo, + } + } catch (error) { + getLogger().error('Failed to detect BitBucket repository', { error }) + return null + } + } + + /** + * Get target remote for PR operations + */ + async getTargetRemote(_cwd?: string): Promise { + // For BitBucket, we typically use 'origin' + // Fork workflows are less common in BitBucket + return 'origin' + } + + /** + * Get workspace and repository slug from config or git remote + */ + private async getWorkspaceAndRepo(cwd?: string): Promise<{ workspace: string; repoSlug: string }> { + let workspace = this.client.getWorkspace() + let repoSlug = this.client.getRepoSlug() + + // If not configured, try to detect from git remote + if (!workspace || !repoSlug) { + const detected = await this.detectRepository(cwd) + if (!detected) { + throw new Error( + 'Could not determine BitBucket workspace/repository. ' + + 'Either configure them in settings or ensure git remote points to bitbucket.org' + ) + } + + workspace = workspace ?? detected.owner + repoSlug = repoSlug ?? detected.repo + } + + return { workspace, repoSlug } + } + + /** + * Map BitBucket PR to generic PullRequest type + */ + private mapBitBucketPRToPullRequest(bbPR: BitBucketPullRequest): PullRequest { + // Map BitBucket states to generic states + let state: 'open' | 'closed' | 'merged' + if (bbPR.state === 'OPEN') { + state = 'open' + } else if (bbPR.state === 'MERGED') { + state = 'merged' + } else { + state = 'closed' // DECLINED or SUPERSEDED + } + + return { + number: bbPR.id, + title: bbPR.title, + body: bbPR.description, + state, + branch: bbPR.source.branch.name, + baseBranch: bbPR.destination.branch.name, + url: bbPR.links.html.href, + isDraft: false, // BitBucket doesn't have draft PRs + } + } +} diff --git a/src/lib/providers/bitbucket/index.ts b/src/lib/providers/bitbucket/index.ts new file mode 100644 index 00000000..c3d4791b --- /dev/null +++ b/src/lib/providers/bitbucket/index.ts @@ -0,0 +1,3 @@ +// BitBucket provider exports +export { BitBucketApiClient, type BitBucketConfig, type BitBucketPullRequest, type BitBucketRepository } from './BitBucketApiClient.js' +export { BitBucketVCSProvider, type BitBucketVCSConfig } from './BitBucketVCSProvider.js' From a2e4d5ed226cfb1cc7c832fe516ffce764fac247 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 02:34:51 +0000 Subject: [PATCH 05/27] Update README with Jira + BitBucket configuration examples and setup instructions Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- README.md | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b2b798d1..876005a5 100644 --- a/README.md +++ b/README.md @@ -387,12 +387,155 @@ Integrations ### Issue Trackers -iloom supports the tools you already use. Unless you use JIRA. +iloom supports multiple issue tracking providers to fit your team's workflow. | **Provider** | **Setup** | **Notes** | |--------------|-----------|-----------| | **GitHub** | `gh auth login` | Default. Supports Issues and Pull Requests automatically. | | **Linear** | `il init` | Requires API token. Supports full read/write on Linear issues. | +| **Jira** | Configure in `.iloom/settings.json` | Atlassian Cloud. Requires API token. See [Jira Setup](#jira-setup) below. | + +### Version Control Providers + +Choose which platform hosts your pull requests and code reviews. + +| **Provider** | **Setup** | **Notes** | +|--------------|-----------|-----------| +| **GitHub** | `gh auth login` | Default. Integrated with GitHub Issues. | +| **BitBucket** | Configure in `.iloom/settings.json` | Atlassian Cloud. Requires app password. See [BitBucket Setup](#bitbucket-setup) below. | + +### Jira Setup + +To use Jira as your issue tracker, add this configuration: + +**.iloom/settings.json (Committed)** +```json +{ + "issueManagement": { + "provider": "jira", + "jira": { + "host": "https://yourcompany.atlassian.net", + "username": "your.email@company.com", + "projectKey": "PROJ", + "boardId": "123", + "transitionMappings": { + "In Review": "Start Review" + } + } + } +} +``` + +**.iloom/settings.local.json (Gitignored - Never commit this file)** +```json +{ + "issueManagement": { + "jira": { + "apiToken": "your-jira-api-token-here" + } + } +} +``` + +**Generate a Jira API Token:** +1. Visit https://id.atlassian.com/manage-profile/security/api-tokens +2. Click "Create API token" +3. Copy the token to `.iloom/settings.local.json` + +**Configuration Options:** +- `host`: Your Jira Cloud instance URL +- `username`: Your Jira email address +- `apiToken`: API token (store in settings.local.json only!) +- `projectKey`: Jira project key (e.g., "PROJ", "ENG") +- `boardId`: (Optional) Board ID for sprint/workflow operations +- `transitionMappings`: (Optional) Map iloom states to your Jira workflow transition names + +### BitBucket Setup + +To use BitBucket for pull requests, add this configuration: + +**.iloom/settings.json (Committed)** +```json +{ + "versionControl": { + "provider": "bitbucket", + "bitbucket": { + "username": "your-bitbucket-username", + "workspace": "your-workspace", + "repoSlug": "your-repo" + } + }, + "mergeBehavior": { + "mode": "bitbucket-pr" + } +} +``` + +**.iloom/settings.local.json (Gitignored - Never commit this file)** +```json +{ + "versionControl": { + "bitbucket": { + "appPassword": "your-bitbucket-app-password" + } + } +} +``` + +**Generate a BitBucket App Password:** +1. Visit https://bitbucket.org/account/settings/app-passwords/ +2. Click "Create app password" +3. Grant permissions: `repository:read`, `repository:write`, `pullrequest:read`, `pullrequest:write` +4. Copy the password to `.iloom/settings.local.json` + +**Configuration Options:** +- `username`: Your BitBucket username +- `appPassword`: App password (store in settings.local.json only!) +- `workspace`: (Optional) BitBucket workspace, auto-detected from git remote if not provided +- `repoSlug`: (Optional) Repository slug, auto-detected from git remote if not provided + +### Jira + BitBucket Together + +Use Jira for issues and BitBucket for pull requests: + +**.iloom/settings.json** +```json +{ + "issueManagement": { + "provider": "jira", + "jira": { + "host": "https://yourcompany.atlassian.net", + "username": "your.email@company.com", + "projectKey": "PROJ" + } + }, + "versionControl": { + "provider": "bitbucket", + "bitbucket": { + "username": "your-bitbucket-username" + } + }, + "mergeBehavior": { + "mode": "bitbucket-pr" + } +} +``` + +**.iloom/settings.local.json** +```json +{ + "issueManagement": { + "jira": { + "apiToken": "your-jira-api-token" + } + }, + "versionControl": { + "bitbucket": { + "appPassword": "your-bitbucket-app-password" + } + } +} +``` ### IDE Support From 23e1c274135609fe9b497d5a9536fe2f4c404d73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 03:18:20 +0000 Subject: [PATCH 06/27] Wire Jira and BitBucket providers into command factories (IssueTrackerFactory, VCSProviderFactory) Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- src/lib/IssueTrackerFactory.ts | 33 +++++++++++- src/lib/VCSProviderFactory.ts | 92 ++++++++++++++++++++++++++++++++++ src/utils/mcp.ts | 11 +++- 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/lib/VCSProviderFactory.ts diff --git a/src/lib/IssueTrackerFactory.ts b/src/lib/IssueTrackerFactory.ts index 8307667d..04fed002 100644 --- a/src/lib/IssueTrackerFactory.ts +++ b/src/lib/IssueTrackerFactory.ts @@ -4,10 +4,11 @@ import type { IssueTracker } from './IssueTracker.js' import { GitHubService } from './GitHubService.js' import { LinearService, type LinearServiceConfig } from './LinearService.js' +import { JiraIssueTracker, type JiraTrackerConfig } from './providers/jira/index.js' import type { IloomSettings } from './SettingsManager.js' import { getLogger } from '../utils/logger-context.js' -export type IssueTrackerProviderType = 'github' | 'linear' +export type IssueTrackerProviderType = 'github' | 'linear' | 'jira' /** * Factory for creating IssueTracker instances based on settings @@ -53,6 +54,36 @@ export class IssueTrackerFactory { getLogger().debug(`IssueTrackerFactory: Creating LinearService with config:`, JSON.stringify(linearConfig, null, 2)) return new LinearService(linearConfig) } + case 'jira': { + const jiraSettings = settings.issueManagement?.jira + + if (!jiraSettings?.host) { + throw new Error('Jira host is required. Configure issueManagement.jira.host in .iloom/settings.json') + } + if (!jiraSettings?.username) { + throw new Error('Jira username is required. Configure issueManagement.jira.username in .iloom/settings.json') + } + if (!jiraSettings?.apiToken) { + throw new Error('Jira API token is required. Configure issueManagement.jira.apiToken in .iloom/settings.local.json') + } + if (!jiraSettings?.projectKey) { + throw new Error('Jira project key is required. Configure issueManagement.jira.projectKey in .iloom/settings.json') + } + + const jiraConfig: JiraTrackerConfig = { + host: jiraSettings.host, + username: jiraSettings.username, + apiToken: jiraSettings.apiToken, + projectKey: jiraSettings.projectKey, + } + + if (jiraSettings.transitionMappings) { + jiraConfig.transitionMappings = jiraSettings.transitionMappings + } + + getLogger().debug(`IssueTrackerFactory: Creating JiraIssueTracker for host: ${jiraSettings.host}`) + return new JiraIssueTracker(jiraConfig) + } default: throw new Error(`Unsupported issue tracker provider: ${provider}`) } diff --git a/src/lib/VCSProviderFactory.ts b/src/lib/VCSProviderFactory.ts new file mode 100644 index 00000000..81592f36 --- /dev/null +++ b/src/lib/VCSProviderFactory.ts @@ -0,0 +1,92 @@ +// VCSProviderFactory - creates appropriate VersionControlProvider based on settings +// Follows pattern from IssueTrackerFactory + +import type { VersionControlProvider } from './VersionControlProvider.js' +import { BitBucketVCSProvider, type BitBucketVCSConfig } from './providers/bitbucket/index.js' +import type { IloomSettings } from './SettingsManager.js' +import { getLogger } from '../utils/logger-context.js' + +export type VCSProviderType = 'github' | 'bitbucket' + +/** + * Factory for creating VersionControlProvider instances based on settings + * + * Note: GitHub VCS operations still use PRManager with gh CLI for now. + * This factory is primarily for BitBucket and future VCS providers. + */ +export class VCSProviderFactory { + /** + * Create a VersionControlProvider instance based on settings configuration + * + * @param settings - iloom settings containing versionControl.provider + * @returns VersionControlProvider instance configured for the specified provider + * @throws Error if provider type is not supported or required config is missing + */ + static create(settings: IloomSettings): VersionControlProvider | null { + const provider = settings.versionControl?.provider + + // If no versionControl config, return null (use legacy PRManager for GitHub) + if (!provider) { + getLogger().debug('VCSProviderFactory: No versionControl.provider configured, using legacy PRManager') + return null + } + + getLogger().debug(`VCSProviderFactory: Creating VCS provider for "${provider}"`) + + switch (provider) { + case 'github': + // GitHub still uses PRManager with gh CLI + getLogger().debug('VCSProviderFactory: GitHub uses legacy PRManager, returning null') + return null + + case 'bitbucket': { + const bbSettings = settings.versionControl?.bitbucket + + if (!bbSettings?.username) { + throw new Error('BitBucket username is required. Configure versionControl.bitbucket.username in .iloom/settings.json') + } + if (!bbSettings?.appPassword) { + throw new Error('BitBucket app password is required. Configure versionControl.bitbucket.appPassword in .iloom/settings.local.json') + } + + const bbConfig: BitBucketVCSConfig = { + username: bbSettings.username, + appPassword: bbSettings.appPassword, + } + + if (bbSettings.workspace) { + bbConfig.workspace = bbSettings.workspace + } + if (bbSettings.repoSlug) { + bbConfig.repoSlug = bbSettings.repoSlug + } + + getLogger().debug(`VCSProviderFactory: Creating BitBucketVCSProvider for user: ${bbSettings.username}`) + return new BitBucketVCSProvider(bbConfig) + } + + default: + throw new Error(`Unsupported VCS provider: ${provider}`) + } + } + + /** + * Check if a VCS provider is configured + * + * @param settings - iloom settings + * @returns true if versionControl provider is configured + */ + static isConfigured(settings: IloomSettings): boolean { + return settings.versionControl?.provider !== undefined && settings.versionControl?.provider !== 'github' + } + + /** + * Get the configured provider name from settings + * + * @param settings - iloom settings + * @returns Provider type string or undefined if not configured + */ + static getProviderName(settings: IloomSettings): VCSProviderType | undefined { + return settings.versionControl?.provider as VCSProviderType | undefined + } +} diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts index 72084725..2845905b 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,13 @@ export async function generateIssueManagementMcpConfig( hasTeamKey: !!teamKey, contextType: contextType ?? 'auto-detect', }) + } else if (provider === 'jira') { + // Jira configuration - for now, Jira doesn't use MCP server + // The JiraIssueTracker is used directly in commands + logger.debug('Jira issue management (no MCP config needed)', { + provider, + contextType: contextType ?? 'auto-detect', + }) } // Generate single MCP server config From 7788d6be43c26493276d781ad32ee49dfc7a49e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 03:31:57 +0000 Subject: [PATCH 07/27] Update BitBucket integration to use API tokens instead of app passwords Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- README.md | 14 +++++++------- src/lib/SettingsManager.ts | 8 ++++---- src/lib/VCSProviderFactory.ts | 6 +++--- src/lib/providers/bitbucket/BitBucketApiClient.ts | 11 +++++++---- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 876005a5..05e79b12 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,7 @@ Choose which platform hosts your pull requests and code reviews. | **Provider** | **Setup** | **Notes** | |--------------|-----------|-----------| | **GitHub** | `gh auth login` | Default. Integrated with GitHub Issues. | -| **BitBucket** | Configure in `.iloom/settings.json` | Atlassian Cloud. Requires app password. See [BitBucket Setup](#bitbucket-setup) below. | +| **BitBucket** | Configure in `.iloom/settings.json` | Atlassian Cloud. Requires API token. See [BitBucket Setup](#bitbucket-setup) below. | ### Jira Setup @@ -476,21 +476,21 @@ To use BitBucket for pull requests, add this configuration: { "versionControl": { "bitbucket": { - "appPassword": "your-bitbucket-app-password" + "apiToken": "your-bitbucket-api-token" } } } ``` -**Generate a BitBucket App Password:** +**Generate a BitBucket API Token:** 1. Visit https://bitbucket.org/account/settings/app-passwords/ -2. Click "Create app password" +2. Click "Create API token" (Note: App passwords were deprecated September 2025) 3. Grant permissions: `repository:read`, `repository:write`, `pullrequest:read`, `pullrequest:write` -4. Copy the password to `.iloom/settings.local.json` +4. Copy the token to `.iloom/settings.local.json` **Configuration Options:** - `username`: Your BitBucket username -- `appPassword`: App password (store in settings.local.json only!) +- `apiToken`: API token (store in settings.local.json only!) - `workspace`: (Optional) BitBucket workspace, auto-detected from git remote if not provided - `repoSlug`: (Optional) Repository slug, auto-detected from git remote if not provided @@ -531,7 +531,7 @@ Use Jira for issues and BitBucket for pull requests: }, "versionControl": { "bitbucket": { - "appPassword": "your-bitbucket-app-password" + "apiToken": "your-bitbucket-api-token" } } } diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index 930b8ee6..04a48af0 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -379,10 +379,10 @@ export const IloomSettingsSchema = z.object({ .string() .min(1, 'BitBucket username cannot be empty') .describe('BitBucket username'), - appPassword: z + apiToken: z .string() .optional() - .describe('BitBucket app password. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://bitbucket.org/account/settings/app-passwords/'), + .describe('BitBucket API token. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://bitbucket.org/account/settings/app-passwords/ (Note: App passwords deprecated Sep 2025, use API tokens)'), workspace: z .string() .optional() @@ -608,10 +608,10 @@ export const IloomSettingsSchemaNoDefaults = z.object({ .string() .min(1, 'BitBucket username cannot be empty') .describe('BitBucket username'), - appPassword: z + apiToken: z .string() .optional() - .describe('BitBucket app password. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://bitbucket.org/account/settings/app-passwords/'), + .describe('BitBucket API token. SECURITY: Store in settings.local.json only, never commit to source control. Generate at: https://bitbucket.org/account/settings/app-passwords/ (Note: App passwords deprecated Sep 2025, use API tokens)'), workspace: z .string() .optional() diff --git a/src/lib/VCSProviderFactory.ts b/src/lib/VCSProviderFactory.ts index 81592f36..a85b5a3f 100644 --- a/src/lib/VCSProviderFactory.ts +++ b/src/lib/VCSProviderFactory.ts @@ -45,13 +45,13 @@ export class VCSProviderFactory { if (!bbSettings?.username) { throw new Error('BitBucket username is required. Configure versionControl.bitbucket.username in .iloom/settings.json') } - if (!bbSettings?.appPassword) { - throw new Error('BitBucket app password is required. Configure versionControl.bitbucket.appPassword in .iloom/settings.local.json') + if (!bbSettings?.apiToken) { + throw new Error('BitBucket API token is required. Configure versionControl.bitbucket.apiToken in .iloom/settings.local.json') } const bbConfig: BitBucketVCSConfig = { username: bbSettings.username, - appPassword: bbSettings.appPassword, + apiToken: bbSettings.apiToken, } if (bbSettings.workspace) { diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.ts b/src/lib/providers/bitbucket/BitBucketApiClient.ts index 138aacae..5fe7ed2b 100644 --- a/src/lib/providers/bitbucket/BitBucketApiClient.ts +++ b/src/lib/providers/bitbucket/BitBucketApiClient.ts @@ -9,7 +9,7 @@ import { getLogger } from '../../../utils/logger-context.js' */ export interface BitBucketConfig { username: string - appPassword: string // App password from BitBucket settings + apiToken: string // API token from BitBucket settings workspace?: string // Optional, can be auto-detected from git remote repoSlug?: string // Optional, can be auto-detected from git remote } @@ -67,8 +67,11 @@ export interface BitBucketRepository { /** * BitBucketApiClient provides low-level REST API access to BitBucket * - * Authentication: Basic Auth with username and app password + * Authentication: Basic Auth with username and API token * API Reference: https://developer.atlassian.com/cloud/bitbucket/rest/intro/ + * + * Note: As of September 9, 2025, BitBucket app passwords can no longer be created. + * Use API tokens with scopes instead. All existing app passwords will be disabled on June 9, 2026. */ export class BitBucketApiClient { private readonly baseUrl = 'https://api.bitbucket.org/2.0' @@ -77,8 +80,8 @@ export class BitBucketApiClient { private readonly repoSlug: string | undefined constructor(config: BitBucketConfig) { - // Create Basic Auth header - const credentials = Buffer.from(`${config.username}:${config.appPassword}`).toString('base64') + // Create Basic Auth header with API token + const credentials = Buffer.from(`${config.username}:${config.apiToken}`).toString('base64') this.authHeader = `Basic ${credentials}` this.workspace = config.workspace From 110043553a8c68ef991d4b8b0c67ed7c9f09c063 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 04:09:04 +0000 Subject: [PATCH 08/27] Implement Jira MCP provider for issue management server integration Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- DEBUGGING_MCP.md | 178 ++++++++++++++++ src/lib/providers/jira/JiraApiClient.ts | 38 ++++ src/lib/providers/jira/JiraIssueTracker.ts | 118 ++++++++++- src/mcp/IssueManagementProviderFactory.ts | 3 + src/mcp/JiraIssueManagementProvider.ts | 226 +++++++++++++++++++++ src/mcp/issue-management-server.ts | 17 +- src/utils/mcp.ts | 25 ++- 7 files changed, 599 insertions(+), 6 deletions(-) create mode 100644 DEBUGGING_MCP.md create mode 100644 src/mcp/JiraIssueManagementProvider.ts diff --git a/DEBUGGING_MCP.md b/DEBUGGING_MCP.md new file mode 100644 index 00000000..24de5a24 --- /dev/null +++ b/DEBUGGING_MCP.md @@ -0,0 +1,178 @@ +# Debugging iloom MCP Servers + +When you see "issue_management · ✘ failed" in Claude Code, it means the MCP server isn't starting properly. Here's how to debug it: + +## 1. Check MCP Server Logs + +The MCP server logs errors to stderr. To see them: + +```bash +# Navigate to your project with iloom configured +cd /path/to/your/project + +# Start a loom (this generates the MCP config) +il start YOUR-ISSUE-123 + +# The MCP server is launched by Claude Code +# Check Claude Code's output panel for errors +``` + +## 2. Test MCP Server Manually + +You can test the MCP server directly: + +```bash +# Set required environment variables for Jira +export ISSUE_PROVIDER=jira +export JIRA_HOST="https://yourcompany.atlassian.net" +export JIRA_USERNAME="your.email@company.com" +export JIRA_API_TOKEN="your-api-token" +export JIRA_PROJECT_KEY="PROJ" + +# Optional: transition mappings +export JIRA_TRANSITION_MAPPINGS='{"In Review":"Start Review"}' + +# Run the MCP server +node dist/mcp/issue-management-server.js +``` + +The server should start and output: +``` +Starting Issue Management MCP Server... +Environment validated +Issue management provider: jira +``` + +## 3. Common Issues + +### Missing Environment Variables + +**Error:** `Missing required environment variables for Jira provider: ...` + +**Solution:** Ensure all required Jira settings are in your `.iloom/settings.local.json`: + +```json +{ + "issueManagement": { + "jira": { + "apiToken": "your-api-token-here" + } + } +} +``` + +And in `.iloom/settings.json`: +```json +{ + "issueManagement": { + "provider": "jira", + "jira": { + "host": "https://yourcompany.atlassian.net", + "username": "your.email@company.com", + "projectKey": "PROJ" + } + } +} +``` + +### Invalid Provider + +**Error:** `Invalid ISSUE_PROVIDER: ... Must be 'github', 'linear', or 'jira'` + +**Solution:** Check that `issueManagement.provider` in your settings is set to one of the supported values. + +### API Authentication Failure + +**Error:** `Jira API error (401): ...` + +**Solution:** +1. Verify your Jira API token is correct +2. Generate a new token at: https://id.atlassian.com/manage-profile/security/api-tokens +3. Ensure the token has proper permissions + +**Error:** `Jira API error (403): ...` + +**Solution:** Your user account may not have permission to access the Jira project. Contact your Jira administrator. + +## 4. Check MCP Configuration + +iloom generates MCP configuration that Claude Code uses. You can inspect it: + +```bash +# Check what MCP config iloom would generate +il start --help # This won't actually start, but shows the config + +# Or manually test config generation: +node -e " +const { generateIssueManagementMcpConfig } = require('./dist/utils/mcp.js'); +const { loadSettings } = require('./dist/lib/SettingsManager.js'); + +(async () => { + const settings = await loadSettings(); + const config = await generateIssueManagementMcpConfig( + 'issue', + null, + 'jira', + settings + ); + console.log(JSON.stringify(config, null, 2)); +})(); +" +``` + +## 5. Verbose Logging + +To see more detailed logs, set the DEBUG environment variable before starting Claude Code: + +```bash +# On macOS/Linux +export DEBUG=iloom:* + +# On Windows (PowerShell) +$env:DEBUG="iloom:*" + +# Then start Claude Code +``` + +## 6. Test Jira Connection + +Test that iloom can connect to Jira: + +```bash +# This will attempt to fetch an issue +il start PROJ-123 + +# If successful, you should see: +# ✓ Issue found: PROJ-123 +``` + +## 7. Claude Code MCP Settings + +Check that Claude Code is configured to use iloom's MCP servers. The config should be in `~/.claude/settings.json` or your project's `.claude/settings.local.json`. + +Look for: +```json +{ + "mcpServers": { + "issue_management": { + "transport": "stdio", + "command": "node", + "args": ["/path/to/iloom/dist/mcp/issue-management-server.js"], + "env": { + "ISSUE_PROVIDER": "jira", + "JIRA_HOST": "...", + // ...other Jira env vars + } + } + } +} +``` + +## 8. Still Having Issues? + +If the MCP server still isn't working: + +1. **Check iloom version:** Run `il --version` and ensure you have the latest version +2. **Reinstall dependencies:** `cd /path/to/iloom && pnpm install && pnpm build` +3. **Check Node version:** MCP servers require Node.js 18 or later +4. **File an issue:** Include the full error output and your (redacted) settings diff --git a/src/lib/providers/jira/JiraApiClient.ts b/src/lib/providers/jira/JiraApiClient.ts index 1a410d6b..4bfaf527 100644 --- a/src/lib/providers/jira/JiraApiClient.ts +++ b/src/lib/providers/jira/JiraApiClient.ts @@ -173,6 +173,13 @@ export class JiraApiClient { return this.request('POST', endpoint, body) } + /** + * Make a PUT request to Jira API + */ + private async put(endpoint: string, body: unknown): Promise { + return this.request('PUT', endpoint, body) + } + /** * Fetch an issue by key (e.g., "PROJ-123") */ @@ -203,6 +210,37 @@ export class JiraApiClient { }) } + /** + * 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 + */ + async updateComment(issueKey: string, commentId: string, body: string): Promise { + return this.put(`/issue/${issueKey}/comment/${commentId}`, { + body: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: body, + }, + ], + }, + ], + }, + }) + } + /** * Get available transitions for an issue */ diff --git a/src/lib/providers/jira/JiraIssueTracker.ts b/src/lib/providers/jira/JiraIssueTracker.ts index 4da4cb66..71f47dd5 100644 --- a/src/lib/providers/jira/JiraIssueTracker.ts +++ b/src/lib/providers/jira/JiraIssueTracker.ts @@ -187,11 +187,123 @@ ${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 = String(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: this.extractTextFromADF(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 = String(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 = String(identifier) + getLogger().debug('Updating Jira comment', { issueKey, commentId }) + + await this.client.updateComment(issueKey, commentId, body) + } + + /** + * Get configuration (for MCP provider) + */ + getConfig(): JiraTrackerConfig { + return this.config + } + + /** + * Extract plain text from Atlassian Document Format (ADF) + * This is a simplified extraction - handles basic text content + */ + private extractTextFromADF(adf: unknown): string { + if (typeof adf === 'string') { + return adf + } + + if (!adf || typeof adf !== 'object' || !('content' in adf)) { + return '' + } + + const adfObj = adf as { content?: unknown[] } + const extractText = (node: unknown): string => { + if (!node || typeof node !== 'object') { + return '' + } + + const nodeObj = node as { type?: string; text?: string; content?: unknown[] } + + if (nodeObj.type === 'text') { + return nodeObj.text ?? '' + } + + if (nodeObj.content && Array.isArray(nodeObj.content)) { + return nodeObj.content.map(extractText).join('') + } + + return '' + } + + return (adfObj.content ?? []).map(extractText).join('\n') + } + /** * Map Jira API issue to generic Issue type */ - private mapJiraIssueToIssue(jiraIssue: JiraIssue): Issue { + 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 + } { return { + id: jiraIssue.id, + key: jiraIssue.key, number: jiraIssue.key, title: jiraIssue.fields.summary, body: jiraIssue.fields.description ?? '', @@ -200,7 +312,11 @@ ${entity.assignees.length > 0 ? `Assignees: ${entity.assignees.join(', ')}` : '' 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, } } diff --git a/src/mcp/IssueManagementProviderFactory.ts b/src/mcp/IssueManagementProviderFactory.ts index 384d171b..beb11cdb 100644 --- a/src/mcp/IssueManagementProviderFactory.ts +++ b/src/mcp/IssueManagementProviderFactory.ts @@ -5,6 +5,7 @@ import type { IssueManagementProvider, IssueProvider } from './types.js' import { GitHubIssueManagementProvider } from './GitHubIssueManagementProvider.js' import { LinearIssueManagementProvider } from './LinearIssueManagementProvider.js' +import { JiraIssueManagementProvider } from './JiraIssueManagementProvider.js' /** * Factory class for creating issue management providers @@ -19,6 +20,8 @@ export class IssueManagementProviderFactory { return new GitHubIssueManagementProvider() case 'linear': return new LinearIssueManagementProvider() + case 'jira': + return new JiraIssueManagementProvider() default: throw new Error(`Unsupported issue management provider: ${provider}`) } diff --git a/src/mcp/JiraIssueManagementProvider.ts b/src/mcp/JiraIssueManagementProvider.ts new file mode 100644 index 00000000..ecdb2336 --- /dev/null +++ b/src/mcp/JiraIssueManagementProvider.ts @@ -0,0 +1,226 @@ +/** + * Jira implementation of Issue Management Provider + * Uses JiraIssueTracker for all operations + * Normalizes Jira-specific fields to provider-agnostic core fields + */ + +import type { + IssueManagementProvider, + GetIssueInput, + GetCommentInput, + CreateCommentInput, + UpdateCommentInput, + CreateIssueInput, + CreateIssueResult, + IssueResult, + CommentDetailResult, + CommentResult, + FlexibleAuthor, +} from './types.js' +import { JiraIssueTracker } from '../lib/providers/jira/JiraIssueTracker.js' +import type { JiraTrackerConfig } from '../lib/providers/jira/JiraIssueTracker.js' +import type { Issue } from '../types/index.js' + +/** + * 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 }), + } +} + +/** + * Jira-specific implementation of IssueManagementProvider + */ +export class JiraIssueManagementProvider implements IssueManagementProvider { + readonly providerName = 'jira' + readonly issuePrefix = '' + private tracker: JiraIssueTracker + + constructor() { + // Read configuration from environment variables + const host = process.env.JIRA_HOST + const username = process.env.JIRA_USERNAME + const apiToken = process.env.JIRA_API_TOKEN + const projectKey = process.env.JIRA_PROJECT_KEY + + if (!host || !username || !apiToken || !projectKey) { + throw new Error( + 'Missing required Jira environment variables: JIRA_HOST, JIRA_USERNAME, JIRA_API_TOKEN, JIRA_PROJECT_KEY' + ) + } + + const config: JiraTrackerConfig = { + host, + username, + apiToken, + projectKey, + } + + // Parse transition mappings if provided + if (process.env.JIRA_TRANSITION_MAPPINGS) { + try { + config.transitionMappings = JSON.parse(process.env.JIRA_TRANSITION_MAPPINGS) + } catch (error) { + console.error('Failed to parse JIRA_TRANSITION_MAPPINGS:', error) + } + } + + this.tracker = new JiraIssueTracker(config) + } + + /** + * 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 + + // Jira doesn't distinguish between issue and PR comments + const comment = await this.tracker.addComment(number, body) + + return { + id: comment.id, + url: `${this.tracker.getConfig().host}/browse/${number}?focusedCommentId=${comment.id}`, + created_at: new Date().toISOString(), + } + } + + /** + * Update an existing comment + */ + async updateComment(input: UpdateCommentInput): Promise { + const { commentId, number, body } = input + + // Update comment via tracker + await this.tracker.updateComment(number, commentId, body) + + return { + id: commentId, + url: `${this.tracker.getConfig().host}/browse/${number}?focusedCommentId=${commentId}`, + updated_at: new Date().toISOString(), + } + } + + /** + * Create a new issue + */ + async createIssue(input: CreateIssueInput): Promise { + const { title, body } = input + + // Create issue via tracker (labels not supported in current implementation) + const issue = await this.tracker.createIssue(title, body) + + const result: CreateIssueResult = { + id: String(issue.number), + url: issue.url, + } + + // Only add number if it's actually a number + if (typeof issue.number === 'number') { + result.number = issue.number + } + + return result + } +} diff --git a/src/mcp/issue-management-server.ts b/src/mcp/issue-management-server.ts index 23a14ddd..379c6320 100644 --- a/src/mcp/issue-management-server.ts +++ b/src/mcp/issue-management-server.ts @@ -27,8 +27,8 @@ function validateEnvironment(): IssueProvider { process.exit(1) } - if (provider !== 'github' && provider !== 'linear') { - console.error(`Invalid ISSUE_PROVIDER: ${provider}. Must be 'github' or 'linear'`) + if (provider !== 'github' && provider !== 'linear' && provider !== 'jira') { + console.error(`Invalid ISSUE_PROVIDER: ${provider}. Must be 'github', 'linear', or 'jira'`) process.exit(1) } @@ -53,6 +53,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 } diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts index 2845905b..0930a8d9 100644 --- a/src/utils/mcp.ts +++ b/src/utils/mcp.ts @@ -96,10 +96,29 @@ export async function generateIssueManagementMcpConfig( contextType: contextType ?? 'auto-detect', }) } else if (provider === 'jira') { - // Jira configuration - for now, Jira doesn't use MCP server - // The JiraIssueTracker is used directly in commands - logger.debug('Jira issue management (no MCP config needed)', { + // 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', }) } From 7b60e73fd0221f230c720f782993a5665d49d7ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 04:20:40 +0000 Subject: [PATCH 09/27] Fix Jira issue body and comment body to extract plain text from ADF format Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- .husky/pre-commit | 7 +++++++ src/lib/providers/jira/JiraApiClient.ts | 4 ++-- src/lib/providers/jira/JiraIssueTracker.ts | 13 ++++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index ab37b584..edbd8e8a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,10 @@ +#!/bin/sh +# Set up PATH for pnpm if not already in PATH +if ! command -v pnpm >/dev/null 2>&1; then + export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}" + export PATH="$PNPM_HOME:$PATH" +fi + # Run linting pnpm run lint diff --git a/src/lib/providers/jira/JiraApiClient.ts b/src/lib/providers/jira/JiraApiClient.ts index 4bfaf527..21967661 100644 --- a/src/lib/providers/jira/JiraApiClient.ts +++ b/src/lib/providers/jira/JiraApiClient.ts @@ -21,7 +21,7 @@ export interface JiraIssue { key: string fields: { summary: string - description: string | null + description: string | null | unknown // Can be string, ADF object, or null status: { name: string } @@ -60,7 +60,7 @@ export interface JiraComment { emailAddress: string accountId: string } - body: string + body: string | unknown // Can be string or ADF object created: string updated: string [key: string]: unknown diff --git a/src/lib/providers/jira/JiraIssueTracker.ts b/src/lib/providers/jira/JiraIssueTracker.ts index 71f47dd5..0d8631b2 100644 --- a/src/lib/providers/jira/JiraIssueTracker.ts +++ b/src/lib/providers/jira/JiraIssueTracker.ts @@ -301,12 +301,23 @@ ${entity.assignees.length > 0 ? `Assignees: ${entity.assignees.join(', ')}` : '' issueType?: string status?: string } { + // Extract description - handle ADF format or plain string + let description = '' + if (jiraIssue.fields.description) { + if (typeof jiraIssue.fields.description === 'string') { + description = jiraIssue.fields.description + } else { + // It's an ADF object, extract text + description = this.extractTextFromADF(jiraIssue.fields.description) + } + } + return { id: jiraIssue.id, key: jiraIssue.key, number: jiraIssue.key, title: jiraIssue.fields.summary, - body: jiraIssue.fields.description ?? '', + body: description, state: jiraIssue.fields.status.name.toLowerCase() as 'open' | 'closed', labels: jiraIssue.fields.labels, assignees: jiraIssue.fields.assignee From f6ad58f5fa10efab4e36bd8c6e4d73105b76a473 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Tue, 13 Jan 2026 21:03:26 -0800 Subject: [PATCH 10/27] update get_issue tool to accept jira provider --- src/mcp/issue-management-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/issue-management-server.ts b/src/mcp/issue-management-server.ts index 379c6320..adea821e 100644 --- a/src/mcp/issue-management-server.ts +++ b/src/mcp/issue-management-server.ts @@ -104,7 +104,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( From 3b941ba7603fc2334b8d233e678eaa5a6a06e479 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Tue, 13 Jan 2026 21:05:48 -0800 Subject: [PATCH 11/27] bidirectional conversion between adf and markdown formatting for jira communication --- package.json | 1 + pnpm-lock.yaml | 721 ++++++++++++++++++ .../providers/jira/AdfMarkdownConverter.ts | 38 + src/lib/providers/jira/JiraApiClient.ts | 52 +- .../providers/jira/JiraCommentFormatter.ts | 142 ---- src/lib/providers/jira/JiraIssueTracker.ts | 50 +- src/lib/providers/jira/index.ts | 2 +- 7 files changed, 772 insertions(+), 234 deletions(-) create mode 100644 src/lib/providers/jira/AdfMarkdownConverter.ts delete mode 100644 src/lib/providers/jira/JiraCommentFormatter.ts diff --git a/package.json b/package.json index 7acf79a8..97061f3d 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "deepmerge": "^4.3.1", "dotenv-flow": "^4.1.0", "execa": "^8.0.1", + "extended-markdown-adf-parser": "^2.4.0", "fast-glob": "^3.3.3", "fs-extra": "^11.1.1", "handlebars": "^4.7.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6623a64c..ca64519e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: execa: specifier: ^8.0.1 version: 8.0.1 + extended-markdown-adf-parser: + specifier: ^2.4.0 + version: 2.4.0 fast-glob: specifier: ^3.3.3 version: 3.3.3 @@ -579,6 +582,9 @@ packages: cpu: [x64] os: [win32] + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -598,12 +604,21 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.14': resolution: {integrity: sha512-gqiKWld3YIkmtrrg9zDvg9jfksZCcPywXVN7IauUGhilwGV/yOyeUsvpR796m/Jye0zUzMXPKe8Ct1B79A7N5Q==} '@types/through@0.0.33': resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.43.0': resolution: {integrity: sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -720,9 +735,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -753,6 +779,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -811,6 +840,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -823,6 +855,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chardet@2.1.0: resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} @@ -912,6 +947,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.2.0: + resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -930,6 +968,13 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dotenv-flow@4.1.0: resolution: {integrity: sha512-0cwP9jpQBQfyHwvE0cRhraZMkdV45TQedA8AAUZMsFzvmLcQyc1HPv+oX0OOYwLFjIlvgVepQ+WuQHbqDaHJZg==} engines: {node: '>= 12.0.0'} @@ -988,6 +1033,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -1083,6 +1132,13 @@ packages: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extended-markdown-adf-parser@2.4.0: + resolution: {integrity: sha512-tTRkUUAJCCFvdnxJSGgnOoyUBdyNueeDpr2x/EhohO62/JZzQw+Wa+ZynlpbPWjONL9vdp0n2uRFxnaO02V72g==} + engines: {node: '>=20.11.1', yarn: '>=4.7.0'} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -1103,9 +1159,15 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1148,6 +1210,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1318,6 +1384,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1372,6 +1442,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1417,6 +1490,9 @@ packages: resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} engines: {node: '>=12'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1433,10 +1509,49 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.2: + resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -1455,6 +1570,93 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1712,6 +1914,25 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1960,6 +2181,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -2027,6 +2251,21 @@ packages: unfetch@4.2.0: resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -2045,6 +2284,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2169,6 +2414,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@ampproject/remapping@2.3.0': @@ -2509,6 +2757,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.1': optional: true + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + '@types/estree@1.0.8': {} '@types/fs-extra@11.0.4': @@ -2531,6 +2783,12 @@ snapshots: dependencies: '@types/node': 20.19.14 + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node@20.19.14': dependencies: undici-types: 6.21.0 @@ -2539,6 +2797,8 @@ snapshots: dependencies: '@types/node': 20.19.14 + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0)(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -2712,6 +2972,10 @@ snapshots: acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -2719,6 +2983,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -2739,6 +3010,8 @@ snapshots: assertion-error@2.0.1: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -2813,6 +3086,8 @@ snapshots: callsites@3.1.0: {} + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -2828,6 +3103,8 @@ snapshots: chalk@5.6.2: {} + character-entities@2.0.2: {} + chardet@2.1.0: {} check-error@2.1.1: {} @@ -2891,6 +3168,10 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.2.0: + dependencies: + character-entities: 2.0.2 + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -2903,6 +3184,12 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dotenv-flow@4.1.0: dependencies: dotenv: 16.6.1 @@ -2970,6 +3257,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.35.0): dependencies: eslint: 9.35.0 @@ -3112,6 +3401,22 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + + extended-markdown-adf-parser@2.4.0: + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + micromark: 4.0.2 + remark: 15.0.1 + remark-frontmatter: 5.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 @@ -3132,10 +3437,16 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 + fault@2.0.1: + dependencies: + format: 0.2.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -3184,6 +3495,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + format@0.2.2: {} + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -3343,6 +3656,8 @@ snapshots: is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} is-stream@3.0.0: {} @@ -3397,6 +3712,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} jsonc-parser@3.3.1: {} @@ -3440,6 +3757,8 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + longest-streak@3.1.0: {} + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -3458,8 +3777,123 @@ snapshots: dependencies: semver: 7.7.2 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.2.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + media-typer@1.1.0: {} memfs@4.49.0: @@ -3477,6 +3911,204 @@ snapshots: merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.2.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.2.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.2.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3698,6 +4330,52 @@ snapshots: readdirp@4.1.2: {} + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -3971,6 +4649,8 @@ snapshots: tree-kill@1.2.2: {} + trough@2.2.0: {} + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -4037,6 +4717,35 @@ snapshots: unfetch@4.2.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -4049,6 +4758,16 @@ snapshots: vary@1.1.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-node@2.1.9(@types/node@20.19.14): dependencies: cac: 6.7.14 @@ -4173,3 +4892,5 @@ snapshots: zod: 3.25.76 zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/src/lib/providers/jira/AdfMarkdownConverter.ts b/src/lib/providers/jira/AdfMarkdownConverter.ts new file mode 100644 index 00000000..09e72af4 --- /dev/null +++ b/src/lib/providers/jira/AdfMarkdownConverter.ts @@ -0,0 +1,38 @@ +// 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() + +/** + * 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: [] } + } + return parser.markdownToAdf(markdown) +} diff --git a/src/lib/providers/jira/JiraApiClient.ts b/src/lib/providers/jira/JiraApiClient.ts index 21967661..a5a11369 100644 --- a/src/lib/providers/jira/JiraApiClient.ts +++ b/src/lib/providers/jira/JiraApiClient.ts @@ -3,6 +3,7 @@ import https from 'node:https' import { getLogger } from '../../../utils/logger-context.js' +import { markdownToAdf } from './AdfMarkdownConverter.js' /** * Jira API configuration @@ -189,24 +190,11 @@ export class JiraApiClient { /** * Add a comment to an issue + * Accepts Markdown content which is converted to ADF for Jira */ async addComment(issueKey: string, body: string): Promise { return this.post(`/issue/${issueKey}/comment`, { - body: { - type: 'doc', - version: 1, - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: body, - }, - ], - }, - ], - }, + body: markdownToAdf(body), }) } @@ -220,24 +208,11 @@ export class JiraApiClient { /** * 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: { - type: 'doc', - version: 1, - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: body, - }, - ], - }, - ], - }, + body: markdownToAdf(body), }) } @@ -262,6 +237,7 @@ export class JiraApiClient { /** * 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', { @@ -270,21 +246,7 @@ export class JiraApiClient { key: projectKey, }, summary, - description: { - type: 'doc', - version: 1, - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: description, - }, - ], - }, - ], - }, + description: markdownToAdf(description), issuetype: { name: issueType, }, diff --git a/src/lib/providers/jira/JiraCommentFormatter.ts b/src/lib/providers/jira/JiraCommentFormatter.ts deleted file mode 100644 index 15423b55..00000000 --- a/src/lib/providers/jira/JiraCommentFormatter.ts +++ /dev/null @@ -1,142 +0,0 @@ -// JiraCommentFormatter - Converts Markdown to Jira Wiki Markup -// Handles common formatting patterns for posting iloom content to Jira - -/** - * JiraCommentFormatter converts Markdown content to Jira Wiki Markup format - * - * Jira Wiki Markup reference: - * https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all - * - * Supported conversions: - * - Headers (# → h1., ## → h2., etc.) - * - Bold (**text** → *text*) - * - Italic (*text* → _text_) - * - Code blocks (``` → {code}) - * - Inline code (`text` → {{text}}) - * - Links ([text](url) → [text|url]) - * - Lists (* item → * item) - */ -export class JiraCommentFormatter { - /** - * Convert Markdown content to Jira Wiki Markup - */ - static markdownToJira(markdown: string): string { - let jira = markdown - - // Convert code blocks (must be done before inline code) - // ```language\ncode\n``` → {code:language}\ncode\n{code} - jira = jira.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => { - if (lang) { - return `{code:${lang}}\n${code.trim()}\n{code}` - } - return `{code}\n${code.trim()}\n{code}` - }) - - // Convert inline code - // `code` → {{code}} - jira = jira.replace(/`([^`]+)`/g, '{{$1}}') - - // Convert headers - // # Header → h1. Header - jira = jira.replace(/^######\s+(.+)$/gm, 'h6. $1') - jira = jira.replace(/^#####\s+(.+)$/gm, 'h5. $1') - jira = jira.replace(/^####\s+(.+)$/gm, 'h4. $1') - jira = jira.replace(/^###\s+(.+)$/gm, 'h3. $1') - jira = jira.replace(/^##\s+(.+)$/gm, 'h2. $1') - jira = jira.replace(/^#\s+(.+)$/gm, 'h1. $1') - - // Convert bold (must be done before italic to handle ***) - // **text** → *text* - jira = jira.replace(/\*\*([^*]+)\*\*/g, '*$1*') - - // Convert italic - // *text* → _text_ - // _text_ → _text_ (already correct) - jira = jira.replace(/(? quote → {quote}quote{quote} - jira = jira.replace(/^>\s+(.+)$/gm, '{quote}$1{quote}') - - // Convert horizontal rules - // --- → ---- - jira = jira.replace(/^---+$/gm, '----') - - return jira - } - - /** - * Convert Jira Wiki Markup to Markdown (reverse operation) - * Useful for displaying Jira content in terminal or logs - */ - static jiraToMarkdown(jira: string): string { - let markdown = jira - - // Convert code blocks - // {code:language}\ncode\n{code} → ```language\ncode\n``` - markdown = markdown.replace(/\{code:(\w+)\}\n([\s\S]*?)\{code\}/g, '```$1\n$2```') - markdown = markdown.replace(/\{code\}\n([\s\S]*?)\{code\}/g, '```\n$1```') - - // Convert inline code - // {{code}} → `code` - markdown = markdown.replace(/\{\{([^}]+)\}\}/g, '`$1`') - - // Convert headers - // h1. Header → # Header - markdown = markdown.replace(/^h6\.\s+(.+)$/gm, '###### $1') - markdown = markdown.replace(/^h5\.\s+(.+)$/gm, '##### $1') - markdown = markdown.replace(/^h4\.\s+(.+)$/gm, '#### $1') - markdown = markdown.replace(/^h3\.\s+(.+)$/gm, '### $1') - markdown = markdown.replace(/^h2\.\s+(.+)$/gm, '## $1') - markdown = markdown.replace(/^h1\.\s+(.+)$/gm, '# $1') - - // Convert bold - // *text* → **text** - markdown = markdown.replace(/\*([^*]+)\*/g, '**$1**') - - // Convert italic - // _text_ → *text* - markdown = markdown.replace(/_([^_]+)_/g, '*$1*') - - // Convert strikethrough - // -text- → ~~text~~ - markdown = markdown.replace(/-([^-]+)-/g, '~~$1~~') - - // Convert links - // [text|url] → [text](url) - markdown = markdown.replace(/\[([^\]|]+)\|([^\]]+)\]/g, '[$1]($2)') - - // Convert ordered lists - // # item → 1. item - let listCounter = 0 - markdown = markdown.replace(/^#\s+(.+)$/gm, () => { - listCounter++ - return `${listCounter}. $1` - }) - - // Convert blockquotes - // {quote}quote{quote} → > quote - markdown = markdown.replace(/\{quote\}(.+?)\{quote\}/g, '> $1') - - // Convert horizontal rules - // ---- → --- - markdown = markdown.replace(/^----+$/gm, '---') - - return markdown - } -} diff --git a/src/lib/providers/jira/JiraIssueTracker.ts b/src/lib/providers/jira/JiraIssueTracker.ts index 0d8631b2..e5de6394 100644 --- a/src/lib/providers/jira/JiraIssueTracker.ts +++ b/src/lib/providers/jira/JiraIssueTracker.ts @@ -5,6 +5,7 @@ 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 @@ -21,7 +22,7 @@ export interface JiraTrackerConfig extends JiraConfig { * - Issue identifiers are strings (e.g., "PROJ-123") * - No issue prefix (unlike GitHub's "#") * - State changes require workflow transitions (not direct status updates) - * - Comments use Jira Wiki Markup (not Markdown) + * - Content uses Atlassian Document Format (ADF), converted to/from Markdown */ export class JiraIssueTracker implements IssueTracker { readonly providerName = 'jira' @@ -212,7 +213,7 @@ ${entity.assignees.length > 0 ? `Assignees: ${entity.assignees.join(', ')}` : '' // Map to expected format return comments.map(comment => ({ id: comment.id, - body: this.extractTextFromADF(comment.body), + body: adfToMarkdown(comment.body), author: comment.author, createdAt: comment.created, updatedAt: comment.updated, @@ -247,41 +248,6 @@ ${entity.assignees.length > 0 ? `Assignees: ${entity.assignees.join(', ')}` : '' return this.config } - /** - * Extract plain text from Atlassian Document Format (ADF) - * This is a simplified extraction - handles basic text content - */ - private extractTextFromADF(adf: unknown): string { - if (typeof adf === 'string') { - return adf - } - - if (!adf || typeof adf !== 'object' || !('content' in adf)) { - return '' - } - - const adfObj = adf as { content?: unknown[] } - const extractText = (node: unknown): string => { - if (!node || typeof node !== 'object') { - return '' - } - - const nodeObj = node as { type?: string; text?: string; content?: unknown[] } - - if (nodeObj.type === 'text') { - return nodeObj.text ?? '' - } - - if (nodeObj.content && Array.isArray(nodeObj.content)) { - return nodeObj.content.map(extractText).join('') - } - - return '' - } - - return (adfObj.content ?? []).map(extractText).join('\n') - } - /** * Map Jira API issue to generic Issue type */ @@ -302,15 +268,7 @@ ${entity.assignees.length > 0 ? `Assignees: ${entity.assignees.join(', ')}` : '' status?: string } { // Extract description - handle ADF format or plain string - let description = '' - if (jiraIssue.fields.description) { - if (typeof jiraIssue.fields.description === 'string') { - description = jiraIssue.fields.description - } else { - // It's an ADF object, extract text - description = this.extractTextFromADF(jiraIssue.fields.description) - } - } + const description = adfToMarkdown(jiraIssue.fields.description) return { id: jiraIssue.id, diff --git a/src/lib/providers/jira/index.ts b/src/lib/providers/jira/index.ts index e7354033..d6f1ae13 100644 --- a/src/lib/providers/jira/index.ts +++ b/src/lib/providers/jira/index.ts @@ -1,4 +1,4 @@ // Jira provider exports export { JiraApiClient, type JiraConfig, type JiraIssue, type JiraComment, type JiraTransition } from './JiraApiClient.js' -export { JiraCommentFormatter } from './JiraCommentFormatter.js' export { JiraIssueTracker, type JiraTrackerConfig } from './JiraIssueTracker.js' +export { adfToMarkdown, markdownToAdf } from './AdfMarkdownConverter.js' From 21c0c12cf1cec68878d18949e20f575d0c84e21d Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 07:48:28 -0800 Subject: [PATCH 12/27] fix bitbucket pr compatibility --- src/cli.ts | 1 + src/commands/finish.ts | 278 +++++++++++++----- src/lib/PRManager.ts | 2 +- src/lib/SessionSummaryService.test.ts | 10 +- src/lib/SessionSummaryService.ts | 2 +- src/lib/SettingsManager.ts | 2 + .../providers/bitbucket/BitBucketApiClient.ts | 17 +- .../bitbucket/BitBucketVCSProvider.ts | 14 +- src/mcp/IssueManagementProviderFactory.ts | 10 +- src/mcp/JiraIssueManagementProvider.ts | 40 +-- src/mcp/issue-management-server.ts | 25 +- src/types/index.ts | 1 + src/utils/remote.ts | 32 +- 13 files changed, 319 insertions(+), 115 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f080aa32..1168aef6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -538,6 +538,7 @@ program .option('-n, --dry-run', 'Preview actions without executing') .option('--pr ', 'Treat input as PR number', parseFloat) .option('--skip-build', 'Skip post-merge build verification') + .option('--skip-to-pr', 'Skip rebase/validation/commit, go directly to PR creation (debug)') .option('--no-browser', 'Skip opening PR in browser (github-pr mode only)') .option('--cleanup', 'Clean up worktree after finishing (default in local mode)') .option('--no-cleanup', 'Keep worktree after finishing') diff --git a/src/commands/finish.ts b/src/commands/finish.ts index efedf1fc..37f156d6 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())) { @@ -599,101 +600,107 @@ export class FinishCommand { worktree: GitWorktree, result: FinishResult ): Promise { - // Step 1: Rebase branch on main FIRST (Issue #344) - // This ensures validation runs against the rebased code (with latest main changes) - getLogger().info('Rebasing branch on main...') - + // Define merge options early so they're available for all code paths const mergeOptions: MergeOptions = { dryRun: options.dryRun ?? false, force: options.force ?? false, } - await this.mergeManager.rebaseOnMain(worktree.path, mergeOptions) - getLogger().success('Branch rebased successfully') - result.operations.push({ - type: 'rebase', - message: 'Branch rebased on main', - success: true, - }) - - // Step 2: Run pre-merge validations AFTER rebase (Issue #344) - // Validates code with latest main changes integrated - if (!options.dryRun) { - getLogger().info('Running pre-merge validations...') - - await this.validationRunner.runValidations(worktree.path, { - dryRun: options.dryRun ?? false, - }) - getLogger().success('All validations passed') - result.operations.push({ - type: 'validation', - message: 'Pre-merge validations passed', - success: true, - }) + // Skip rebase/validation/commit steps if --skip-to-pr flag is set (debug mode) + if (options.skipToPr) { + getLogger().info('Skipping rebase/validation/commit (--skip-to-pr flag)') } else { - getLogger().info('[DRY RUN] Would run pre-merge validations') + // Step 1: Rebase branch on main FIRST (Issue #344) + // This ensures validation runs against the rebased code (with latest main changes) + getLogger().info('Rebasing branch on main...') + + await this.mergeManager.rebaseOnMain(worktree.path, mergeOptions) + getLogger().success('Branch rebased successfully') result.operations.push({ - type: 'validation', - message: 'Would run pre-merge validations (dry-run)', + type: 'rebase', + message: 'Branch rebased on main', success: true, }) - } - // Step 3: Detect uncommitted changes AFTER validation passes - const gitStatus = await this.commitManager.detectUncommittedChanges(worktree.path) + // Step 2: Run pre-merge validations AFTER rebase (Issue #344) + // Validates code with latest main changes integrated + if (!options.dryRun) { + getLogger().info('Running pre-merge validations...') - // Step 4: Commit changes only if validation passed AND changes exist - if (gitStatus.hasUncommittedChanges) { - if (options.dryRun) { - getLogger().info('[DRY RUN] Would auto-commit uncommitted changes (validation passed)') + await this.validationRunner.runValidations(worktree.path, { + dryRun: options.dryRun ?? false, + }) + getLogger().success('All validations passed') result.operations.push({ - type: 'commit', - message: 'Would auto-commit uncommitted changes (dry-run)', + type: 'validation', + message: 'Pre-merge validations passed', success: true, }) } else { - getLogger().info('Validation passed, auto-committing uncommitted changes...') - - // Load settings to get skipVerify configuration and issuePrefix - const settings = await this.settingsManager.loadSettings(worktree.path) - const skipVerify = settings.workflows?.issue?.noVerify ?? false - const providerType = settings.issueManagement?.provider ?? 'github' - const issuePrefix = IssueManagementProviderFactory.create(providerType).issuePrefix - - const commitOptions: CommitOptions = { - dryRun: options.dryRun ?? false, - skipVerify, - issuePrefix, - } + getLogger().info('[DRY RUN] Would run pre-merge validations') + result.operations.push({ + type: 'validation', + message: 'Would run pre-merge validations (dry-run)', + success: true, + }) + } - // Only add issueNumber if it's an issue - if (parsed.type === 'issue' && parsed.number) { - commitOptions.issueNumber = parsed.number - } + // Step 3: Detect uncommitted changes AFTER validation passes + const gitStatus = await this.commitManager.detectUncommittedChanges(worktree.path) - try { - await this.commitManager.commitChanges(worktree.path, commitOptions) - getLogger().success('Changes committed successfully') + // Step 4: Commit changes only if validation passed AND changes exist + if (gitStatus.hasUncommittedChanges) { + if (options.dryRun) { + getLogger().info('[DRY RUN] Would auto-commit uncommitted changes (validation passed)') result.operations.push({ type: 'commit', - message: 'Changes committed successfully', + message: 'Would auto-commit uncommitted changes (dry-run)', success: true, }) - } catch (error) { - if (error instanceof UserAbortedCommitError) { - getLogger().info('Commit aborted by user') + } else { + getLogger().info('Validation passed, auto-committing uncommitted changes...') + + // Load settings to get skipVerify configuration and issuePrefix + const settings = await this.settingsManager.loadSettings(worktree.path) + const skipVerify = settings.workflows?.issue?.noVerify ?? false + const providerType = settings.issueManagement?.provider ?? 'github' + const issuePrefix = IssueManagementProviderFactory.create(providerType, settings).issuePrefix + + const commitOptions: CommitOptions = { + dryRun: options.dryRun ?? false, + skipVerify, + issuePrefix, + } + + // Only add issueNumber if it's an issue + if (parsed.type === 'issue' && parsed.number) { + commitOptions.issueNumber = parsed.number + } + + try { + await this.commitManager.commitChanges(worktree.path, commitOptions) + getLogger().success('Changes committed successfully') result.operations.push({ type: 'commit', - message: 'Commit aborted by user', - success: false, + message: 'Changes committed successfully', + success: true, }) - throw error // Propagate to CLI for non-zero exit + } catch (error) { + if (error instanceof UserAbortedCommitError) { + getLogger().info('Commit aborted by user') + result.operations.push({ + type: 'commit', + message: 'Commit aborted by user', + success: false, + }) + throw error // Propagate to CLI for non-zero exit + } + throw error // Re-throw other errors } - throw error // Re-throw other errors } + } else { + getLogger().debug('No uncommitted changes found') } - } else { - getLogger().debug('No uncommitted changes found') } // Step 5: Check merge mode from settings and branch workflow @@ -839,6 +846,23 @@ export class FinishCommand { return } + if (mergeBehavior.mode === 'bitbucket-pr') { + // For BitBucket, we use the VCS provider layer - NOT the issue tracker + // This allows Jira/Linear issues to create PRs in BitBucket + const { VCSProviderFactory } = await import('../lib/VCSProviderFactory.js') + const vcsProvider = VCSProviderFactory.create(settings) + + if (!vcsProvider || vcsProvider.providerName !== 'bitbucket') { + throw new Error( + `The 'bitbucket-pr' merge mode requires BitBucket VCS configuration. ` + + `Add versionControl.provider: 'bitbucket' to your settings.` + ) + } + + await this.executeBitBucketPRWorkflow(parsed, options, worktree, settings, vcsProvider, result) + return + } + // Step 6: Perform fast-forward merge getLogger().info('Performing fast-forward merge...') await this.mergeManager.performFastForwardMerge(worktree.branch, worktree.path, mergeOptions) @@ -956,7 +980,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, { @@ -1104,6 +1128,122 @@ export class FinishCommand { } } + /** + * Execute workflow for BitBucket PR creation (bitbucket-pr merge mode) + * Validates → Commits → Pushes → Creates PR → Prompts for cleanup + * + * Unlike GitHub PR workflow, this uses the VersionControlProvider abstraction + * instead of PRManager, allowing it to work with any issue tracker (Jira, Linear, etc.) + */ + private async executeBitBucketPRWorkflow( + parsed: ParsedFinishInput, + options: FinishOptions, + worktree: GitWorktree, + settings: import('../lib/SettingsManager.js').IloomSettings, + vcsProvider: import('../lib/VersionControlProvider.js').VersionControlProvider, + finishResult: FinishResult + ): Promise { + // Step 1: Push branch to origin + if (options.dryRun) { + getLogger().info('[DRY RUN] Would push branch to origin') + } else { + getLogger().info('Pushing branch to origin...') + await pushBranchToRemote(worktree.branch, worktree.path, { dryRun: false }) + getLogger().success('Branch pushed successfully') + } + + // Step 2: Generate PR title from issue if available + // Read metadata to get original issue key (preserves case for Jira/Linear IDs) + const { MetadataManager } = await import('../lib/MetadataManager.js') + const metadataManager = new MetadataManager() + const metadata = await metadataManager.readMetadata(worktree.path) + + // Use issue key from metadata (original case) or fall back to parsed.number (may be lowercase) + const issueKey = metadata?.issue_numbers?.[0] ?? parsed.number + + let prTitle = `Work from ${worktree.branch}` + if (parsed.type === 'issue' && parsed.number) { + try { + const issue = await this.issueTracker.fetchIssue(parsed.number) + + // Apply ticket prefix if enabled (default: true) + const usePrefix = settings.mergeBehavior?.prTitlePrefix; + if (usePrefix) { + prTitle = `${issueKey}: ${issue.title}` + } else { + prTitle = issue.title + } + } catch (error) { + getLogger().debug('Could not fetch issue title, using branch name', { error }) + } + } + + // Step 3: Get base branch (respects parent loom metadata for child looms) + const baseBranch = await getMergeTargetBranch(worktree.path) + + // Step 4: Check for existing PR or create new one + if (options.dryRun) { + getLogger().info('[DRY RUN] Would create BitBucket PR') + getLogger().info(` Title: ${prTitle}`) + getLogger().info(` Base: ${baseBranch}`) + finishResult.operations.push({ + type: 'pr-creation', + message: 'Would create BitBucket PR (dry-run)', + success: true, + }) + } else { + // Check for existing PR first + const existingPR = await vcsProvider.checkForExistingPR(worktree.branch, worktree.path) + + if (existingPR) { + getLogger().success(`Existing pull request: ${existingPR.url}`) + finishResult.prUrl = existingPR.url + finishResult.operations.push({ + type: 'pr-creation', + message: 'Found existing pull request', + success: true, + }) + } else { + // Generate PR body using Claude (same as GitHub workflow) + const { PRManager } = await import('../lib/PRManager.js') + const prManager = new PRManager(settings) + const prBody = await prManager.generatePRBody( + parsed.type === 'issue' ? parsed.number : undefined, + worktree.path + ) + + // Create new PR + const prUrl = await vcsProvider.createPR( + worktree.branch, + prTitle, + prBody, + baseBranch, + worktree.path + ) + getLogger().success(`Pull request created: ${prUrl}`) + finishResult.prUrl = prUrl + finishResult.operations.push({ + type: 'pr-creation', + message: 'Pull request created', + success: true, + }) + } + + // Generate session summary - posts to the ISSUE (Jira/Linear), not the PR + // For BitBucket workflows, the issue tracker (Jira/Linear) doesn't support PR comments, + // so we post to the issue where the knowledge capture belongs + await this.generateSessionSummaryIfConfigured(parsed, worktree, options) + + // Archive metadata BEFORE cleanup prompt (ensures it runs even with --no-cleanup) + const { MetadataManager } = await import('../lib/MetadataManager.js') + const metadataManager = new MetadataManager() + await metadataManager.archiveMetadata(worktree.path) + + // Interactive cleanup prompt (unless flags override) + await this.handlePRCleanupPrompt(parsed, options, worktree, finishResult) + } + } + /** * Handle cleanup prompt after PR creation * Respects --cleanup and --no-cleanup flags, otherwise prompts user diff --git a/src/lib/PRManager.ts b/src/lib/PRManager.ts index 29373e89..58fafff0 100644 --- a/src/lib/PRManager.ts +++ b/src/lib/PRManager.ts @@ -27,7 +27,7 @@ export class PRManager { */ private get issuePrefix(): string { const providerType = this.settings.issueManagement?.provider ?? 'github' - const provider = IssueManagementProviderFactory.create(providerType) + const provider = IssueManagementProviderFactory.create(providerType, this.settings) return provider.issuePrefix } diff --git a/src/lib/SessionSummaryService.test.ts b/src/lib/SessionSummaryService.test.ts index 67d5cdcb..f6fa68b3 100644 --- a/src/lib/SessionSummaryService.test.ts +++ b/src/lib/SessionSummaryService.test.ts @@ -169,7 +169,7 @@ describe('SessionSummaryService', () => { }) // Verify provider was created and comment was posted - expect(IssueManagementProviderFactory.create).toHaveBeenCalledWith('github') + expect(IssueManagementProviderFactory.create).toHaveBeenCalledWith('github', defaultSettings) expect(mockIssueProvider.createComment).toHaveBeenCalledWith({ number: '123', body: '## iloom Session Summary\n\n**Key Themes:**\n- Theme one about testing\n- Theme two about implementation\n\n### Key Insights\n- Test insight one\n- Test insight two', @@ -246,16 +246,18 @@ describe('SessionSummaryService', () => { }) it('should use correct issue management provider based on settings', async () => { - vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue({ + const mockSettingsValue: IloomSettings = { ...defaultSettings, issueManagement: { provider: 'linear', }, - }) + }; + + vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue(mockSettingsValue) await service.generateAndPostSummary(defaultInput) - expect(IssueManagementProviderFactory.create).toHaveBeenCalledWith('linear') + expect(IssueManagementProviderFactory.create).toHaveBeenCalledWith('linear', mockSettingsValue) }) it('should skip when Claude returns empty result', async () => { diff --git a/src/lib/SessionSummaryService.ts b/src/lib/SessionSummaryService.ts index 94f9af4e..e5049fea 100644 --- a/src/lib/SessionSummaryService.ts +++ b/src/lib/SessionSummaryService.ts @@ -400,7 +400,7 @@ export class SessionSummaryService { ): Promise { // Get the issue management provider from settings const providerType = (settings.issueManagement?.provider ?? 'github') as IssueProvider - const provider = IssueManagementProviderFactory.create(providerType) + const provider = IssueManagementProviderFactory.create(providerType, settings) // Apply attribution if configured const finalSummary = await this.applyAttributionWithSettings(summary, settings, worktreePath) diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index 04a48af0..33c545fc 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -400,6 +400,7 @@ export const IloomSettingsSchema = z.object({ .object({ mode: z.enum(['local', 'github-pr', 'github-draft-pr', 'bitbucket-pr']).default('local'), remote: z.string().optional(), + prTitlePrefix: z.boolean().default(true).optional().describe('Prefix PR titles with the issue number (e.g., "QLH-123: Title"). Default: true'), }) .optional() .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), github-draft-pr (create draft PR at start, mark ready on finish), or bitbucket-pr (create BitBucket PR)'), @@ -629,6 +630,7 @@ export const IloomSettingsSchemaNoDefaults = z.object({ .object({ mode: z.enum(['local', 'github-pr', 'github-draft-pr', 'bitbucket-pr']).optional(), remote: z.string().optional(), + prTitlePrefix: z.boolean().optional(), }) .optional() .describe('Merge behavior configuration: local (merge locally), github-pr (create PR), github-draft-pr (create draft PR at start, mark ready on finish), or bitbucket-pr (create BitBucket PR)'), diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.ts b/src/lib/providers/bitbucket/BitBucketApiClient.ts index 5fe7ed2b..06e09c11 100644 --- a/src/lib/providers/bitbucket/BitBucketApiClient.ts +++ b/src/lib/providers/bitbucket/BitBucketApiClient.ts @@ -187,16 +187,27 @@ export class BitBucketApiClient { /** * List open pull requests for a branch + * + * Note: BitBucket uses BBQL (BitBucket Query Language) for filtering. + * The q parameter must use the format: q=source.branch.name="branch-name" + * When using BBQL, we include state filter in the query to ensure it's applied. + * See: https://developer.atlassian.com/cloud/bitbucket/rest/intro/#filtering */ async listPullRequests( workspace: string, repoSlug: string, sourceBranch?: string ): Promise { - let endpoint = `/repositories/${workspace}/${repoSlug}/pullrequests?state=OPEN` - + let endpoint = `/repositories/${workspace}/${repoSlug}/pullrequests` + if (sourceBranch) { - endpoint += `&source.branch.name=${encodeURIComponent(sourceBranch)}` + // Use BBQL query syntax for filtering by source branch AND state + // Include state="OPEN" in the query to exclude DECLINED/MERGED/SUPERSEDED PRs + const query = `state="OPEN" AND source.branch.name="${sourceBranch}"` + endpoint += `?q=${encodeURIComponent(query)}` + } else { + // No branch filter, just filter by state + endpoint += `?state=OPEN` } const response = await this.get<{ values: BitBucketPullRequest[] }>(endpoint) diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts index d6a5c152..4c8c4ca4 100644 --- a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts @@ -69,7 +69,9 @@ export class BitBucketVCSProvider implements VersionControlProvider { ): Promise { const { workspace, repoSlug } = await this.getWorkspaceAndRepo(cwd) - getLogger().debug('Creating BitBucket PR', { workspace, repoSlug, branchName, title }) + // Log the target repository so users can verify it's correct + getLogger().info(`Creating BitBucket PR in ${workspace}/${repoSlug}`) + getLogger().debug('PR details', { branchName, title, baseBranch }) const pr = await this.client.createPullRequest( workspace, @@ -80,6 +82,16 @@ export class BitBucketVCSProvider implements VersionControlProvider { baseBranch ) + // Validate the response structure + if (!pr?.id || !pr?.links?.html?.href) { + getLogger().error('Invalid BitBucket API response', { pr }) + throw new Error( + `BitBucket API returned invalid PR response. ` + + `Expected PR with id and links.html.href, got: ${JSON.stringify(pr)}` + ) + } + + getLogger().info(`BitBucket PR #${pr.id} created successfully`) return pr.links.html.href } diff --git a/src/mcp/IssueManagementProviderFactory.ts b/src/mcp/IssueManagementProviderFactory.ts index beb11cdb..794d2a6e 100644 --- a/src/mcp/IssueManagementProviderFactory.ts +++ b/src/mcp/IssueManagementProviderFactory.ts @@ -6,6 +6,7 @@ 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 @@ -13,15 +14,20 @@ import { JiraIssueManagementProvider } from './JiraIssueManagementProvider.js' 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': - return new JiraIssueManagementProvider() + 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 index ecdb2336..47ea4ce4 100644 --- a/src/mcp/JiraIssueManagementProvider.ts +++ b/src/mcp/JiraIssueManagementProvider.ts @@ -20,6 +20,8 @@ import type { 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 @@ -43,38 +45,38 @@ export class JiraIssueManagementProvider implements IssueManagementProvider { readonly issuePrefix = '' private tracker: JiraIssueTracker - constructor() { - // Read configuration from environment variables - const host = process.env.JIRA_HOST - const username = process.env.JIRA_USERNAME - const apiToken = process.env.JIRA_API_TOKEN - const projectKey = process.env.JIRA_PROJECT_KEY + constructor(settings: IloomSettings) { + const jiraSettings = settings.issueManagement?.jira - if (!host || !username || !apiToken || !projectKey) { + if (!jiraSettings?.host || !jiraSettings?.username || !jiraSettings?.apiToken || !jiraSettings?.projectKey) { throw new Error( - 'Missing required Jira environment variables: JIRA_HOST, JIRA_USERNAME, JIRA_API_TOKEN, JIRA_PROJECT_KEY' + 'Missing required Jira settings: issueManagement.jira.{host, username, apiToken, projectKey}' ) } const config: JiraTrackerConfig = { - host, - username, - apiToken, - projectKey, + host: jiraSettings.host, + username: jiraSettings.username, + apiToken: jiraSettings.apiToken, + projectKey: jiraSettings.projectKey, } - // Parse transition mappings if provided - if (process.env.JIRA_TRANSITION_MAPPINGS) { - try { - config.transitionMappings = JSON.parse(process.env.JIRA_TRANSITION_MAPPINGS) - } catch (error) { - console.error('Failed to parse JIRA_TRANSITION_MAPPINGS:', error) - } + if (jiraSettings.transitionMappings) { + config.transitionMappings = jiraSettings.transitionMappings } this.tracker = new JiraIssueTracker(config) } + /** + * Static factory for convenience when settings aren't pre-loaded + */ + static async create(): Promise { + const settingsManager = new SettingsManager() + const settings = await settingsManager.loadSettings() + return new JiraIssueManagementProvider(settings) + } + /** * Fetch issue details using JiraIssueTracker */ diff --git a/src/mcp/issue-management-server.ts b/src/mcp/issue-management-server.ts index adea821e..e8556937 100644 --- a/src/mcp/issue-management-server.ts +++ b/src/mcp/issue-management-server.ts @@ -10,6 +10,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' import { IssueManagementProviderFactory } from './IssueManagementProviderFactory.js' +import { SettingsManager } from '../lib/SettingsManager.js' +import type { IloomSettings } from '../lib/SettingsManager.js' import type { IssueProvider, GetIssueInput, @@ -19,6 +21,9 @@ import type { CreateIssueInput, } from './types.js' +// Module-level settings loaded at startup +let settings: IloomSettings | undefined + // Validate required environment variables function validateEnvironment(): IssueProvider { const provider = process.env.ISSUE_PROVIDER as IssueProvider | undefined @@ -135,7 +140,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.getIssue({ number, includeComments }) @@ -185,7 +191,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.getComment({ commentId, number }) @@ -234,7 +241,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.createComment({ number, body, type }) @@ -283,7 +291,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.updateComment({ commentId, number, body }) @@ -335,7 +344,8 @@ server.registerTool( try { const provider = IssueManagementProviderFactory.create( - process.env.ISSUE_PROVIDER as IssueProvider + process.env.ISSUE_PROVIDER as IssueProvider, + settings ) const result = await provider.createIssue({ title, body, labels, teamKey }) @@ -363,6 +373,11 @@ server.registerTool( async function main(): Promise { console.error('Starting Issue Management MCP Server...') + // Load settings for providers that need them + const settingsManager = new SettingsManager() + settings = await settingsManager.loadSettings() + console.error('Settings loaded') + // Validate environment and get provider const provider = validateEnvironment() console.error('Environment validated') diff --git a/src/types/index.ts b/src/types/index.ts index 27d4ac50..30601067 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -181,6 +181,7 @@ export interface FinishOptions { noBrowser?: boolean // --no-browser - Skip opening PR in browser (github-pr mode only) cleanup?: boolean // --cleanup / --no-cleanup - Control worktree cleanup after finishing json?: boolean // --json - Output result as JSON + skipToPr?: boolean // --skip-to-pr - Skip rebase/validation/commit, go directly to PR creation (debug) } /** diff --git a/src/utils/remote.ts b/src/utils/remote.ts index 9f4c2ac2..31c4a223 100644 --- a/src/utils/remote.ts +++ b/src/utils/remote.ts @@ -54,23 +54,35 @@ export async function parseGitRemotes(cwd?: string): Promise { } /** - * Extract owner and repo from GitHub URL - * Supports both HTTPS and SSH formats + * Extract owner and repo from Git remote URL + * Supports both HTTPS and SSH formats for GitHub and BitBucket */ function extractOwnerRepoFromUrl(url: string): { owner: string; repo: string } | null { // Remove .git suffix if present const cleanUrl = url.replace(/\.git$/, '') - // HTTPS format: https://github.com/owner/repo - const httpsMatch = cleanUrl.match(/https?:\/\/github\.com\/([^/]+)\/([^/]+)/) - if (httpsMatch?.[1] && httpsMatch?.[2]) { - return { owner: httpsMatch[1], repo: httpsMatch[2] } + // GitHub HTTPS format: https://github.com/owner/repo + const githubHttpsMatch = cleanUrl.match(/https?:\/\/github\.com\/([^/]+)\/([^/]+)/) + if (githubHttpsMatch?.[1] && githubHttpsMatch?.[2]) { + return { owner: githubHttpsMatch[1], repo: githubHttpsMatch[2] } } - // SSH format: git@github.com:owner/repo - const sshMatch = cleanUrl.match(/git@github\.com:([^/]+)\/(.+)/) - if (sshMatch?.[1] && sshMatch?.[2]) { - return { owner: sshMatch[1], repo: sshMatch[2] } + // GitHub SSH format: git@github.com:owner/repo + const githubSshMatch = cleanUrl.match(/git@github\.com:([^/]+)\/(.+)/) + if (githubSshMatch?.[1] && githubSshMatch?.[2]) { + return { owner: githubSshMatch[1], repo: githubSshMatch[2] } + } + + // BitBucket HTTPS format: https://bitbucket.org/workspace/repo + const bitbucketHttpsMatch = cleanUrl.match(/https?:\/\/bitbucket\.org\/([^/]+)\/([^/]+)/) + if (bitbucketHttpsMatch?.[1] && bitbucketHttpsMatch?.[2]) { + return { owner: bitbucketHttpsMatch[1], repo: bitbucketHttpsMatch[2] } + } + + // BitBucket SSH format: git@bitbucket.org:workspace/repo + const bitbucketSshMatch = cleanUrl.match(/git@bitbucket\.org:([^/]+)\/(.+)/) + if (bitbucketSshMatch?.[1] && bitbucketSshMatch?.[2]) { + return { owner: bitbucketSshMatch[1], repo: bitbucketSshMatch[2] } } return null From 45ba60ba8c2fd4186c50ae988dd36f9f6a1576f0 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 08:26:27 -0800 Subject: [PATCH 13/27] fix jira issue mcp server access to config --- src/mcp/JiraIssueManagementProvider.ts | 59 +++++++++++++++++++------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/src/mcp/JiraIssueManagementProvider.ts b/src/mcp/JiraIssueManagementProvider.ts index 47ea4ce4..aef69345 100644 --- a/src/mcp/JiraIssueManagementProvider.ts +++ b/src/mcp/JiraIssueManagementProvider.ts @@ -36,25 +36,14 @@ function normalizeAuthor(author: { displayName?: string; emailAddress?: string; ...(author.accountId && { accountId: author.accountId }), } } - /** - * Jira-specific implementation of IssueManagementProvider + * Extract Jira configuration from settings (for cli usage) or environment variables (in mcp server) */ -export class JiraIssueManagementProvider implements IssueManagementProvider { - readonly providerName = 'jira' - readonly issuePrefix = '' - private tracker: JiraIssueTracker - - constructor(settings: IloomSettings) { - const jiraSettings = settings.issueManagement?.jira +const getJiraTrackerConfig = (settings: IloomSettings): JiraTrackerConfig => { + const jiraSettings = settings.issueManagement?.jira - if (!jiraSettings?.host || !jiraSettings?.username || !jiraSettings?.apiToken || !jiraSettings?.projectKey) { - throw new Error( - 'Missing required Jira settings: issueManagement.jira.{host, username, apiToken, projectKey}' - ) - } - - const config: JiraTrackerConfig = { + if (jiraSettings?.host && jiraSettings?.username && jiraSettings?.apiToken && jiraSettings?.projectKey) { + const config: JiraTrackerConfig = { host: jiraSettings.host, username: jiraSettings.username, apiToken: jiraSettings.apiToken, @@ -65,6 +54,44 @@ export class JiraIssueManagementProvider implements IssueManagementProvider { config.transitionMappings = jiraSettings.transitionMappings } + return config; + } + + if (process.env.JIRA_HOST && process.env.JIRA_USERNAME && process.env.JIRA_API_TOKEN && process.env.JIRA_PROJECT_KEY) { + const config: JiraTrackerConfig = { + host: process.env.JIRA_HOST, + username: process.env.JIRA_USERNAME, + apiToken: process.env.JIRA_API_TOKEN, + projectKey: process.env.JIRA_PROJECT_KEY, + } + + if (process.env.JIRA_TRANSITION_MAPPINGS) { + try { + config.transitionMappings = JSON.parse(process.env.JIRA_TRANSITION_MAPPINGS) + } catch { + throw new Error('Invalid JSON in JIRA_TRANSITION_MAPPINGS environment variable') + } + } + + return config + } + + throw new Error( + 'Missing required Jira settings: issueManagement.jira.{host, username, apiToken, projectKey} or corresponding environment variables' + ) +} + +/** + * Jira-specific implementation of IssueManagementProvider + */ +export class JiraIssueManagementProvider implements IssueManagementProvider { + readonly providerName = 'jira' + readonly issuePrefix = '' + private tracker: JiraIssueTracker + + constructor(settings: IloomSettings) { + const config = getJiraTrackerConfig(settings); + this.tracker = new JiraIssueTracker(config) } From 23dad7d0d8dda38de8bb0cdccf6e4bb0fdd2ac35 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 08:27:03 -0800 Subject: [PATCH 14/27] add debug flag to claude code in debud mode --- src/utils/claude.test.ts | 2 +- src/utils/claude.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils/claude.test.ts b/src/utils/claude.test.ts index f2f678f0..c1efe788 100644 --- a/src/utils/claude.test.ts +++ b/src/utils/claude.test.ts @@ -334,7 +334,7 @@ describe('claude utils', () => { expect(execa).toHaveBeenCalledWith( 'claude', - ['-p', '--output-format', 'stream-json', '--verbose', '--add-dir', '/tmp'], + ['-p', '--output-format', 'stream-json', '--verbose', '--add-dir', '/tmp', '--debug'], expect.objectContaining({ input: prompt, timeout: 0, diff --git a/src/utils/claude.ts b/src/utils/claude.ts index a06b0681..25b2e025 100644 --- a/src/utils/claude.ts +++ b/src/utils/claude.ts @@ -191,11 +191,15 @@ export async function launchClaude( if (sessionId) { args.push('--session-id', sessionId) } + const isDebugMode = logger.isDebugEnabled() + + if (isDebugMode) { + args.push('--debug') // Enable debug mode for more detailed logs + } try { if (headless) { // Headless mode: capture and return output - const isDebugMode = logger.isDebugEnabled() // Set up execa options based on debug mode const execaOptions = { From c0e8f54c0e476a3fa5790485f9e7533e154607b7 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 08:28:30 -0800 Subject: [PATCH 15/27] use issue number from metadata to preserve case which is important to jira/bitbucket --- src/commands/finish.ts | 44 ++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/commands/finish.ts b/src/commands/finish.ts index 37f156d6..12a20b49 100644 --- a/src/commands/finish.ts +++ b/src/commands/finish.ts @@ -249,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 @@ -346,6 +345,19 @@ 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) + if (metadata?.issue_numbers?.[0]) { + result.number = metadata.issue_numbers[0] + } + } + } + return result } @@ -372,16 +384,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 issue key from metadata if available (original case), otherwise use extracted (lowercase) + const originalIssueKey = 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, } @@ -401,12 +421,14 @@ export class FinishCommand { // Try to extract issue from branch name const branchIssueNumber = extractIssueNumber(currentBranch) if (branchIssueNumber !== null) { + // Use issue key from metadata if available (original case), otherwise use extracted (lowercase) + const originalIssueKey = 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, } @@ -673,6 +695,7 @@ export class FinishCommand { } // 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 } @@ -1153,14 +1176,7 @@ export class FinishCommand { } // Step 2: Generate PR title from issue if available - // Read metadata to get original issue key (preserves case for Jira/Linear IDs) - const { MetadataManager } = await import('../lib/MetadataManager.js') - const metadataManager = new MetadataManager() - const metadata = await metadataManager.readMetadata(worktree.path) - - // Use issue key from metadata (original case) or fall back to parsed.number (may be lowercase) - const issueKey = metadata?.issue_numbers?.[0] ?? parsed.number - + // Note: parsed.number already has correct case from parseInput() metadata lookup let prTitle = `Work from ${worktree.branch}` if (parsed.type === 'issue' && parsed.number) { try { @@ -1169,7 +1185,7 @@ export class FinishCommand { // Apply ticket prefix if enabled (default: true) const usePrefix = settings.mergeBehavior?.prTitlePrefix; if (usePrefix) { - prTitle = `${issueKey}: ${issue.title}` + prTitle = `${parsed.number}: ${issue.title}` } else { prTitle = issue.title } From fa233a9c5ae5d6dc59a5e9501143c09b2f5ae6d7 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 10:07:57 -0800 Subject: [PATCH 16/27] support reviewers added to bitbucket PR --- README.md | 20 ++ src/lib/SettingsManager.test.ts | 106 +++++++ src/lib/SettingsManager.ts | 8 + src/lib/VCSProviderFactory.test.ts | 254 ++++++++++++++++ src/lib/VCSProviderFactory.ts | 3 + .../bitbucket/BitBucketApiClient.test.ts | 287 ++++++++++++++++++ .../providers/bitbucket/BitBucketApiClient.ts | 98 +++++- .../bitbucket/BitBucketVCSProvider.test.ts | 268 ++++++++++++++++ .../bitbucket/BitBucketVCSProvider.ts | 52 +++- 9 files changed, 1079 insertions(+), 17 deletions(-) create mode 100644 src/lib/VCSProviderFactory.test.ts create mode 100644 src/lib/providers/bitbucket/BitBucketApiClient.test.ts create mode 100644 src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts diff --git a/README.md b/README.md index 05e79b12..43f898c3 100644 --- a/README.md +++ b/README.md @@ -493,6 +493,26 @@ To use BitBucket for pull requests, add this configuration: - `apiToken`: API token (store in settings.local.json only!) - `workspace`: (Optional) BitBucket workspace, auto-detected from git remote if not provided - `repoSlug`: (Optional) Repository slug, auto-detected from git remote if not provided +- `reviewers`: (Optional) Array of email addresses to automatically add as PR reviewers. Emails are resolved to BitBucket account IDs at PR creation time. Unresolved emails are logged as warnings but don't block PR creation. + +**Example with Reviewers:** +```json +{ + "versionControl": { + "provider": "bitbucket", + "bitbucket": { + "username": "your-bitbucket-username", + "reviewers": [ + "alice@yourcompany.com", + "bob@yourcompany.com" + ] + } + }, + "mergeBehavior": { + "mode": "bitbucket-pr" + } +} +``` ### Jira + BitBucket Together diff --git a/src/lib/SettingsManager.test.ts b/src/lib/SettingsManager.test.ts index a187a458..82574719 100644 --- a/src/lib/SettingsManager.test.ts +++ b/src/lib/SettingsManager.test.ts @@ -2612,6 +2612,112 @@ const error: { code?: string; message: string } = { }) }) + describe('bitbucket reviewers configuration', () => { + it('should accept valid email addresses in reviewers array', async () => { + const projectRoot = '/test/project' + const validSettings = { + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice@example.com', 'bob@company.org'], + }, + }, + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(validSettings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + const result = await settingsManager.loadSettings(projectRoot) + expect(result.versionControl?.bitbucket?.reviewers).toEqual(['alice@example.com', 'bob@company.org']) + }) + + it('should reject invalid email addresses in reviewers array', async () => { + const projectRoot = '/test/project' + const invalidSettings = { + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['not-an-email', 'alice@example.com'], + }, + }, + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(invalidSettings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + await expect(settingsManager.loadSettings(projectRoot)).rejects.toThrow( + /Reviewer must be a valid email address/ + ) + }) + + it('should allow empty reviewers array', async () => { + const projectRoot = '/test/project' + const validSettings = { + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: 'testuser', + apiToken: 'test-token', + reviewers: [], + }, + }, + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(validSettings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + const result = await settingsManager.loadSettings(projectRoot) + expect(result.versionControl?.bitbucket?.reviewers).toEqual([]) + }) + + it('should allow missing reviewers field', async () => { + const projectRoot = '/test/project' + const validSettings = { + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: 'testuser', + apiToken: 'test-token', + }, + }, + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(validSettings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + const result = await settingsManager.loadSettings(projectRoot) + expect(result.versionControl?.bitbucket?.reviewers).toBeUndefined() + }) + }) + describe('getSpinModel', () => { it('should return opus by default when spin not configured', () => { const settings = { sourceEnvOnStart: false } diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index 33c545fc..d2040cfa 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -391,6 +391,10 @@ export const IloomSettingsSchema = z.object({ .string() .optional() .describe('BitBucket repository slug (optional, auto-detected from git remote if not provided)'), + reviewers: z + .array(z.string().email('Reviewer must be a valid email address')) + .optional() + .describe('List of email addresses to add as PR reviewers. Emails are resolved to Bitbucket account IDs at PR creation time.'), }) .optional(), }) @@ -621,6 +625,10 @@ export const IloomSettingsSchemaNoDefaults = z.object({ .string() .optional() .describe('BitBucket repository slug (optional, auto-detected from git remote if not provided)'), + reviewers: z + .array(z.string().email('Reviewer must be a valid email address')) + .optional() + .describe('List of email addresses to add as PR reviewers. Emails are resolved to Bitbucket account IDs at PR creation time.'), }) .optional(), }) diff --git a/src/lib/VCSProviderFactory.test.ts b/src/lib/VCSProviderFactory.test.ts new file mode 100644 index 00000000..000e7761 --- /dev/null +++ b/src/lib/VCSProviderFactory.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { VCSProviderFactory } from './VCSProviderFactory.js' +import { BitBucketVCSProvider } from './providers/bitbucket/index.js' +import type { IloomSettings } from './SettingsManager.js' + +// Mock the BitBucketVCSProvider +vi.mock('./providers/bitbucket/index.js', () => ({ + BitBucketVCSProvider: vi.fn().mockImplementation((config) => ({ + providerName: 'bitbucket', + config, + })), +})) + +// Mock the logger +vi.mock('../utils/logger-context.js', () => ({ + getLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +describe('VCSProviderFactory', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('create', () => { + it('should return null for github provider', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'github', + }, + } + + const result = VCSProviderFactory.create(settings) + expect(result).toBeNull() + }) + + it('should return null when no provider is configured', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + } + + const result = VCSProviderFactory.create(settings) + expect(result).toBeNull() + }) + + it('should create BitBucketVCSProvider with basic config', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: 'testuser', + apiToken: 'test-token', + }, + }, + } + + VCSProviderFactory.create(settings) + + expect(BitBucketVCSProvider).toHaveBeenCalledWith({ + username: 'testuser', + apiToken: 'test-token', + }) + }) + + it('should pass workspace and repoSlug when configured', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: 'testuser', + apiToken: 'test-token', + workspace: 'my-workspace', + repoSlug: 'my-repo', + }, + }, + } + + VCSProviderFactory.create(settings) + + expect(BitBucketVCSProvider).toHaveBeenCalledWith({ + username: 'testuser', + apiToken: 'test-token', + workspace: 'my-workspace', + repoSlug: 'my-repo', + }) + }) + + it('should pass reviewers when configured', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice@example.com', 'bob@example.com'], + }, + }, + } + + VCSProviderFactory.create(settings) + + expect(BitBucketVCSProvider).toHaveBeenCalledWith({ + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice@example.com', 'bob@example.com'], + }) + }) + + it('should pass all config options together', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: 'testuser', + apiToken: 'test-token', + workspace: 'my-workspace', + repoSlug: 'my-repo', + reviewers: ['alice@example.com'], + }, + }, + } + + VCSProviderFactory.create(settings) + + expect(BitBucketVCSProvider).toHaveBeenCalledWith({ + username: 'testuser', + apiToken: 'test-token', + workspace: 'my-workspace', + repoSlug: 'my-repo', + reviewers: ['alice@example.com'], + }) + }) + + it('should throw when bitbucket username is missing', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: '', + apiToken: 'test-token', + }, + }, + } + + expect(() => VCSProviderFactory.create(settings)).toThrow( + 'BitBucket username is required' + ) + }) + + it('should throw when bitbucket apiToken is missing', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'bitbucket', + bitbucket: { + username: 'testuser', + }, + }, + } + + expect(() => VCSProviderFactory.create(settings)).toThrow( + 'BitBucket API token is required' + ) + }) + }) + + describe('isConfigured', () => { + it('should return true for bitbucket provider', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'bitbucket', + }, + } + + expect(VCSProviderFactory.isConfigured(settings)).toBe(true) + }) + + it('should return false for github provider', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'github', + }, + } + + expect(VCSProviderFactory.isConfigured(settings)).toBe(false) + }) + + it('should return false when no provider is configured', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + } + + expect(VCSProviderFactory.isConfigured(settings)).toBe(false) + }) + }) + + describe('getProviderName', () => { + it('should return bitbucket when configured', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'bitbucket', + }, + } + + expect(VCSProviderFactory.getProviderName(settings)).toBe('bitbucket') + }) + + it('should return github when configured', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + versionControl: { + provider: 'github', + }, + } + + expect(VCSProviderFactory.getProviderName(settings)).toBe('github') + }) + + it('should return undefined when no provider is configured', () => { + const settings: IloomSettings = { + sourceEnvOnStart: false, + attribution: 'upstreamOnly', + } + + expect(VCSProviderFactory.getProviderName(settings)).toBeUndefined() + }) + }) +}) diff --git a/src/lib/VCSProviderFactory.ts b/src/lib/VCSProviderFactory.ts index a85b5a3f..871a05dc 100644 --- a/src/lib/VCSProviderFactory.ts +++ b/src/lib/VCSProviderFactory.ts @@ -60,6 +60,9 @@ export class VCSProviderFactory { if (bbSettings.repoSlug) { bbConfig.repoSlug = bbSettings.repoSlug } + if (bbSettings.reviewers) { + bbConfig.reviewers = bbSettings.reviewers + } getLogger().debug(`VCSProviderFactory: Creating BitBucketVCSProvider for user: ${bbSettings.username}`) return new BitBucketVCSProvider(bbConfig) diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.test.ts b/src/lib/providers/bitbucket/BitBucketApiClient.test.ts new file mode 100644 index 00000000..82e22a3f --- /dev/null +++ b/src/lib/providers/bitbucket/BitBucketApiClient.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { BitBucketApiClient, type BitBucketConfig } from './BitBucketApiClient.js' + +// Mock the https module +vi.mock('node:https', () => ({ + default: { + request: vi.fn(), + }, +})) + +// Mock the logger +vi.mock('../../../utils/logger-context.js', () => ({ + getLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +describe('BitBucketApiClient', () => { + let client: BitBucketApiClient + const config: BitBucketConfig = { + username: 'testuser', + apiToken: 'test-api-token', + workspace: 'test-workspace', + repoSlug: 'test-repo', + } + + beforeEach(() => { + client = new BitBucketApiClient(config) + }) + + describe('createPullRequest', () => { + it('should include reviewers in payload when provided', async () => { + const https = await import('node:https') + let capturedPayload: string | undefined + + // Mock the request to capture the payload + vi.mocked(https.default.request).mockImplementation((options, callback) => { + const mockResponse = { + statusCode: 201, + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + })) + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: (data: string) => { capturedPayload = data }, + end: vi.fn(), + } + }) + + await client.createPullRequest( + 'workspace', + 'repo', + 'Test PR', + 'Test description', + 'feature-branch', + 'main', + ['account-id-1', 'account-id-2'] + ) + + expect(capturedPayload).toBeDefined() + const payload = JSON.parse(capturedPayload!) + expect(payload.reviewers).toEqual([ + { account_id: 'account-id-1' }, + { account_id: 'account-id-2' }, + ]) + }) + + it('should not include reviewers in payload when not provided', async () => { + const https = await import('node:https') + let capturedPayload: string | undefined + + vi.mocked(https.default.request).mockImplementation((options, callback) => { + const mockResponse = { + statusCode: 201, + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + })) + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: (data: string) => { capturedPayload = data }, + end: vi.fn(), + } + }) + + await client.createPullRequest( + 'workspace', + 'repo', + 'Test PR', + 'Test description', + 'feature-branch', + 'main' + ) + + expect(capturedPayload).toBeDefined() + const payload = JSON.parse(capturedPayload!) + expect(payload.reviewers).toBeUndefined() + }) + + it('should not include reviewers when array is empty', async () => { + const https = await import('node:https') + let capturedPayload: string | undefined + + vi.mocked(https.default.request).mockImplementation((options, callback) => { + const mockResponse = { + statusCode: 201, + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + })) + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: (data: string) => { capturedPayload = data }, + end: vi.fn(), + } + }) + + await client.createPullRequest( + 'workspace', + 'repo', + 'Test PR', + 'Test description', + 'feature-branch', + 'main', + [] + ) + + expect(capturedPayload).toBeDefined() + const payload = JSON.parse(capturedPayload!) + expect(payload.reviewers).toBeUndefined() + }) + }) + + describe('findUsersByEmail', () => { + it('should return map of email to account_id for matched users', async () => { + const https = await import('node:https') + + vi.mocked(https.default.request).mockImplementation((options, callback) => { + const mockResponse = { + statusCode: 200, + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ + values: [ + { user: { account_id: 'acc-1', display_name: 'Alice Test', uuid: 'uuid-1', nickname: 'alice' } }, + { user: { account_id: 'acc-2', display_name: 'Bob Example', uuid: 'uuid-2', nickname: 'bob' } }, + ], + })) + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } + }) + + const result = await client.findUsersByEmail('workspace', ['alice@example.com', 'bob@example.com']) + + expect(result.get('alice@example.com')).toBe('acc-1') + expect(result.get('bob@example.com')).toBe('acc-2') + }) + + it('should return empty map when no users match', async () => { + const https = await import('node:https') + + vi.mocked(https.default.request).mockImplementation((options, callback) => { + const mockResponse = { + statusCode: 200, + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ + values: [ + { user: { account_id: 'acc-1', display_name: 'Charlie Different', uuid: 'uuid-1', nickname: 'charlie' } }, + ], + })) + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } + }) + + const result = await client.findUsersByEmail('workspace', ['alice@example.com']) + + expect(result.size).toBe(0) + }) + + it('should handle API errors gracefully', async () => { + const https = await import('node:https') + + vi.mocked(https.default.request).mockImplementation((options, callback) => { + const mockResponse = { + statusCode: 403, + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ error: { message: 'Access denied' } })) + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } + }) + + // Should not throw, just return empty map + const result = await client.findUsersByEmail('workspace', ['alice@example.com']) + expect(result.size).toBe(0) + }) + }) + + describe('getWorkspace', () => { + it('should return configured workspace', () => { + expect(client.getWorkspace()).toBe('test-workspace') + }) + }) + + describe('getRepoSlug', () => { + it('should return configured repoSlug', () => { + expect(client.getRepoSlug()).toBe('test-repo') + }) + }) +}) diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.ts b/src/lib/providers/bitbucket/BitBucketApiClient.ts index 06e09c11..85cb42bc 100644 --- a/src/lib/providers/bitbucket/BitBucketApiClient.ts +++ b/src/lib/providers/bitbucket/BitBucketApiClient.ts @@ -46,6 +46,19 @@ export interface BitBucketPullRequest { [key: string]: unknown } +/** + * BitBucket workspace member response from API + * Used for resolving email addresses to account IDs + */ +export interface BitBucketWorkspaceMember { + user: { + account_id: string + display_name: string + uuid: string + nickname?: string + } +} + /** * BitBucket repository response from API */ @@ -223,24 +236,32 @@ export class BitBucketApiClient { title: string, description: string, sourceBranch: string, - destinationBranch: string + destinationBranch: string, + reviewerAccountIds?: string[] ): Promise { - return this.post( - `/repositories/${workspace}/${repoSlug}/pullrequests`, - { - title, - description, - source: { - branch: { - name: sourceBranch, - }, + const payload: Record = { + title, + description, + source: { + branch: { + name: sourceBranch, }, - destination: { - branch: { - name: destinationBranch, - }, + }, + destination: { + branch: { + name: destinationBranch, }, - } + }, + } + + // Add reviewers if provided + if (reviewerAccountIds && reviewerAccountIds.length > 0) { + payload.reviewers = reviewerAccountIds.map(id => ({ account_id: id })) + } + + return this.post( + `/repositories/${workspace}/${repoSlug}/pullrequests`, + payload ) } @@ -263,6 +284,53 @@ export class BitBucketApiClient { ) } + /** + * Find workspace members by email addresses + * Returns a map of email -> account_id for resolved emails + * + * Note: BitBucket API requires querying members individually since the BBQL + * user.email field is not available in workspace members endpoint. + * We use the user email lookup endpoint instead. + */ + async findUsersByEmail( + workspace: string, + emails: string[] + ): Promise> { + const result = new Map() + + // Query each email individually using the users endpoint + // The workspace members endpoint doesn't expose email in searchable fields + for (const email of emails) { + try { + // Use the workspace members endpoint with a search query + // The API supports searching by nickname which often matches email prefix + const endpoint = `/workspaces/${workspace}/members` + const response = await this.get<{ values: BitBucketWorkspaceMember[] }>(endpoint) + + // Find member whose nickname or display_name contains the email prefix + // This is a best-effort match since email is not directly exposed + const emailPrefix = email.split('@')[0]?.toLowerCase() + if (!emailPrefix) continue + + const member = response.values.find(m => + m.user.nickname?.toLowerCase() === emailPrefix || + m.user.display_name.toLowerCase().includes(emailPrefix) + ) + + if (member) { + result.set(email, member.user.account_id) + getLogger().debug(`Resolved reviewer email ${email} to account ID ${member.user.account_id}`) + } else { + getLogger().warn(`Could not resolve reviewer email ${email} to a BitBucket account ID`) + } + } catch (error) { + getLogger().warn(`Failed to resolve reviewer email ${email}`, { error }) + } + } + + return result + } + /** * Test connection to BitBucket API */ diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts new file mode 100644 index 00000000..59388c98 --- /dev/null +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { BitBucketVCSProvider, type BitBucketVCSConfig } from './BitBucketVCSProvider.js' +import { BitBucketApiClient } from './BitBucketApiClient.js' + +// Mock the BitBucketApiClient +vi.mock('./BitBucketApiClient.js', () => ({ + BitBucketApiClient: vi.fn().mockImplementation(() => ({ + getWorkspace: vi.fn().mockReturnValue('test-workspace'), + getRepoSlug: vi.fn().mockReturnValue('test-repo'), + createPullRequest: vi.fn(), + findUsersByEmail: vi.fn(), + listPullRequests: vi.fn(), + getPullRequest: vi.fn(), + addPRComment: vi.fn(), + })), +})) + +// Mock the logger +vi.mock('../../../utils/logger-context.js', () => ({ + getLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +// Mock the remote parser +vi.mock('../../../utils/remote.js', () => ({ + parseGitRemotes: vi.fn().mockResolvedValue([]), +})) + +describe('BitBucketVCSProvider', () => { + let provider: BitBucketVCSProvider + let mockClient: { + getWorkspace: ReturnType + getRepoSlug: ReturnType + createPullRequest: ReturnType + findUsersByEmail: ReturnType + listPullRequests: ReturnType + getPullRequest: ReturnType + addPRComment: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + // Get the mock client instance + mockClient = { + getWorkspace: vi.fn().mockReturnValue('test-workspace'), + getRepoSlug: vi.fn().mockReturnValue('test-repo'), + createPullRequest: vi.fn(), + findUsersByEmail: vi.fn(), + listPullRequests: vi.fn(), + getPullRequest: vi.fn(), + addPRComment: vi.fn(), + } + vi.mocked(BitBucketApiClient).mockImplementation(() => mockClient as unknown as BitBucketApiClient) + }) + + describe('createPR with reviewers', () => { + it('should resolve reviewer emails and pass account IDs to createPullRequest', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice@example.com', 'bob@example.com'], + } + provider = new BitBucketVCSProvider(config) + + // Mock email resolution + mockClient.findUsersByEmail.mockResolvedValue( + new Map([ + ['alice@example.com', 'acc-alice'], + ['bob@example.com', 'acc-bob'], + ]) + ) + + // Mock PR creation + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + description: 'Test body', + state: 'OPEN', + author: { display_name: 'Test', uuid: 'uuid' }, + source: { branch: { name: 'feature' } }, + destination: { branch: { name: 'main' } }, + created_on: '2024-01-01', + updated_on: '2024-01-01', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + const url = await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // Verify findUsersByEmail was called with the configured emails + expect(mockClient.findUsersByEmail).toHaveBeenCalledWith( + 'test-workspace', + ['alice@example.com', 'bob@example.com'] + ) + + // Verify createPullRequest was called with resolved account IDs + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + ['acc-alice', 'acc-bob'] + ) + + expect(url).toBe('https://bitbucket.org/test/pr/123') + }) + + it('should continue with partial reviewers when some emails cannot be resolved', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice@example.com', 'unknown@example.com'], + } + provider = new BitBucketVCSProvider(config) + + // Only alice resolves + mockClient.findUsersByEmail.mockResolvedValue( + new Map([['alice@example.com', 'acc-alice']]) + ) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // Should only pass the resolved reviewer + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + ['acc-alice'] + ) + }) + + it('should not pass reviewers when no emails can be resolved', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['unknown@example.com'], + } + provider = new BitBucketVCSProvider(config) + + // No emails resolve + mockClient.findUsersByEmail.mockResolvedValue(new Map()) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // Should pass empty array for reviewers + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + [] + ) + }) + + it('should not resolve reviewers when none are configured', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + // No reviewers configured + } + provider = new BitBucketVCSProvider(config) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // findUsersByEmail should not be called + expect(mockClient.findUsersByEmail).not.toHaveBeenCalled() + + // createPullRequest should be called without reviewers + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + undefined + ) + }) + + it('should not resolve reviewers when array is empty', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: [], + } + provider = new BitBucketVCSProvider(config) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // findUsersByEmail should not be called + expect(mockClient.findUsersByEmail).not.toHaveBeenCalled() + + // createPullRequest should be called without reviewers + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + undefined + ) + }) + }) + + describe('provider properties', () => { + it('should have correct provider name', () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + } + provider = new BitBucketVCSProvider(config) + expect(provider.providerName).toBe('bitbucket') + }) + + it('should not support draft PRs', () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + } + provider = new BitBucketVCSProvider(config) + expect(provider.supportsDraftPRs).toBe(false) + }) + + it('should support forks', () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + } + provider = new BitBucketVCSProvider(config) + expect(provider.supportsForks).toBe(true) + }) + }) +}) diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts index 4c8c4ca4..a7d6fc27 100644 --- a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts @@ -11,7 +11,9 @@ import { parseGitRemotes } from '../../../utils/remote.js' * BitBucket-specific configuration * Extends BitBucketConfig with username, appPassword, workspace, and repoSlug */ -export type BitBucketVCSConfig = BitBucketConfig +export interface BitBucketVCSConfig extends BitBucketConfig { + reviewers?: string[] // Email addresses of reviewers to add to PRs +} /** * BitBucketVCSProvider implements VersionControlProvider for BitBucket @@ -27,9 +29,13 @@ export class BitBucketVCSProvider implements VersionControlProvider { readonly supportsDraftPRs = false // BitBucket doesn't have draft PRs private readonly client: BitBucketApiClient + private readonly reviewerEmails?: string[] constructor(config: BitBucketVCSConfig) { this.client = new BitBucketApiClient(config) + if (config.reviewers) { + this.reviewerEmails = config.reviewers + } } /** @@ -73,13 +79,20 @@ export class BitBucketVCSProvider implements VersionControlProvider { getLogger().info(`Creating BitBucket PR in ${workspace}/${repoSlug}`) getLogger().debug('PR details', { branchName, title, baseBranch }) + // Resolve reviewer emails to account IDs if configured + let reviewerIds: string[] | undefined + if (this.reviewerEmails && this.reviewerEmails.length > 0) { + reviewerIds = await this.resolveReviewerEmails(workspace, this.reviewerEmails) + } + const pr = await this.client.createPullRequest( workspace, repoSlug, title, body, branchName, - baseBranch + baseBranch, + reviewerIds ) // Validate the response structure @@ -187,6 +200,41 @@ export class BitBucketVCSProvider implements VersionControlProvider { return { workspace, repoSlug } } + /** + * Resolve reviewer email addresses to BitBucket account IDs + * Warns for any emails that cannot be resolved but continues with partial list + */ + private async resolveReviewerEmails(workspace: string, emails: string[]): Promise { + getLogger().debug(`Resolving ${emails.length} reviewer email(s) to BitBucket account IDs`) + + const emailToAccountId = await this.client.findUsersByEmail(workspace, emails) + + const resolvedIds: string[] = [] + const unresolvedEmails: string[] = [] + + for (const email of emails) { + const accountId = emailToAccountId.get(email) + if (accountId) { + resolvedIds.push(accountId) + } else { + unresolvedEmails.push(email) + } + } + + if (unresolvedEmails.length > 0) { + getLogger().warn( + `Could not resolve ${unresolvedEmails.length} reviewer email(s) to BitBucket account IDs: ${unresolvedEmails.join(', ')}. ` + + `These reviewers will not be added to the PR.` + ) + } + + if (resolvedIds.length > 0) { + getLogger().info(`Resolved ${resolvedIds.length} reviewer(s) for PR`) + } + + return resolvedIds + } + /** * Map BitBucket PR to generic PullRequest type */ From d720e58b3b9247bcefc817e3610ea0687740c021 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 11:35:08 -0800 Subject: [PATCH 17/27] fix markdown to adf conversion and code mark exclusivity --- .../jira/AdfMarkdownConverter.test.ts | 663 ++++++++++++++++++ .../providers/jira/AdfMarkdownConverter.ts | 82 ++- src/lib/providers/jira/JiraApiClient.ts | 4 +- 3 files changed, 747 insertions(+), 2 deletions(-) create mode 100644 src/lib/providers/jira/AdfMarkdownConverter.test.ts diff --git a/src/lib/providers/jira/AdfMarkdownConverter.test.ts b/src/lib/providers/jira/AdfMarkdownConverter.test.ts new file mode 100644 index 00000000..5643a3dc --- /dev/null +++ b/src/lib/providers/jira/AdfMarkdownConverter.test.ts @@ -0,0 +1,663 @@ +import { describe, test, expect } from 'vitest' +import { convertDetailsToExpandSyntax, markdownToAdf } from './AdfMarkdownConverter.js' + +// Type definition for ADF nodes used in tests +interface AdfNode { + type: string + content?: AdfNode[] + marks?: Array<{ type: string; attrs?: Record }> + text?: string + attrs?: Record +} + +// Helper function to find text nodes with code marks in ADF tree +function findTextNodesWithCodeMark(node: AdfNode): AdfNode[] { + const results: AdfNode[] = [] + + if (node.type === 'text' && node.marks?.some((mark) => mark.type === 'code')) { + results.push(node) + } + + if (node.content && Array.isArray(node.content)) { + for (const child of node.content) { + results.push(...findTextNodesWithCodeMark(child)) + } + } + + return results +} + +describe('AdfMarkdownConverter', () => { + describe('convertDetailsToExpandSyntax', () => { + test('converts basic details/summary block', () => { + const input = `
+Header +CONTENT +
` + + const expected = `~~~expand title="Header" +CONTENT +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles multiple details blocks', () => { + const input = `First block: +
+First Header +First content +
+ +Some text in between + +
+Second Header +Second content +
` + + const expected = `First block: +~~~expand title="First Header" +First content +~~~ + +Some text in between + +~~~expand title="Second Header" +Second content +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles extra whitespace before and after content', () => { + const input = `
+Header + + +Content with extra newlines + + +
` + + const expected = `~~~expand title="Header" +Content with extra newlines +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles empty content', () => { + const input = `
+Header +
` + + const expected = `~~~expand title="Header" +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles content with code blocks', () => { + const input = `
+Error Details + +\`\`\`typescript +const error = new Error('test') +console.log(error) +\`\`\` + +
` + + const expected = `~~~expand title="Error Details" +\`\`\`typescript +const error = new Error('test') +console.log(error) +\`\`\` +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles nested details blocks (2-level)', () => { + const input = `
+Outer Header + +This is some outer content + +
+Inner Header +This is some inner content +
+ +
` + + const expected = `~~~expand title="Outer Header" +This is some outer content + +~~~expand title="Inner Header" +This is some inner content +~~~ +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles nested details blocks (3-level)', () => { + const input = `
+Level 1 + +Content at level 1 + +
+Level 2 + +Content at level 2 + +
+Level 3 +Content at level 3 +
+ +
+ +
` + + const expected = `~~~expand title="Level 1" +Content at level 1 + +~~~expand title="Level 2" +Content at level 2 + +~~~expand title="Level 3" +Content at level 3 +~~~ +~~~ +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles mixed nested and non-nested blocks', () => { + const input = `
+First Block +Simple content +
+ +Some text in between + +
+Nested Block + +Outer content + +
+Inner Block +Inner content +
+ +
` + + const expected = `~~~expand title="First Block" +Simple content +~~~ + +Some text in between + +~~~expand title="Nested Block" +Outer content + +~~~expand title="Inner Block" +Inner content +~~~ +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles details tag with attributes', () => { + const input = `
+Expanded by Default +Content here +
` + + const expected = `~~~expand title="Expanded by Default" +Content here +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles summary tag with attributes', () => { + const input = `
+Header +Content here +
` + + const expected = `~~~expand title="Header" +Content here +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles HTML entities in summary', () => { + const input = `
+<Component> Details +Content here +
` + + const expected = `~~~expand title=" Details" +Content here +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles case-insensitive HTML tags', () => { + const input = `
+Header +Content +
` + + const expected = `~~~expand title="Header" +Content +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('returns original text if no details blocks', () => { + const input = `Just some regular text +with multiple lines +and no details blocks` + + expect(convertDetailsToExpandSyntax(input)).toBe(input) + }) + + test('returns empty string for empty input', () => { + expect(convertDetailsToExpandSyntax('')).toBe('') + }) + + test('returns null for null input', () => { + expect(convertDetailsToExpandSyntax(null as unknown as string)).toBe(null) + }) + + test('returns undefined for undefined input', () => { + expect(convertDetailsToExpandSyntax(undefined as unknown as string)).toBe(undefined) + }) + + test('handles malformed HTML gracefully - missing closing tag', () => { + const input = '
HeaderContent' // Missing closing tag + + // Should not throw, just return original text + expect(() => convertDetailsToExpandSyntax(input)).not.toThrow() + expect(convertDetailsToExpandSyntax(input)).toBe(input) + }) + + test('handles malformed HTML gracefully - missing summary tag', () => { + const input = '
Content without summary
' + + // Should not throw, just return original text + expect(() => convertDetailsToExpandSyntax(input)).not.toThrow() + expect(convertDetailsToExpandSyntax(input)).toBe(input) + }) + + test('handles unicode characters', () => { + const input = `
+Unicode Test 🚀 +Content with émojis 🎉 and àccénts +
` + + const expected = `~~~expand title="Unicode Test 🚀" +Content with émojis 🎉 and àccénts +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('real-world workflow example', () => { + const input = `## Implementation Progress + +
+📋 Complete Context & Details (click to expand) + +### Phase 1: Setup +- [x] Create files +- [x] Write tests + +### Phase 2: Testing +- [ ] Run tests + +
+ +Last updated: 2025-01-16` + + const expected = `## Implementation Progress + +~~~expand title="📋 Complete Context & Details (click to expand)" +### Phase 1: Setup +- [x] Create files +- [x] Write tests + +### Phase 2: Testing +- [ ] Run tests +~~~ + +Last updated: 2025-01-16` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('normalizes excessive blank lines in content', () => { + const input = `
+Header + +Content line 1 + + + +Content line 2 + + + + +Content line 3 + +
` + + const expected = `~~~expand title="Header" +Content line 1 + +Content line 2 + +Content line 3 +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles special characters in content', () => { + const input = `
+Special Chars +!@#$%^&*()_+-=[]{}|;':",./<>? +
` + + const expected = `~~~expand title="Special Chars" +!@#$%^&*()_+-=[]{}|;':",./<>? +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles content with HTML tags that should be preserved', () => { + const input = `
+HTML Content +Some text with bold and italic +
` + + const expected = `~~~expand title="HTML Content" +Some text with bold and italic +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + + test('handles extremely long content', () => { + const longContent = 'Line\n'.repeat(1000) + const input = `
+Long Content +${longContent} +
` + + const result = convertDetailsToExpandSyntax(input) + expect(result).toContain('~~~expand title="Long Content"') + expect(result).toContain('~~~') + expect(result.length).toBeGreaterThan(longContent.length) + }) + + test('handles all HTML entity types', () => { + const input = `
+<div> & "quotes" 'apostrophe' +Content +
` + + const expected = `~~~expand title="
& "quotes" 'apostrophe'" +Content +~~~` + + expect(convertDetailsToExpandSyntax(input)).toBe(expected) + }) + }) + + describe('markdownToAdf', () => { + test('returns empty doc for empty input', () => { + expect(markdownToAdf('')).toEqual({ type: 'doc', version: 1, content: [] }) + }) + + test('returns empty doc for null input', () => { + expect(markdownToAdf(null as unknown as string)).toEqual({ type: 'doc', version: 1, content: [] }) + }) + + test('returns empty doc for undefined input', () => { + expect(markdownToAdf(undefined as unknown as string)).toEqual({ type: 'doc', version: 1, content: [] }) + }) + + test('converts plain text to ADF', () => { + const result = markdownToAdf('Hello world') + expect(result).toHaveProperty('type', 'doc') + expect(result).toHaveProperty('version', 1) + expect(result).toHaveProperty('content') + }) + + test('converts details/summary to ADF expand node', () => { + const input = `
+Click to expand +Hidden content +
` + + const result = markdownToAdf(input) + expect(result).toHaveProperty('type', 'doc') + expect(result).toHaveProperty('content') + + // The ADF should contain an expand node (since the preprocessing converts to expand syntax) + const content = (result as { content: unknown[] }).content + expect(content.length).toBeGreaterThan(0) + + // Find the expand node in the content + const hasExpandNode = content.some((node: unknown) => { + return (node as { type: string }).type === 'expand' + }) + expect(hasExpandNode).toBe(true) + }) + + test('converts nested details/summary correctly', () => { + const input = `
+Outer +Outer content +
+Inner +Inner content +
+
` + + const result = markdownToAdf(input) + expect(result).toHaveProperty('type', 'doc') + + // Should have expand nodes + const content = (result as { content: unknown[] }).content + const expandNodes = content.filter((node: unknown) => (node as { type: string }).type === 'expand') + expect(expandNodes.length).toBeGreaterThanOrEqual(1) + }) + + test('preserves regular markdown content alongside details blocks', () => { + const input = `# Heading + +Some regular text + +
+Expandable +Hidden content +
+ +More text` + + const result = markdownToAdf(input) + expect(result).toHaveProperty('type', 'doc') + + const content = (result as { content: unknown[] }).content + // Should have multiple content nodes (heading, paragraphs, expand) + expect(content.length).toBeGreaterThan(1) + }) + + test('handles markdown with emoji in summary', () => { + const input = `
+📋 Complete Context +Content here +
` + + const result = markdownToAdf(input) + expect(result).toHaveProperty('type', 'doc') + + // Should have an expand node with the title including emoji + const content = (result as { content: unknown[] }).content + const expandNode = content.find((node: unknown) => (node as { type: string }).type === 'expand') + expect(expandNode).toBeDefined() + expect((expandNode as { attrs: { title: string } }).attrs.title).toBe('📋 Complete Context') + }) + + // Tests for code mark sanitization - ADF spec requires code marks to be standalone + describe('code mark sanitization', () => { + test('code mark only remains unchanged', () => { + const input = 'Some `code` here' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code') + }) + + test('code with bold mark - removes bold, keeps only code', () => { + const input = '**bold `code`**' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code') + }) + + test('code with italic mark - removes italic, keeps only code', () => { + const input = '*italic `code`*' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code') + }) + + test('code with multiple marks (bold + italic) - removes all, keeps only code', () => { + const input = '***bold italic `code`***' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code') + }) + + test('code inside link - removes link mark, keeps only code', () => { + const input = '[`code link`](https://example.com)' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + expect(codeNodes[0].text).toBe('code link') + }) + + test('nested content with code marks is recursively sanitized', () => { + // Blockquote with bold code inside + const input = `> **bold \`code\`** in blockquote` + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + }) + + test('no code marks - text remains unchanged', () => { + const input = '**bold** and *italic* text' + const result = markdownToAdf(input) as AdfNode + + // Should have bold and italic marks, but no code + const codeNodes = findTextNodesWithCodeMark(result) + expect(codeNodes.length).toBe(0) + + // The bold and italic text should still have their marks + const content = result.content || [] + expect(content.length).toBeGreaterThan(0) + }) + + test('multiple text nodes - only code-marked nodes are affected', () => { + const input = '**bold** and `code` and *italic*' + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + // Only one code node + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + + // Other marks should be preserved + const content = result.content || [] + const paragraph = content[0] + const textNodes = (paragraph?.content || []) as AdfNode[] + + // Find the bold text node + const boldNode = textNodes.find( + (node) => node.type === 'text' && node.marks?.some((m) => m.type === 'strong') + ) + expect(boldNode).toBeDefined() + + // Find the italic text node + const italicNode = textNodes.find( + (node) => node.type === 'text' && node.marks?.some((m) => m.type === 'em') + ) + expect(italicNode).toBeDefined() + }) + + test('handles text with no marks array', () => { + const input = 'Plain text with no formatting' + const result = markdownToAdf(input) as AdfNode + + // Should not throw and should have content + expect(result.type).toBe('doc') + expect(result.content?.length).toBeGreaterThan(0) + }) + + test('handles deeply nested structure with code marks', () => { + // List with nested content containing code + const input = `- Item with **\`code\`** inside` + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + expect(codeNodes.length).toBe(1) + expect(codeNodes[0].marks).toEqual([{ type: 'code' }]) + }) + + test('mixed content - code inside various formatting preserved correctly', () => { + const input = `Text with **bold \`code1\`** and *italic \`code2\`* and plain \`code3\`` + const result = markdownToAdf(input) as AdfNode + const codeNodes = findTextNodesWithCodeMark(result) + + // All three code nodes should only have code marks + expect(codeNodes.length).toBe(3) + for (const node of codeNodes) { + expect(node.marks).toEqual([{ type: 'code' }]) + } + }) + }) + }) +}) diff --git a/src/lib/providers/jira/AdfMarkdownConverter.ts b/src/lib/providers/jira/AdfMarkdownConverter.ts index 09e72af4..36f8ef46 100644 --- a/src/lib/providers/jira/AdfMarkdownConverter.ts +++ b/src/lib/providers/jira/AdfMarkdownConverter.ts @@ -5,6 +5,82 @@ 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 @@ -34,5 +110,9 @@ export function markdownToAdf(markdown: string): object { if (!markdown) { return { type: 'doc', version: 1, content: [] } } - return parser.markdownToAdf(markdown) + // 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 index a5a11369..063d4141 100644 --- a/src/lib/providers/jira/JiraApiClient.ts +++ b/src/lib/providers/jira/JiraApiClient.ts @@ -193,8 +193,10 @@ export class JiraApiClient { * 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: markdownToAdf(body), + body: adfBody }) } From 4c17e27babe8de4ffbfbdc291ea3c120749daa81 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 14:32:50 -0800 Subject: [PATCH 18/27] fix bitbucket user id resolution --- README.md | 6 +- src/lib/SettingsManager.test.ts | 33 +----- src/lib/SettingsManager.ts | 8 +- .../bitbucket/BitBucketApiClient.test.ts | 112 ++++++++++++++++-- .../providers/bitbucket/BitBucketApiClient.ts | 89 ++++++++------ .../bitbucket/BitBucketVCSProvider.test.ts | 48 ++++---- .../bitbucket/BitBucketVCSProvider.ts | 34 +++--- 7 files changed, 205 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 43f898c3..93079bc5 100644 --- a/README.md +++ b/README.md @@ -493,7 +493,7 @@ To use BitBucket for pull requests, add this configuration: - `apiToken`: API token (store in settings.local.json only!) - `workspace`: (Optional) BitBucket workspace, auto-detected from git remote if not provided - `repoSlug`: (Optional) Repository slug, auto-detected from git remote if not provided -- `reviewers`: (Optional) Array of email addresses to automatically add as PR reviewers. Emails are resolved to BitBucket account IDs at PR creation time. Unresolved emails are logged as warnings but don't block PR creation. +- `reviewers`: (Optional) Array of BitBucket usernames to automatically add as PR reviewers. Usernames are resolved to BitBucket account IDs at PR creation time. Unresolved usernames are logged as warnings but don't block PR creation. **Example with Reviewers:** ```json @@ -503,8 +503,8 @@ To use BitBucket for pull requests, add this configuration: "bitbucket": { "username": "your-bitbucket-username", "reviewers": [ - "alice@yourcompany.com", - "bob@yourcompany.com" + "alice.jones", + "bob.smith" ] } }, diff --git a/src/lib/SettingsManager.test.ts b/src/lib/SettingsManager.test.ts index 82574719..f0843165 100644 --- a/src/lib/SettingsManager.test.ts +++ b/src/lib/SettingsManager.test.ts @@ -2613,7 +2613,7 @@ const error: { code?: string; message: string } = { }) describe('bitbucket reviewers configuration', () => { - it('should accept valid email addresses in reviewers array', async () => { + it('should accept valid usernames in reviewers array', async () => { const projectRoot = '/test/project' const validSettings = { versionControl: { @@ -2621,7 +2621,7 @@ const error: { code?: string; message: string } = { bitbucket: { username: 'testuser', apiToken: 'test-token', - reviewers: ['alice@example.com', 'bob@company.org'], + reviewers: ['alice', 'bob_smith'], }, }, } @@ -2636,34 +2636,7 @@ const error: { code?: string; message: string } = { .mockRejectedValueOnce(error) // settings.local.json const result = await settingsManager.loadSettings(projectRoot) - expect(result.versionControl?.bitbucket?.reviewers).toEqual(['alice@example.com', 'bob@company.org']) - }) - - it('should reject invalid email addresses in reviewers array', async () => { - const projectRoot = '/test/project' - const invalidSettings = { - versionControl: { - provider: 'bitbucket', - bitbucket: { - username: 'testuser', - apiToken: 'test-token', - reviewers: ['not-an-email', 'alice@example.com'], - }, - }, - } - - const error: { code?: string; message: string } = { - code: 'ENOENT', - message: 'ENOENT: no such file or directory', - } - vi.mocked(readFile) - .mockRejectedValueOnce(error) // global settings - .mockResolvedValueOnce(JSON.stringify(invalidSettings)) // settings.json - .mockRejectedValueOnce(error) // settings.local.json - - await expect(settingsManager.loadSettings(projectRoot)).rejects.toThrow( - /Reviewer must be a valid email address/ - ) + expect(result.versionControl?.bitbucket?.reviewers).toEqual(['alice', 'bob_smith']) }) it('should allow empty reviewers array', async () => { diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index d2040cfa..823424d0 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -392,9 +392,9 @@ export const IloomSettingsSchema = z.object({ .optional() .describe('BitBucket repository slug (optional, auto-detected from git remote if not provided)'), reviewers: z - .array(z.string().email('Reviewer must be a valid email address')) + .array(z.string().describe('Reviewer username')) .optional() - .describe('List of email addresses to add as PR reviewers. Emails are resolved to Bitbucket account IDs at PR creation time.'), + .describe('List of usernames to add as PR reviewers. Usernames are resolved to Bitbucket account IDs at PR creation time.'), }) .optional(), }) @@ -626,9 +626,9 @@ export const IloomSettingsSchemaNoDefaults = z.object({ .optional() .describe('BitBucket repository slug (optional, auto-detected from git remote if not provided)'), reviewers: z - .array(z.string().email('Reviewer must be a valid email address')) + .array(z.string().describe('Reviewer username')) .optional() - .describe('List of email addresses to add as PR reviewers. Emails are resolved to Bitbucket account IDs at PR creation time.'), + .describe('List of usernames to add as PR reviewers. Usernames are resolved to Bitbucket account IDs at PR creation time.'), }) .optional(), }) diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.test.ts b/src/lib/providers/bitbucket/BitBucketApiClient.test.ts index 82e22a3f..b999eb04 100644 --- a/src/lib/providers/bitbucket/BitBucketApiClient.test.ts +++ b/src/lib/providers/bitbucket/BitBucketApiClient.test.ts @@ -171,8 +171,8 @@ describe('BitBucketApiClient', () => { }) }) - describe('findUsersByEmail', () => { - it('should return map of email to account_id for matched users', async () => { + describe('findUsersByUsername', () => { + it('should return map of username to account_id for matched users', async () => { const https = await import('node:https') vi.mocked(https.default.request).mockImplementation((options, callback) => { @@ -202,10 +202,10 @@ describe('BitBucketApiClient', () => { } }) - const result = await client.findUsersByEmail('workspace', ['alice@example.com', 'bob@example.com']) + const result = await client.findUsersByUsername('workspace', ['alice', 'bob']) - expect(result.get('alice@example.com')).toBe('acc-1') - expect(result.get('bob@example.com')).toBe('acc-2') + expect(result.get('alice')).toBe('acc-1') + expect(result.get('bob')).toBe('acc-2') }) it('should return empty map when no users match', async () => { @@ -237,12 +237,12 @@ describe('BitBucketApiClient', () => { } }) - const result = await client.findUsersByEmail('workspace', ['alice@example.com']) + const result = await client.findUsersByUsername('workspace', ['alice']) expect(result.size).toBe(0) }) - it('should handle API errors gracefully', async () => { + it('should handle API errors by throwing', async () => { const https = await import('node:https') vi.mocked(https.default.request).mockImplementation((options, callback) => { @@ -267,9 +267,101 @@ describe('BitBucketApiClient', () => { } }) - // Should not throw, just return empty map - const result = await client.findUsersByEmail('workspace', ['alice@example.com']) - expect(result.size).toBe(0) + // Should throw on API error + await expect(client.findUsersByUsername('workspace', ['alice'])).rejects.toThrow('BitBucket API error') + }) + + it('should handle pagination when fetching workspace members', async () => { + const https = await import('node:https') + let requestCount = 0 + const requestPaths: string[] = [] + + vi.mocked(https.default.request).mockImplementation((options, callback) => { + requestCount++ + // Capture the path used in each request to verify no URL duplication + requestPaths.push((options as { path: string }).path) + const mockResponse = { + statusCode: 200, + on: vi.fn((event, handler) => { + if (event === 'data') { + // First request returns first page with 'next' URL + if (requestCount === 1) { + handler(JSON.stringify({ + values: [ + { user: { account_id: 'acc-1', display_name: 'Alice Test', uuid: 'uuid-1', nickname: 'alice' } }, + ], + next: 'https://api.bitbucket.org/2.0/workspaces/workspace/members?page=2', + })) + } else { + // Second request returns second page without 'next' + handler(JSON.stringify({ + values: [ + { user: { account_id: 'acc-2', display_name: 'Bob Example', uuid: 'uuid-2', nickname: 'bob' } }, + ], + })) + } + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } + }) + + const result = await client.findUsersByUsername('workspace', ['alice', 'bob']) + + // Should have made 2 requests (one for each page) + expect(requestCount).toBe(2) + // Should have found both users from different pages + expect(result.get('alice')).toBe('acc-1') + expect(result.get('bob')).toBe('acc-2') + // Verify no URL path duplication (bug fix verification) + // First request should be the initial endpoint + expect(requestPaths[0]).toBe('/2.0/workspaces/workspace/members') + // Second request should be the pagination path (not /2.0/2.0/...) + expect(requestPaths[1]).toBe('/2.0/workspaces/workspace/members?page=2') + }) + + it('should match by display_name when nickname does not match', async () => { + const https = await import('node:https') + + vi.mocked(https.default.request).mockImplementation((options, callback) => { + const mockResponse = { + statusCode: 200, + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ + values: [ + { user: { account_id: 'acc-1', display_name: 'alice', uuid: 'uuid-1', nickname: 'alice123' } }, + ], + })) + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } + }) + + const result = await client.findUsersByUsername('workspace', ['alice']) + + expect(result.get('alice')).toBe('acc-1') }) }) diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.ts b/src/lib/providers/bitbucket/BitBucketApiClient.ts index 85cb42bc..3f54b2b6 100644 --- a/src/lib/providers/bitbucket/BitBucketApiClient.ts +++ b/src/lib/providers/bitbucket/BitBucketApiClient.ts @@ -48,7 +48,7 @@ export interface BitBucketPullRequest { /** * BitBucket workspace member response from API - * Used for resolving email addresses to account IDs + * Used for resolving usernames to account IDs */ export interface BitBucketWorkspaceMember { user: { @@ -77,6 +77,8 @@ export interface BitBucketRepository { [key: string]: unknown } +interface BitBucketWorkspaceMembersResponse { values: BitBucketWorkspaceMember[]; next?: string } + /** * BitBucketApiClient provides low-level REST API access to BitBucket * @@ -109,7 +111,10 @@ export class BitBucketApiClient { endpoint: string, body?: unknown ): Promise { - const url = new URL(`${this.baseUrl}${endpoint}`) + // If endpoint is already a full URL, use it directly; otherwise prepend baseUrl + const url = endpoint.startsWith('http://') || endpoint.startsWith('https://') + ? new URL(endpoint) + : new URL(`${this.baseUrl}${endpoint}`) getLogger().debug(`BitBucket API ${method} request`, { url: url.toString() }) return new Promise((resolve, reject) => { @@ -285,52 +290,62 @@ export class BitBucketApiClient { } /** - * Find workspace members by email addresses - * Returns a map of email -> account_id for resolved emails - * - * Note: BitBucket API requires querying members individually since the BBQL - * user.email field is not available in workspace members endpoint. - * We use the user email lookup endpoint instead. + * Find workspace members by usernames + * Returns a map of username -> account_id for resolved users + * Handles pagination to fetch all workspace members */ - async findUsersByEmail( + async findUsersByUsername( workspace: string, - emails: string[] + usernames: string[] ): Promise> { const result = new Map() - // Query each email individually using the users endpoint - // The workspace members endpoint doesn't expose email in searchable fields - for (const email of emails) { - try { - // Use the workspace members endpoint with a search query - // The API supports searching by nickname which often matches email prefix - const endpoint = `/workspaces/${workspace}/members` - const response = await this.get<{ values: BitBucketWorkspaceMember[] }>(endpoint) - - // Find member whose nickname or display_name contains the email prefix - // This is a best-effort match since email is not directly exposed - const emailPrefix = email.split('@')[0]?.toLowerCase() - if (!emailPrefix) continue - - const member = response.values.find(m => - m.user.nickname?.toLowerCase() === emailPrefix || - m.user.display_name.toLowerCase().includes(emailPrefix) - ) - - if (member) { - result.set(email, member.user.account_id) - getLogger().debug(`Resolved reviewer email ${email} to account ID ${member.user.account_id}`) - } else { - getLogger().warn(`Could not resolve reviewer email ${email} to a BitBucket account ID`) - } - } catch (error) { - getLogger().warn(`Failed to resolve reviewer email ${email}`, { error }) + // Fetch all workspace members with pagination + const allMembers = await this.getAllWorkspaceMembers(workspace) + + getLogger().debug(`Resolving ${usernames.length} usernames against ${allMembers.length} workspace members`, { allMembers}) + + // Match usernames against fetched members + for (const username of usernames) { + const usernameLower = username.toLowerCase() + const member = allMembers.find(m => + m.user.nickname?.toLowerCase() === usernameLower || + m.user.display_name.toLowerCase() === usernameLower + ) + + if (member) { + result.set(username, member.user.account_id) + getLogger().debug(`Resolved reviewer ${username} to account ID ${member.user.account_id}`) + } else { + getLogger().warn(`Could not resolve reviewer ${username} to a BitBucket account ID`) } } return result } + /** + * Fetch all workspace members with pagination + */ + private async getAllWorkspaceMembers(workspace: string): Promise { + const allMembers: BitBucketWorkspaceMember[] = [] + let nextUrl: string | null = `/workspaces/${workspace}/members` + + while (nextUrl) { + const response: BitBucketWorkspaceMembersResponse = + await this.get(nextUrl) + + allMembers.push(...response.values) + + // BitBucket pagination uses 'next' field with full URL + // Use it directly since request() now handles full URLs + nextUrl = response.next ?? null + } + + getLogger().debug(`Fetched ${allMembers.length} workspace members from BitBucket`) + return allMembers + } + /** * Test connection to BitBucket API */ diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts index 59388c98..efe5edc8 100644 --- a/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts @@ -8,7 +8,7 @@ vi.mock('./BitBucketApiClient.js', () => ({ getWorkspace: vi.fn().mockReturnValue('test-workspace'), getRepoSlug: vi.fn().mockReturnValue('test-repo'), createPullRequest: vi.fn(), - findUsersByEmail: vi.fn(), + findUsersByUsername: vi.fn(), listPullRequests: vi.fn(), getPullRequest: vi.fn(), addPRComment: vi.fn(), @@ -36,7 +36,7 @@ describe('BitBucketVCSProvider', () => { getWorkspace: ReturnType getRepoSlug: ReturnType createPullRequest: ReturnType - findUsersByEmail: ReturnType + findUsersByUsername: ReturnType listPullRequests: ReturnType getPullRequest: ReturnType addPRComment: ReturnType @@ -49,7 +49,7 @@ describe('BitBucketVCSProvider', () => { getWorkspace: vi.fn().mockReturnValue('test-workspace'), getRepoSlug: vi.fn().mockReturnValue('test-repo'), createPullRequest: vi.fn(), - findUsersByEmail: vi.fn(), + findUsersByUsername: vi.fn(), listPullRequests: vi.fn(), getPullRequest: vi.fn(), addPRComment: vi.fn(), @@ -58,19 +58,19 @@ describe('BitBucketVCSProvider', () => { }) describe('createPR with reviewers', () => { - it('should resolve reviewer emails and pass account IDs to createPullRequest', async () => { + it('should resolve reviewer usernames and pass account IDs to createPullRequest', async () => { const config: BitBucketVCSConfig = { username: 'testuser', apiToken: 'test-token', - reviewers: ['alice@example.com', 'bob@example.com'], + reviewers: ['alice', 'bob'], } provider = new BitBucketVCSProvider(config) - // Mock email resolution - mockClient.findUsersByEmail.mockResolvedValue( + // Mock username resolution + mockClient.findUsersByUsername.mockResolvedValue( new Map([ - ['alice@example.com', 'acc-alice'], - ['bob@example.com', 'acc-bob'], + ['alice', 'acc-alice'], + ['bob', 'acc-bob'], ]) ) @@ -90,10 +90,10 @@ describe('BitBucketVCSProvider', () => { const url = await provider.createPR('feature', 'Test PR', 'Test body', 'main') - // Verify findUsersByEmail was called with the configured emails - expect(mockClient.findUsersByEmail).toHaveBeenCalledWith( + // Verify findUsersByUsername was called with the configured usernames + expect(mockClient.findUsersByUsername).toHaveBeenCalledWith( 'test-workspace', - ['alice@example.com', 'bob@example.com'] + ['alice', 'bob'] ) // Verify createPullRequest was called with resolved account IDs @@ -110,17 +110,17 @@ describe('BitBucketVCSProvider', () => { expect(url).toBe('https://bitbucket.org/test/pr/123') }) - it('should continue with partial reviewers when some emails cannot be resolved', async () => { + it('should continue with partial reviewers when some usernames cannot be resolved', async () => { const config: BitBucketVCSConfig = { username: 'testuser', apiToken: 'test-token', - reviewers: ['alice@example.com', 'unknown@example.com'], + reviewers: ['alice', 'unknown_user'], } provider = new BitBucketVCSProvider(config) // Only alice resolves - mockClient.findUsersByEmail.mockResolvedValue( - new Map([['alice@example.com', 'acc-alice']]) + mockClient.findUsersByUsername.mockResolvedValue( + new Map([['alice', 'acc-alice']]) ) mockClient.createPullRequest.mockResolvedValue({ @@ -143,16 +143,16 @@ describe('BitBucketVCSProvider', () => { ) }) - it('should not pass reviewers when no emails can be resolved', async () => { + it('should not pass reviewers when no usernames can be resolved', async () => { const config: BitBucketVCSConfig = { username: 'testuser', apiToken: 'test-token', - reviewers: ['unknown@example.com'], + reviewers: ['unknown_user'], } provider = new BitBucketVCSProvider(config) - // No emails resolve - mockClient.findUsersByEmail.mockResolvedValue(new Map()) + // No usernames resolve + mockClient.findUsersByUsername.mockResolvedValue(new Map()) mockClient.createPullRequest.mockResolvedValue({ id: 123, @@ -190,8 +190,8 @@ describe('BitBucketVCSProvider', () => { await provider.createPR('feature', 'Test PR', 'Test body', 'main') - // findUsersByEmail should not be called - expect(mockClient.findUsersByEmail).not.toHaveBeenCalled() + // findUsersByUsername should not be called + expect(mockClient.findUsersByUsername).not.toHaveBeenCalled() // createPullRequest should be called without reviewers expect(mockClient.createPullRequest).toHaveBeenCalledWith( @@ -221,8 +221,8 @@ describe('BitBucketVCSProvider', () => { await provider.createPR('feature', 'Test PR', 'Test body', 'main') - // findUsersByEmail should not be called - expect(mockClient.findUsersByEmail).not.toHaveBeenCalled() + // findUsersByUsername should not be called + expect(mockClient.findUsersByUsername).not.toHaveBeenCalled() // createPullRequest should be called without reviewers expect(mockClient.createPullRequest).toHaveBeenCalledWith( diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts index a7d6fc27..de9bfde5 100644 --- a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts @@ -12,7 +12,7 @@ import { parseGitRemotes } from '../../../utils/remote.js' * Extends BitBucketConfig with username, appPassword, workspace, and repoSlug */ export interface BitBucketVCSConfig extends BitBucketConfig { - reviewers?: string[] // Email addresses of reviewers to add to PRs + reviewers?: string[] // Usernames of reviewers to add to PRs } /** @@ -29,12 +29,12 @@ export class BitBucketVCSProvider implements VersionControlProvider { readonly supportsDraftPRs = false // BitBucket doesn't have draft PRs private readonly client: BitBucketApiClient - private readonly reviewerEmails?: string[] + private readonly reviewerUsernames?: string[] constructor(config: BitBucketVCSConfig) { this.client = new BitBucketApiClient(config) if (config.reviewers) { - this.reviewerEmails = config.reviewers + this.reviewerUsernames = config.reviewers } } @@ -79,10 +79,10 @@ export class BitBucketVCSProvider implements VersionControlProvider { getLogger().info(`Creating BitBucket PR in ${workspace}/${repoSlug}`) getLogger().debug('PR details', { branchName, title, baseBranch }) - // Resolve reviewer emails to account IDs if configured + // Resolve reviewer usernames to account IDs if configured let reviewerIds: string[] | undefined - if (this.reviewerEmails && this.reviewerEmails.length > 0) { - reviewerIds = await this.resolveReviewerEmails(workspace, this.reviewerEmails) + if (this.reviewerUsernames && this.reviewerUsernames.length > 0) { + reviewerIds = await this.resolveReviewerUsernames(workspace, this.reviewerUsernames) } const pr = await this.client.createPullRequest( @@ -201,29 +201,29 @@ export class BitBucketVCSProvider implements VersionControlProvider { } /** - * Resolve reviewer email addresses to BitBucket account IDs - * Warns for any emails that cannot be resolved but continues with partial list + * Resolve reviewer usernames to BitBucket account IDs + * Warns for any usernames that cannot be resolved but continues with partial list */ - private async resolveReviewerEmails(workspace: string, emails: string[]): Promise { - getLogger().debug(`Resolving ${emails.length} reviewer email(s) to BitBucket account IDs`) + private async resolveReviewerUsernames(workspace: string, usernames: string[]): Promise { + getLogger().debug(`Resolving ${usernames.length} reviewer username(s) to BitBucket account IDs`) - const emailToAccountId = await this.client.findUsersByEmail(workspace, emails) + const usernameToAccountId = await this.client.findUsersByUsername(workspace, usernames) const resolvedIds: string[] = [] - const unresolvedEmails: string[] = [] + const unresolvedUsernames: string[] = [] - for (const email of emails) { - const accountId = emailToAccountId.get(email) + for (const username of usernames) { + const accountId = usernameToAccountId.get(username) if (accountId) { resolvedIds.push(accountId) } else { - unresolvedEmails.push(email) + unresolvedUsernames.push(username) } } - if (unresolvedEmails.length > 0) { + if (unresolvedUsernames.length > 0) { getLogger().warn( - `Could not resolve ${unresolvedEmails.length} reviewer email(s) to BitBucket account IDs: ${unresolvedEmails.join(', ')}. ` + + `Could not resolve ${unresolvedUsernames.length} reviewer username(s) to BitBucket account IDs: ${unresolvedUsernames.join(', ')}. ` + `These reviewers will not be added to the PR.` ) } From a93b84ef89e2d5fbc12745f56f477b896818822b Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 14:35:13 -0800 Subject: [PATCH 19/27] create new debug subcommand for integration testing --- src/cli.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 1168aef6..39a0b25b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1588,6 +1588,93 @@ program process.exit(0) }) +// Debug commands - only registered when debug mode is enabled +if (process.env.ILOOM_DEBUG === 'true') { + const debugCommand = program + .command('debug') + .description('Debug tools (only available in debug mode)') + + const bitbucketDebugCommand = debugCommand + .command('bitbucket') + .description('BitBucket debug tools') + + bitbucketDebugCommand + .command('resolve-reviewer-ids') + .description('Resolve configured reviewer usernames to BitBucket account IDs') + .action(async () => { + try { + const settingsManager = new SettingsManager() + const settings = await settingsManager.loadSettings() + + const bitbucketConfig = settings.versionControl?.bitbucket + if (!bitbucketConfig) { + logger.error('BitBucket configuration not found in settings') + logger.info('Configure versionControl.bitbucket in .iloom/settings.json') + process.exit(1) + } + + if (!bitbucketConfig.username) { + logger.error('BitBucket username not configured') + logger.info('Configure versionControl.bitbucket.username in .iloom/settings.json') + process.exit(1) + } + + if (!bitbucketConfig.apiToken) { + logger.error('BitBucket API token not configured') + logger.info('Configure versionControl.bitbucket.apiToken in .iloom/settings.local.json') + process.exit(1) + } + + const reviewers = bitbucketConfig.reviewers ?? [] + if (reviewers.length === 0) { + logger.warn('No reviewers configured in settings') + logger.info('Configure versionControl.bitbucket.reviewers in .iloom/settings.json') + console.log(JSON.stringify({}, null, 2)) + process.exit(0) + } + + // Get workspace from config or auto-detect from git remote + let workspace = bitbucketConfig.workspace + if (!workspace) { + const { parseGitRemotes } = await import('./utils/remote.js') + const remotes = await parseGitRemotes() + const bitbucketRemote = remotes.find(r => r.url.includes('bitbucket.org')) + if (!bitbucketRemote) { + logger.error('Could not auto-detect BitBucket workspace from git remote') + logger.info('Configure versionControl.bitbucket.workspace in .iloom/settings.json') + process.exit(1) + } + workspace = bitbucketRemote.owner + } + + // At this point workspace is guaranteed to be a string (either from config or auto-detected) + const resolvedWorkspace = workspace + + // Create BitBucket API client and resolve reviewer IDs + const { BitBucketApiClient } = await import('./lib/providers/bitbucket/BitBucketApiClient.js') + const apiClient = new BitBucketApiClient({ + username: bitbucketConfig.username, + apiToken: bitbucketConfig.apiToken, + workspace: resolvedWorkspace, + }) + + const resolvedMap = await apiClient.findUsersByUsername(resolvedWorkspace, reviewers) + + // Convert Map to plain object for JSON output + const result: Record = {} + for (const [username, accountId] of resolvedMap) { + result[username] = accountId + } + + console.log(JSON.stringify(result, null, 2)) + process.exit(0) + } catch (error) { + logger.error(`Failed to resolve reviewer IDs: ${error instanceof Error ? error.message : 'Unknown error'}`) + process.exit(1) + } + }) +} + // Parse CLI arguments (only when run directly, not when imported for testing) // Resolve symlinks to handle npm link and global installs const isRunDirectly = process.argv[1] && ((): boolean => { From db9d5122eab87f420917626c21c6beae3d8c6ccb Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 14:39:16 -0800 Subject: [PATCH 20/27] exclude current user from bitbucker pr reviewer list --- .../bitbucket/BitBucketApiClient.test.ts | 66 ++++++++++ .../providers/bitbucket/BitBucketApiClient.ts | 18 ++- .../bitbucket/BitBucketVCSProvider.test.ts | 113 ++++++++++++++++++ .../bitbucket/BitBucketVCSProvider.ts | 13 ++ 4 files changed, 209 insertions(+), 1 deletion(-) diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.test.ts b/src/lib/providers/bitbucket/BitBucketApiClient.test.ts index b999eb04..cffa6d34 100644 --- a/src/lib/providers/bitbucket/BitBucketApiClient.test.ts +++ b/src/lib/providers/bitbucket/BitBucketApiClient.test.ts @@ -365,6 +365,72 @@ describe('BitBucketApiClient', () => { }) }) + describe('getCurrentUser', () => { + it('should return current user data from /user endpoint', async () => { + const https = await import('node:https') + + vi.mocked(https.default.request).mockImplementation((options, callback) => { + const mockResponse = { + statusCode: 200, + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ + account_id: 'acc-current-user', + display_name: 'Current User', + nickname: 'currentuser', + })) + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } + }) + + const user = await client.getCurrentUser() + + expect(user.account_id).toBe('acc-current-user') + expect(user.display_name).toBe('Current User') + expect(user.nickname).toBe('currentuser') + }) + + it('should throw on API error', async () => { + const https = await import('node:https') + + vi.mocked(https.default.request).mockImplementation((options, callback) => { + const mockResponse = { + statusCode: 401, + on: vi.fn((event, handler) => { + if (event === 'data') { + handler(JSON.stringify({ error: { message: 'Unauthorized' } })) + } + if (event === 'end') { + handler() + } + return mockResponse + }), + } + // @ts-expect-error - Mock callback + callback(mockResponse) + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } + }) + + await expect(client.getCurrentUser()).rejects.toThrow('BitBucket API error') + }) + }) + describe('getWorkspace', () => { it('should return configured workspace', () => { expect(client.getWorkspace()).toBe('test-workspace') diff --git a/src/lib/providers/bitbucket/BitBucketApiClient.ts b/src/lib/providers/bitbucket/BitBucketApiClient.ts index 3f54b2b6..aa21ace2 100644 --- a/src/lib/providers/bitbucket/BitBucketApiClient.ts +++ b/src/lib/providers/bitbucket/BitBucketApiClient.ts @@ -79,6 +79,15 @@ export interface BitBucketRepository { interface BitBucketWorkspaceMembersResponse { values: BitBucketWorkspaceMember[]; next?: string } +/** + * BitBucket current user response from /user endpoint + */ +export interface BitBucketCurrentUser { + account_id: string + display_name: string + nickname?: string +} + /** * BitBucketApiClient provides low-level REST API access to BitBucket * @@ -346,12 +355,19 @@ export class BitBucketApiClient { return allMembers } + /** + * Get the currently authenticated user + */ + async getCurrentUser(): Promise { + return this.get('/user') + } + /** * Test connection to BitBucket API */ async testConnection(): Promise { try { - await this.get('/user') + await this.getCurrentUser() return true } catch (error) { getLogger().error('BitBucket connection test failed', { error }) diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts index efe5edc8..3ce770b9 100644 --- a/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.test.ts @@ -9,6 +9,7 @@ vi.mock('./BitBucketApiClient.js', () => ({ getRepoSlug: vi.fn().mockReturnValue('test-repo'), createPullRequest: vi.fn(), findUsersByUsername: vi.fn(), + getCurrentUser: vi.fn(), listPullRequests: vi.fn(), getPullRequest: vi.fn(), addPRComment: vi.fn(), @@ -37,6 +38,7 @@ describe('BitBucketVCSProvider', () => { getRepoSlug: ReturnType createPullRequest: ReturnType findUsersByUsername: ReturnType + getCurrentUser: ReturnType listPullRequests: ReturnType getPullRequest: ReturnType addPRComment: ReturnType @@ -50,6 +52,11 @@ describe('BitBucketVCSProvider', () => { getRepoSlug: vi.fn().mockReturnValue('test-repo'), createPullRequest: vi.fn(), findUsersByUsername: vi.fn(), + getCurrentUser: vi.fn().mockResolvedValue({ + account_id: 'acc-current-user', + display_name: 'Current User', + nickname: 'currentuser', + }), listPullRequests: vi.fn(), getPullRequest: vi.fn(), addPRComment: vi.fn(), @@ -235,6 +242,112 @@ describe('BitBucketVCSProvider', () => { undefined ) }) + + it('should filter out the current user from reviewers list', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice', 'currentuser'], // currentuser is the PR author + } + provider = new BitBucketVCSProvider(config) + + // Current user has account_id 'acc-current-user' (set in beforeEach) + mockClient.findUsersByUsername.mockResolvedValue( + new Map([ + ['alice', 'acc-alice'], + ['currentuser', 'acc-current-user'], // Same as current user + ]) + ) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // getCurrentUser should be called to get the current user's account ID + expect(mockClient.getCurrentUser).toHaveBeenCalled() + + // createPullRequest should be called with only alice (current user filtered out) + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + ['acc-alice'] + ) + }) + + it('should pass all reviewers when current user is not in the list', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['alice', 'bob'], + } + provider = new BitBucketVCSProvider(config) + + mockClient.findUsersByUsername.mockResolvedValue( + new Map([ + ['alice', 'acc-alice'], + ['bob', 'acc-bob'], + ]) + ) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // All reviewers should be passed (none filtered) + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + ['acc-alice', 'acc-bob'] + ) + }) + + it('should pass empty array when current user is the only reviewer', async () => { + const config: BitBucketVCSConfig = { + username: 'testuser', + apiToken: 'test-token', + reviewers: ['currentuser'], + } + provider = new BitBucketVCSProvider(config) + + mockClient.findUsersByUsername.mockResolvedValue( + new Map([['currentuser', 'acc-current-user']]) + ) + + mockClient.createPullRequest.mockResolvedValue({ + id: 123, + title: 'Test PR', + links: { html: { href: 'https://bitbucket.org/test/pr/123' } }, + }) + + await provider.createPR('feature', 'Test PR', 'Test body', 'main') + + // createPullRequest should be called with empty array (current user filtered out) + expect(mockClient.createPullRequest).toHaveBeenCalledWith( + 'test-workspace', + 'test-repo', + 'Test PR', + 'Test body', + 'feature', + 'main', + [] + ) + }) }) describe('provider properties', () => { diff --git a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts index de9bfde5..c876f44a 100644 --- a/src/lib/providers/bitbucket/BitBucketVCSProvider.ts +++ b/src/lib/providers/bitbucket/BitBucketVCSProvider.ts @@ -83,6 +83,19 @@ export class BitBucketVCSProvider implements VersionControlProvider { let reviewerIds: string[] | undefined if (this.reviewerUsernames && this.reviewerUsernames.length > 0) { reviewerIds = await this.resolveReviewerUsernames(workspace, this.reviewerUsernames) + + // Filter out the current user from reviewers (BitBucket doesn't allow PR author as reviewer) + if (reviewerIds.length > 0) { + const currentUser = await this.client.getCurrentUser() + const originalCount = reviewerIds.length + reviewerIds = reviewerIds.filter(id => id !== currentUser.account_id) + + if (reviewerIds.length < originalCount) { + getLogger().debug( + `Removed current user (${currentUser.display_name}) from reviewers list - PR author cannot be a reviewer` + ) + } + } } const pr = await this.client.createPullRequest( From 945ac879d7fafba7b13e6f20005752516a86c60b Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 17:22:34 -0800 Subject: [PATCH 21/27] pass settings to IssueManagementProviderFactory when using commit command --- src/commands/commit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/commit.ts b/src/commands/commit.ts index ced2db2b..9a5adaf2 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) From aac1251d19027d7cb86ab86bb64688f7602b8401 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Fri, 16 Jan 2026 18:55:00 -0800 Subject: [PATCH 22/27] add moveIssueToReadyForReview method to issue trackers Co-Authored-By: Claude Opus 4.5 --- src/commands/finish.ts | 30 +++++++++ src/lib/GitHubService.ts | 73 +++++++++++++++++++--- src/lib/IssueTracker.ts | 1 + src/lib/LinearService.ts | 10 +++ src/lib/providers/jira/JiraIssueTracker.ts | 33 ++++++++++ 5 files changed, 140 insertions(+), 7 deletions(-) diff --git a/src/commands/finish.ts b/src/commands/finish.ts index 12a20b49..9777c48c 100644 --- a/src/commands/finish.ts +++ b/src/commands/finish.ts @@ -1130,6 +1130,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 @@ -1243,6 +1258,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 + ) + } + } } // Generate session summary - posts to the ISSUE (Jira/Linear), not the PR diff --git a/src/lib/GitHubService.ts b/src/lib/GitHubService.ts index 32c08828..3bfb5c4b 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,12 +365,13 @@ 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 }) diff --git a/src/lib/IssueTracker.ts b/src/lib/IssueTracker.ts index a562bf9d..f44b6d40 100644 --- a/src/lib/IssueTracker.ts +++ b/src/lib/IssueTracker.ts @@ -40,6 +40,7 @@ export interface IssueTracker { // Status management - optional, check provider capabilities before calling moveIssueToInProgress?(identifier: string | number): Promise + moveIssueToReadyForReview?(identifier: string | number): Promise // Context extraction - formats issue/PR for AI prompts extractContext(entity: Issue | PullRequest): string diff --git a/src/lib/LinearService.ts b/src/lib/LinearService.ts index f26d0456..9eb523db 100644 --- a/src/lib/LinearService.ts +++ b/src/lib/LinearService.ts @@ -192,6 +192,16 @@ 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') + } + /** * Extract issue context for AI prompts * @param entity - Issue (Linear doesn't have PRs) diff --git a/src/lib/providers/jira/JiraIssueTracker.ts b/src/lib/providers/jira/JiraIssueTracker.ts index e5de6394..e5fd583a 100644 --- a/src/lib/providers/jira/JiraIssueTracker.ts +++ b/src/lib/providers/jira/JiraIssueTracker.ts @@ -172,6 +172,39 @@ export class JiraIssueTracker implements IssueTracker { 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 = String(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 */ From 9557d6f9c838c0405486ff7be10f66109c0e12d8 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Thu, 22 Jan 2026 09:13:11 -0800 Subject: [PATCH 23/27] attempt to prevent the llm from generating jira wiki markup --- src/lib/IssueEnhancementService.ts | 6 ++++-- templates/agents/iloom-issue-analyze-and-plan.md | 14 ++++++++++++++ templates/agents/iloom-issue-analyzer.md | 14 ++++++++++++++ .../agents/iloom-issue-complexity-evaluator.md | 14 ++++++++++++++ templates/agents/iloom-issue-enhancer.md | 14 ++++++++++++++ templates/agents/iloom-issue-implementer.md | 14 ++++++++++++++ templates/agents/iloom-issue-planner.md | 14 ++++++++++++++ templates/agents/iloom-issue-reviewer.md | 14 ++++++++++++++ templates/prompts/session-summary-prompt.txt | 14 ++++++++++++++ 9 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/lib/IssueEnhancementService.ts b/src/lib/IssueEnhancementService.ts index fdaa1922..4c2cc2ae 100644 --- a/src/lib/IssueEnhancementService.ts +++ b/src/lib/IssueEnhancementService.ts @@ -64,20 +64,22 @@ export class IssueEnhancementService { // Call Claude in headless mode with issue enhancer agent const prompt = `@agent-iloom-issue-enhancer -TASK: Enhance the following issue description for GitHub. +TASK: Enhance the following issue description for the issue tracker. INPUT: ${description} OUTPUT REQUIREMENTS: - Return ONLY the enhanced description markdown text +- Use GitHub-Flavored Markdown syntax ONLY +- NEVER use Jira Wiki format (e.g., {code}, h1., *bold*, {quote}, [link|url]) - NO meta-commentary (no "Here is...", "The enhanced...", "I have...", etc) - NO code block markers (\`\`\`) - NO conversational framing or acknowledgments - NO explanations of your work - Start your response immediately with the enhanced content -Your response should be the raw markdown that will become the GitHub issue body.` +Your response should be the raw markdown that will become the issue body.` const enhanced = await launchClaude(prompt, { headless: true, diff --git a/templates/agents/iloom-issue-analyze-and-plan.md b/templates/agents/iloom-issue-analyze-and-plan.md index a276bc28..ab06bffa 100644 --- a/templates/agents/iloom-issue-analyze-and-plan.md +++ b/templates/agents/iloom-issue-analyze-and-plan.md @@ -216,6 +216,20 @@ Based on the lightweight analysis, create a detailed plan following the project' IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-analyzer.md b/templates/agents/iloom-issue-analyzer.md index 4bde6292..c119b47a 100644 --- a/templates/agents/iloom-issue-analyzer.md +++ b/templates/agents/iloom-issue-analyzer.md @@ -289,6 +289,20 @@ Use domain-specific MCP tools when available (Figma MCP, Database MCPs, etc.) as IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-complexity-evaluator.md b/templates/agents/iloom-issue-complexity-evaluator.md index 4d27bd29..b052824a 100644 --- a/templates/agents/iloom-issue-complexity-evaluator.md +++ b/templates/agents/iloom-issue-complexity-evaluator.md @@ -170,6 +170,20 @@ Estimate the following metrics: IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-enhancer.md b/templates/agents/iloom-issue-enhancer.md index 205a3149..5188aa35 100644 --- a/templates/agents/iloom-issue-enhancer.md +++ b/templates/agents/iloom-issue-enhancer.md @@ -94,6 +94,20 @@ Before asking questions, perform minimal research to avoid questions whose answe IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-implementer.md b/templates/agents/iloom-issue-implementer.md index 60b0b539..de22d340 100644 --- a/templates/agents/iloom-issue-implementer.md +++ b/templates/agents/iloom-issue-implementer.md @@ -18,6 +18,20 @@ This enables the recap panel to show quick-reference links to artifacts created IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-planner.md b/templates/agents/iloom-issue-planner.md index 33089dd8..ba53527c 100644 --- a/templates/agents/iloom-issue-planner.md +++ b/templates/agents/iloom-issue-planner.md @@ -34,6 +34,20 @@ Your primary task is to: IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/agents/iloom-issue-reviewer.md b/templates/agents/iloom-issue-reviewer.md index 13554515..a5331191 100644 --- a/templates/agents/iloom-issue-reviewer.md +++ b/templates/agents/iloom-issue-reviewer.md @@ -46,6 +46,20 @@ This enables the recap panel to show quick-reference links to artifacts created IMPORTANT: You have been provided with MCP tools for issue management during this workflow. +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + Available Tools: - mcp__issue_management__get_issue: Fetch issue details Parameters: { number: string, includeComments?: boolean } diff --git a/templates/prompts/session-summary-prompt.txt b/templates/prompts/session-summary-prompt.txt index 61a5fbd5..5213d12b 100644 --- a/templates/prompts/session-summary-prompt.txt +++ b/templates/prompts/session-summary-prompt.txt @@ -89,6 +89,20 @@ The reader doesn't care about your internal process. They care about: - Any explanation of what you're doing - Any text after the closing `
` tag +**CRITICAL FORMAT REQUIREMENT:** +All comment content MUST use **GitHub-Flavored Markdown** syntax. +NEVER use Jira Wiki format - it will corrupt the output when converted. + +| Do NOT use (Jira Wiki) | Use instead (Markdown) | +|------------------------|------------------------| +| `{code}...{code}` | ` ``` ` code blocks | +| `h1. Title` | `# Title` | +| `*bold*` | `**bold**` | +| `_italic_` | `*italic*` | +| `{quote}...{quote}` | `> ` blockquotes | +| `[link text\|url]` | `[link text](url)` | +| `-` or `*` at line start | `- ` (with space) for lists | + **Output ONLY the markdown content below, starting with `## iloom Session Summary` and ending with `
`.** Structure it with key themes visible at the top, then detailed sections wrapped in collapsible tags: From 31ebac95bf9eb2c5eb4433963092b04cdf4319b5 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Thu, 22 Jan 2026 10:42:20 -0800 Subject: [PATCH 24/27] add setting to configure git commit timeout for longer running hooks --- .iloom/settings.example.json | 3 + README.md | 23 +++- src/commands/commit.ts | 1 + src/commands/finish.ts | 2 + src/lib/CommitManager.test.ts | 69 ++++++++++++ src/lib/CommitManager.ts | 8 +- src/lib/SettingsManager.test.ts | 185 ++++++++++++++++++++++++++++++-- src/lib/SettingsManager.ts | 22 ++++ src/types/index.ts | 1 + src/utils/git.ts | 4 +- 10 files changed, 303 insertions(+), 15 deletions(-) diff --git a/.iloom/settings.example.json b/.iloom/settings.example.json index bcf574af..db4bda03 100644 --- a/.iloom/settings.example.json +++ b/.iloom/settings.example.json @@ -1,5 +1,8 @@ { "mainBranch": "main", + "git": { + "commitTimeout": 60000 + }, "workflows": { "issue": { "permissionMode": "default", diff --git a/README.md b/README.md index 93079bc5..42677a8b 100644 --- a/README.md +++ b/README.md @@ -562,9 +562,28 @@ Use Jira for issues and BitBucket for pull requests: iloom creates isolated workspace settings for your editor. Color synchronization (visual context) only works best VS Code-based editors. * **Supported:** VS Code, Cursor, Windsurf, Antigravity, WebStorm, IntelliJ, Sublime Text. - + * **Config:** Set your preference via `il init` or `il start --set ide.type=cursor`. - + +### Git Operation Settings + +Configure git operation timeouts for projects with long-running pre-commit hooks. + +**.iloom/settings.json** +```json +{ + "git": { + "commitTimeout": 120000 + } +} +``` + +| Setting | Default | Range | Description | +|---------|---------|-------|-------------| +| `git.commitTimeout` | 60000 (60s) | 1000-600000 | Timeout in milliseconds for git commit operations. Increase if pre-commit hooks (linting, tests, type checking) exceed the default timeout. | + +**When to increase:** If you see timeout errors during `il commit` or `il finish`, your pre-commit hooks are taking longer than the default 60 seconds. Set a higher value based on your typical hook duration. + Advanced Features ----------------- diff --git a/src/commands/commit.ts b/src/commands/commit.ts index 9a5adaf2..a3253239 100644 --- a/src/commands/commit.ts +++ b/src/commands/commit.ts @@ -160,6 +160,7 @@ export class CommitCommand { skipVerifySilent: input.wipCommit === true, // Don't warn for --wip-commit noReview: input.noReview ?? false, trailerType, + timeout: settings.git?.commitTimeout, ...(commitMessage && { message: commitMessage }), ...(detected.issueNumber !== undefined && { issueNumber: detected.issueNumber }), } diff --git a/src/commands/finish.ts b/src/commands/finish.ts index 9777c48c..e72ac346 100644 --- a/src/commands/finish.ts +++ b/src/commands/finish.ts @@ -692,6 +692,7 @@ export class FinishCommand { dryRun: options.dryRun ?? false, skipVerify, issuePrefix, + timeout: settings.git?.commitTimeout, } // Only add issueNumber if it's an issue @@ -1010,6 +1011,7 @@ export class FinishCommand { dryRun: false, skipVerify, issuePrefix, + timeout: settings.git?.commitTimeout, // Do NOT pass issueNumber for PRs - no "Fixes #" trailer needed }) getLogger().success('Changes committed') diff --git a/src/lib/CommitManager.test.ts b/src/lib/CommitManager.test.ts index adff6681..3fc38390 100644 --- a/src/lib/CommitManager.test.ts +++ b/src/lib/CommitManager.test.ts @@ -1593,4 +1593,73 @@ describe('CommitManager', () => { }) }) }) + + describe('Commit Timeout Configuration', () => { + beforeEach(() => { + vi.mocked(claude.detectClaudeCli).mockResolvedValue(false) + vi.mocked(prompt.promptCommitAction).mockResolvedValue('accept') + }) + + it('should pass custom timeout to executeGitCommand when timeout option is provided', async () => { + vi.mocked(git.executeGitCommand).mockResolvedValue('') + + await manager.commitChanges(mockWorktreePath, { + issuePrefix: '#', + noReview: true, + timeout: 120000, + dryRun: false, + }) + + expect(git.executeGitCommand).toHaveBeenCalledWith( + ['commit', '-m', 'WIP: Auto-commit uncommitted changes'], + { cwd: mockWorktreePath, timeout: 120000 } + ) + }) + + it('should not include timeout in options when timeout is undefined', async () => { + vi.mocked(git.executeGitCommand).mockResolvedValue('') + + await manager.commitChanges(mockWorktreePath, { + issuePrefix: '#', + noReview: true, + dryRun: false, + }) + + expect(git.executeGitCommand).toHaveBeenCalledWith( + ['commit', '-m', 'WIP: Auto-commit uncommitted changes'], + { cwd: mockWorktreePath, timeout: undefined } + ) + }) + + it('should pass timeout to interactive editor commit', async () => { + vi.mocked(prompt.promptCommitAction).mockResolvedValue('edit') + vi.mocked(git.executeGitCommand).mockResolvedValue('') + + await manager.commitChanges(mockWorktreePath, { + issuePrefix: '#', + timeout: 180000, + dryRun: false, + }) + + expect(git.executeGitCommand).toHaveBeenCalledWith( + ['commit', '-e', '-m', 'WIP: Auto-commit uncommitted changes'], + { cwd: mockWorktreePath, stdio: 'inherit', timeout: 180000 } + ) + }) + + it('should use 300000ms fallback when timeout not provided for interactive editing', async () => { + vi.mocked(prompt.promptCommitAction).mockResolvedValue('edit') + vi.mocked(git.executeGitCommand).mockResolvedValue('') + + await manager.commitChanges(mockWorktreePath, { + issuePrefix: '#', + dryRun: false, + }) + + expect(git.executeGitCommand).toHaveBeenCalledWith( + ['commit', '-e', '-m', 'WIP: Auto-commit uncommitted changes'], + { cwd: mockWorktreePath, stdio: 'inherit', timeout: 300000 } + ) + }) + }) }) diff --git a/src/lib/CommitManager.ts b/src/lib/CommitManager.ts index c41c8f69..cbadaf4f 100644 --- a/src/lib/CommitManager.ts +++ b/src/lib/CommitManager.ts @@ -95,7 +95,7 @@ export class CommitManager { if (options.skipVerify) { commitArgs.push('--no-verify') } - await executeGitCommand(commitArgs, { cwd: worktreePath }) + await executeGitCommand(commitArgs, { cwd: worktreePath, timeout: options.timeout }) } else { // Prompt user for action instead of going straight to editor const action = await promptCommitAction(message) @@ -110,7 +110,7 @@ export class CommitManager { if (options.skipVerify) { commitArgs.push('--no-verify') } - await executeGitCommand(commitArgs, { cwd: worktreePath }) + await executeGitCommand(commitArgs, { cwd: worktreePath, timeout: options.timeout }) } else { // action === 'edit': Use git editor for user review getLogger().info('Opening editor for commit message review...') @@ -135,7 +135,7 @@ export class CommitManager { await executeGitCommand(commitArgs, { cwd: worktreePath, stdio: 'inherit', - timeout: 300000 // 5 minutes for interactive editing + timeout: options.timeout ?? 300000 // Use configured timeout or default 5 minutes for interactive editing }) } } @@ -215,7 +215,7 @@ export class CommitManager { // Rewrite the file without comments for git commit -F await writeFile(commitMsgPath, finalMessage, 'utf-8') - await executeGitCommand(commitArgs, { cwd: worktreePath }) + await executeGitCommand(commitArgs, { cwd: worktreePath, timeout: options.timeout }) } finally { // Clean up - git normally handles this but we should be safe diff --git a/src/lib/SettingsManager.test.ts b/src/lib/SettingsManager.test.ts index f0843165..4f472ba2 100644 --- a/src/lib/SettingsManager.test.ts +++ b/src/lib/SettingsManager.test.ts @@ -12,6 +12,10 @@ vi.mock('../utils/logger.js', () => ({ }, })) +const defaultSettings = { + git: { commitTimeout: 60000 }, +} + describe('SettingsManager', () => { let settingsManager: SettingsManager @@ -50,7 +54,7 @@ describe('SettingsManager', () => { const result = await settingsManager.loadSettings(projectRoot) // sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly' - expect(result).toEqual({ ...validSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly' }) + expect(result).toEqual({ ...validSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings }) }) it('should return empty object when settings file does not exist', async () => { @@ -66,7 +70,7 @@ describe('SettingsManager', () => { const result = await settingsManager.loadSettings(projectRoot) // sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly' - expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly' }) + expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings }) }) it('should return empty object when .iloom directory does not exist', async () => { @@ -82,7 +86,7 @@ describe('SettingsManager', () => { const result = await settingsManager.loadSettings(projectRoot) // sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly' - expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly' }) + expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings }) }) it('should throw error for malformed JSON in settings file', async () => { @@ -136,7 +140,7 @@ describe('SettingsManager', () => { const result = await settingsManager.loadSettings(projectRoot) // sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly' - expect(result).toEqual({ ...emptyAgentsSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly' }) + expect(result).toEqual({ ...emptyAgentsSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings }) }) it('should handle settings file with null agents value', async () => { @@ -156,7 +160,7 @@ describe('SettingsManager', () => { const result = await settingsManager.loadSettings(projectRoot) // sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly' - expect(result).toEqual({ ...nullAgentsSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly' }) + expect(result).toEqual({ ...nullAgentsSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings }) }) it('should use process.cwd() when projectRoot not provided', async () => { @@ -179,7 +183,7 @@ describe('SettingsManager', () => { const result = await settingsManager.loadSettings() // sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly' - expect(result).toEqual({ ...validSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly' }) + expect(result).toEqual({ ...validSettings, sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings }) }) it('should load settings with mainBranch field', async () => { @@ -1522,7 +1526,7 @@ describe('SettingsManager', () => { const result = await settingsManager.loadSettings(projectRoot) // sourceEnvOnStart defaults to false, attribution defaults to 'upstreamOnly' - expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly' }) + expect(result).toEqual({ sourceEnvOnStart: false, attribution: 'upstreamOnly', ...defaultSettings }) }) it('should deep merge workflows with partial overrides', async () => { @@ -2724,4 +2728,171 @@ const error: { code?: string; message: string } = { expect(result).toBe('opus') }) }) + + describe('git.commitTimeout configuration', () => { + it('should return undefined git section when not specified in settings', async () => { + const projectRoot = '/test/project' + const settings = { + mainBranch: 'main', + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + const result = await settingsManager.loadSettings(projectRoot) + + // git section is optional, so it should be undefined when not provided + expect(result.git).toEqual(defaultSettings.git) + }) + + it('should apply default commitTimeout value (60000) when git section exists but commitTimeout not specified', async () => { + const projectRoot = '/test/project' + const settings = { + mainBranch: 'main', + ...defaultSettings + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + const result = await settingsManager.loadSettings(projectRoot) + + // Default should be applied by Zod schema when git object exists + expect(result.git?.commitTimeout).toBe(60000) + }) + + it('should accept custom commitTimeout value', async () => { + const projectRoot = '/test/project' + const settings = { + mainBranch: 'main', + git: { + commitTimeout: 120000, + }, + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + const result = await settingsManager.loadSettings(projectRoot) + + expect(result.git?.commitTimeout).toBe(120000) + }) + + it('should reject commitTimeout below minimum (1000ms)', async () => { + const projectRoot = '/test/project' + const settings = { + mainBranch: 'main', + git: { + commitTimeout: 500, // Below minimum + }, + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + await expect(settingsManager.loadSettings(projectRoot)).rejects.toThrow( + /Commit timeout must be at least 1000ms/ + ) + }) + + it('should reject commitTimeout above maximum (600000ms)', async () => { + const projectRoot = '/test/project' + const settings = { + mainBranch: 'main', + git: { + commitTimeout: 700000, // Above maximum + }, + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + await expect(settingsManager.loadSettings(projectRoot)).rejects.toThrow( + /Commit timeout cannot exceed 600000ms/ + ) + }) + + it('should accept minimum valid commitTimeout (1000ms)', async () => { + const projectRoot = '/test/project' + const settings = { + mainBranch: 'main', + git: { + commitTimeout: 1000, // Exact minimum + }, + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + const result = await settingsManager.loadSettings(projectRoot) + + expect(result.git?.commitTimeout).toBe(1000) + }) + + it('should accept maximum valid commitTimeout (600000ms)', async () => { + const projectRoot = '/test/project' + const settings = { + mainBranch: 'main', + git: { + commitTimeout: 600000, // Exact maximum + }, + } + + const error: { code?: string; message: string } = { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + } + + vi.mocked(readFile) + .mockRejectedValueOnce(error) // global settings + .mockResolvedValueOnce(JSON.stringify(settings)) // settings.json + .mockRejectedValueOnce(error) // settings.local.json + + const result = await settingsManager.loadSettings(projectRoot) + + expect(result.git?.commitTimeout).toBe(600000) + }) + }) }) diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index 823424d0..78ae3e0e 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -451,6 +451,17 @@ export const IloomSettingsSchema = z.object({ '"upstreamOnly" - only show for contributions to external repositories (e.g., open source). ' + '"on" - always show attribution.' ), + git: z + .object({ + commitTimeout: z + .number() + .min(1000, 'Commit timeout must be at least 1000ms') + .max(600000, 'Commit timeout cannot exceed 600000ms (10 minutes)') + .default(60000) + .describe('Timeout in milliseconds for git commit operations. Increase for long-running pre-commit hooks.'), + }) + .default({ }) // ensures the object always exists and uses default for the inner properties + .describe('Git operation settings'), }) /** @@ -684,6 +695,17 @@ export const IloomSettingsSchemaNoDefaults = z.object({ '"upstreamOnly" - only show for contributions to external repositories (e.g., open source). ' + '"on" - always show attribution.' ), + git: z + .object({ + commitTimeout: z + .number() + .min(1000, 'Commit timeout must be at least 1000ms') + .max(600000, 'Commit timeout cannot exceed 600000ms (10 minutes)') + .optional() + .describe('Timeout in milliseconds for git commit operations. Increase for long-running pre-commit hooks.'), + }) + .optional() + .describe('Git operation settings'), }) /** diff --git a/src/types/index.ts b/src/types/index.ts index 30601067..8b3aad02 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -332,6 +332,7 @@ export interface CommitOptions { skipVerify?: boolean // Skip pre-commit hooks (--no-verify flag) skipVerifySilent?: boolean // Skip without warning (for --wip-commit) trailerType?: 'Refs' | 'Fixes' // Trailer type: "Refs" references issue, "Fixes" closes it (default: 'Fixes' for backward compat) + timeout?: number // Timeout in milliseconds for commit operation } /** diff --git a/src/utils/git.ts b/src/utils/git.ts index 76c78c57..5ed7ba86 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -26,12 +26,12 @@ export class GitCommandError extends Error { */ export async function executeGitCommand( args: string[], - options?: { cwd?: string; timeout?: number; stdio?: 'inherit' | 'pipe'; env?: NodeJS.ProcessEnv } + options?: { cwd?: string; timeout?: number | undefined; stdio?: 'inherit' | 'pipe'; env?: NodeJS.ProcessEnv } ): Promise { try { const result = await execa('git', args, { cwd: options?.cwd ?? process.cwd(), - timeout: options?.timeout ?? 30000, + timeout: options?.timeout ?? 60000, encoding: 'utf8', stdio: options?.stdio ?? 'pipe', verbose: logger.isDebugEnabled(), From 6dedb8ec0abf1b4de852d59048899e9d3a3941b3 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Mon, 9 Feb 2026 13:52:18 -0800 Subject: [PATCH 25/27] feat: accept claude trust dialog --- src/lib/LoomManager.ts | 66 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts index ef37a54f..552a0897 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -1,6 +1,8 @@ import path from 'path' +import os from 'os' import fs from 'fs-extra' import fg from 'fast-glob' +import { execa } from 'execa' import { GitWorktreeManager } from './GitWorktreeManager.js' import type { IssueTracker } from './IssueTracker.js' import type { BranchNamingService } from './BranchNamingService.js' @@ -194,6 +196,11 @@ export class LoomManager { } } + // 10.1. Pre-accept Claude trust dialog if Claude is enabled + if (input.options?.enableClaude !== false) { + await this.acceptClaudeTrustDialog(worktreePath) + } + // 10.5. Handle github-draft-pr mode - push branch and create draft PR let draftPrNumber: number | undefined = undefined let draftPrUrl: string | undefined = undefined @@ -988,6 +995,57 @@ export class LoomManager { // The colorTerminal setting is passed through to launch options } + /** + * Pre-accept Claude Code trust dialog for a worktree path + * This allows Claude to launch without the interactive trust confirmation + * + * @param worktreePath - The path to the worktree to trust + */ + private async acceptClaudeTrustDialog(worktreePath: string): Promise { + const claudeJsonPath = path.join(os.homedir(), '.claude.json') + + // 1. Run claude command to register the project path (will fail but updates config) + try { + await execa('claude', [ + '--dangerously-skip-permissions', + '--no-session-persistence', + '--print' + ], { + cwd: worktreePath, + timeout: 10000, + reject: false, // Don't throw on non-zero exit + }) + } catch { + // Expected to fail - we just need it to register the path + } + + // 2. Read ~/.claude.json and set hasTrustDialogAccepted + try { + let claudeJson: Record = {} + if (await fs.pathExists(claudeJsonPath)) { + claudeJson = await fs.readJson(claudeJsonPath) + } + + // Ensure projects object exists (structure: { projects: { ... } }) + if (!claudeJson.projects || typeof claudeJson.projects !== 'object') { + claudeJson.projects = {} + } + const projects = claudeJson.projects as Record> + + // Ensure project entry exists + projects[worktreePath] ??= {} + + // Set trust flag + projects[worktreePath].hasTrustDialogAccepted = true + + // Write back + await fs.writeJson(claudeJsonPath, claudeJson, { spaces: 2 }) + getLogger().debug(`Accepted Claude trust dialog for: ${worktreePath}`) + } catch (error) { + getLogger().warn(`Failed to accept Claude trust dialog: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + /** * Map worktrees to loom objects * Reads loom metadata from MetadataManager with branch name parsing as fallback @@ -1165,8 +1223,14 @@ export class LoomManager { } } - // 7. Launch components (same as new worktree) + // 6.5. Pre-accept Claude trust dialog if Claude is enabled const enableClaude = input.options?.enableClaude !== false + if (enableClaude) { + await this.acceptClaudeTrustDialog(worktreePath) + } + + // 7. Launch components (same as new worktree) + // Note: enableClaude is already defined above const enableCode = input.options?.enableCode !== false const enableDevServer = input.options?.enableDevServer !== false const enableTerminal = input.options?.enableTerminal ?? false From 2829eaaa623cb2996a60aeb83272ef284b15ad37 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Tue, 10 Feb 2026 19:17:13 -0800 Subject: [PATCH 26/27] fix: resolve Jira issue key case sensitivity in branch name extraction Jira issue keys (e.g., PROJ-123) are case-sensitive in the REST API, but git branch names lowercase them (feat/issue-proj-123__desc). This broke API operations when keys were extracted from branches. Adds issueKey metadata field to store canonical case at loom creation, normalizeIdentifier() to IssueTracker interface, and case-insensitive detectInputType for Jira. Also relaxes ClaudeContextManager to accept string identifiers. Co-Authored-By: Claude Opus 4.6 --- src/commands/commit.ts | 10 ++++---- src/commands/finish.ts | 13 +++++----- src/commands/summary.ts | 16 ++++++------ src/lib/ClaudeContextManager.test.ts | 12 ++++----- src/lib/ClaudeContextManager.ts | 8 +++--- src/lib/GitHubService.ts | 5 ++++ src/lib/IssueTracker.ts | 4 +++ src/lib/LinearService.ts | 9 +++++++ src/lib/LoomManager.ts | 7 +++-- src/lib/MetadataManager.test.ts | 5 ++++ src/lib/MetadataManager.ts | 5 ++++ src/lib/providers/jira/JiraIssueTracker.ts | 30 ++++++++++++++-------- src/mcp/JiraIssueManagementProvider.ts | 10 +++++--- 13 files changed, 88 insertions(+), 46 deletions(-) diff --git a/src/commands/commit.ts b/src/commands/commit.ts index a3253239..b688430c 100644 --- a/src/commands/commit.ts +++ b/src/commands/commit.ts @@ -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 e72ac346..eef06a4f 100644 --- a/src/commands/finish.ts +++ b/src/commands/finish.ts @@ -352,8 +352,9 @@ export class FinishCommand { const { MetadataManager } = await import('../lib/MetadataManager.js') const metadataManager = new MetadataManager() const metadata = await metadataManager.readMetadata(worktree.path) - if (metadata?.issue_numbers?.[0]) { - result.number = metadata.issue_numbers[0] + const canonicalKey = metadata?.issueKey ?? metadata?.issue_numbers?.[0] + if (canonicalKey) { + result.number = canonicalKey } } } @@ -394,8 +395,8 @@ export class FinishCommand { const issueNumber = extractIssueNumber(currentDir) if (issueNumber !== null) { - // Use issue key from metadata if available (original case), otherwise use extracted (lowercase) - const originalIssueKey = metadata?.issue_numbers?.[0] ?? issueNumber + // 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 #${originalIssueKey} from directory: ${currentDir}` ) @@ -421,8 +422,8 @@ export class FinishCommand { // Try to extract issue from branch name const branchIssueNumber = extractIssueNumber(currentBranch) if (branchIssueNumber !== null) { - // Use issue key from metadata if available (original case), otherwise use extracted (lowercase) - const originalIssueKey = metadata?.issue_numbers?.[0] ?? branchIssueNumber + // 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 #${originalIssueKey} from branch: ${currentBranch}` ) diff --git a/src/commands/summary.ts b/src/commands/summary.ts index bdb5a00d..2ccc85a5 100644 --- a/src/commands/summary.ts +++ b/src/commands/summary.ts @@ -152,7 +152,7 @@ export class SummaryCommand { return { worktree: issueWorktree, loomType: metadata?.issueType ?? 'issue', - issueNumber: metadata?.issue_numbers?.[0] ?? String(issueNumber), + issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? String(issueNumber), } } @@ -180,7 +180,7 @@ export class SummaryCommand { return { worktree: issueWorktree, loomType: metadata?.issueType ?? 'issue', - issueNumber: metadata?.issue_numbers?.[0] ?? alphanumericId, + issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? alphanumericId, } } throw new Error(`No loom found for identifier: ${identifier}`) @@ -194,8 +194,8 @@ export class SummaryCommand { // For branch looms, try to get issue number from metadata let issueNumber: string | number | undefined - if (loomType === 'issue' && metadata?.issue_numbers?.[0]) { - issueNumber = metadata.issue_numbers[0] + if (loomType === 'issue' && (metadata?.issueKey || metadata?.issue_numbers?.[0])) { + issueNumber = metadata?.issueKey ?? metadata?.issue_numbers?.[0] } else if (loomType === 'pr' && metadata?.pr_numbers?.[0]) { issueNumber = metadata.pr_numbers[0] } @@ -256,7 +256,7 @@ export class SummaryCommand { return { worktree, loomType: metadata?.issueType ?? 'issue', - issueNumber: metadata?.issue_numbers?.[0] ?? String(issueNumber), + issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? String(issueNumber), } } throw new Error(`No loom found for auto-detected issue #${issueNumber}`) @@ -284,7 +284,7 @@ export class SummaryCommand { return { worktree, loomType: metadata?.issueType ?? 'issue', - issueNumber: metadata?.issue_numbers?.[0] ?? String(branchIssueNumber), + issueNumber: metadata?.issueKey ?? metadata?.issue_numbers?.[0] ?? String(branchIssueNumber), } } } @@ -297,8 +297,8 @@ export class SummaryCommand { // For branch looms, try to get issue number from metadata let resolvedIssueNumber: string | number | undefined - if (loomType === 'issue' && metadata?.issue_numbers?.[0]) { - resolvedIssueNumber = metadata.issue_numbers[0] + if (loomType === 'issue' && (metadata?.issueKey || metadata?.issue_numbers?.[0])) { + resolvedIssueNumber = metadata?.issueKey ?? metadata?.issue_numbers?.[0] } else if (loomType === 'pr' && metadata?.pr_numbers?.[0]) { resolvedIssueNumber = metadata.pr_numbers[0] } diff --git a/src/lib/ClaudeContextManager.test.ts b/src/lib/ClaudeContextManager.test.ts index d1355ee8..9cad1b33 100644 --- a/src/lib/ClaudeContextManager.test.ts +++ b/src/lib/ClaudeContextManager.test.ts @@ -85,29 +85,29 @@ describe('ClaudeContextManager', () => { ) }) - it('should throw error when issue identifier is not a number', async () => { + it('should throw error when issue identifier is undefined', async () => { const context = { type: 'issue', - identifier: 'not-a-number', + identifier: undefined, workspacePath: '/workspace', port: 3000, } as unknown as ClaudeContext await expect(manager.prepareContext(context)).rejects.toThrow( - 'Issue identifier must be a number' + 'Issue identifier is required' ) }) - it('should throw error when PR identifier is not a number', async () => { + it('should throw error when PR identifier is undefined', async () => { const context = { type: 'pr', - identifier: 'not-a-number', + identifier: undefined, workspacePath: '/workspace', port: 3000, } as unknown as ClaudeContext await expect(manager.prepareContext(context)).rejects.toThrow( - 'PR identifier must be a number' + 'PR identifier is required' ) }) diff --git a/src/lib/ClaudeContextManager.ts b/src/lib/ClaudeContextManager.ts index a041ee2f..0ad74f94 100644 --- a/src/lib/ClaudeContextManager.ts +++ b/src/lib/ClaudeContextManager.ts @@ -33,12 +33,12 @@ export class ClaudeContextManager { throw new Error('Workspace path is required') } - if (context.type === 'issue' && typeof context.identifier !== 'number') { - throw new Error('Issue identifier must be a number') + if (context.type === 'issue' && context.identifier === undefined) { + throw new Error('Issue identifier is required') } - if (context.type === 'pr' && typeof context.identifier !== 'number') { - throw new Error('PR identifier must be a number') + if (context.type === 'pr' && context.identifier === undefined) { + throw new Error('PR identifier is required') } logger.debug('Context prepared', { context }) diff --git a/src/lib/GitHubService.ts b/src/lib/GitHubService.ts index 3bfb5c4b..8d3a015d 100644 --- a/src/lib/GitHubService.ts +++ b/src/lib/GitHubService.ts @@ -378,6 +378,11 @@ export class GitHubService implements IssueTracker { } } + // 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/IssueTracker.ts b/src/lib/IssueTracker.ts index f44b6d40..f7af80e5 100644 --- a/src/lib/IssueTracker.ts +++ b/src/lib/IssueTracker.ts @@ -42,6 +42,10 @@ export interface IssueTracker { 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/LinearService.ts b/src/lib/LinearService.ts index 9eb523db..e7aed9be 100644 --- a/src/lib/LinearService.ts +++ b/src/lib/LinearService.ts @@ -202,6 +202,15 @@ export class LinearService implements IssueTracker { 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 552a0897..3cfe9419 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -382,6 +382,7 @@ export class LoomManager { branchName, worktreePath, issueType: input.type, + ...(input.type === 'issue' && { issueKey: this.issueTracker.normalizeIdentifier(input.identifier) }), issue_numbers, pr_numbers, issueTracker: this.issueTracker.providerName, @@ -1063,8 +1064,9 @@ export class LoomManager { type = loomMetadata.issueType // Extract identifier from metadata based on type - if (type === 'issue' && loomMetadata.issue_numbers?.[0]) { - const issueId = loomMetadata.issue_numbers[0] + // Prefer issueKey (canonical case) over issue_numbers (may be lowercase from branch extraction) + if (type === 'issue' && (loomMetadata.issueKey || loomMetadata.issue_numbers?.[0])) { + const issueId = loomMetadata.issueKey ?? loomMetadata.issue_numbers[0] ?? '' // Try to parse as number, otherwise keep as string (for alphanumeric IDs) const numericId = parseInt(issueId, 10) identifier = isNaN(numericId) ? issueId : numericId @@ -1307,6 +1309,7 @@ export class LoomManager { branchName, worktreePath, issueType: input.type, + ...(input.type === 'issue' && { issueKey: this.issueTracker.normalizeIdentifier(input.identifier) }), issue_numbers, pr_numbers, issueTracker: this.issueTracker.providerName, diff --git a/src/lib/MetadataManager.test.ts b/src/lib/MetadataManager.test.ts index 211eb4fc..4be76a88 100644 --- a/src/lib/MetadataManager.test.ts +++ b/src/lib/MetadataManager.test.ts @@ -279,6 +279,7 @@ describe('MetadataManager', () => { branchName: 'issue-42__auth-fix', worktreePath: '/Users/jane/dev/repo', issueType: 'issue', + issueKey: null, issue_numbers: ['42'], pr_numbers: [], issueTracker: 'github', @@ -373,6 +374,7 @@ describe('MetadataManager', () => { branchName: null, worktreePath: null, issueType: null, + issueKey: null, issue_numbers: [], pr_numbers: [], issueTracker: null, @@ -624,6 +626,7 @@ describe('MetadataManager', () => { branchName: 'issue-1__feat', worktreePath: '/Users/alice/project1', issueType: 'issue', + issueKey: null, issue_numbers: ['1'], pr_numbers: [], issueTracker: 'github', @@ -642,6 +645,7 @@ describe('MetadataManager', () => { branchName: 'issue-2__fix', worktreePath: '/Users/bob/project2', issueType: 'issue', + issueKey: null, issue_numbers: ['2'], pr_numbers: [], issueTracker: 'github', @@ -744,6 +748,7 @@ describe('MetadataManager', () => { branchName: null, worktreePath: null, issueType: null, + issueKey: null, issue_numbers: [], pr_numbers: [], issueTracker: null, diff --git a/src/lib/MetadataManager.ts b/src/lib/MetadataManager.ts index f314efde..a68bb622 100644 --- a/src/lib/MetadataManager.ts +++ b/src/lib/MetadataManager.ts @@ -16,6 +16,7 @@ export interface MetadataFile { branchName?: string worktreePath?: string issueType?: 'branch' | 'issue' | 'pr' + issueKey?: string // Canonical, properly-cased issue key (e.g., "PROJ-123") issue_numbers?: string[] pr_numbers?: string[] issueTracker?: string @@ -45,6 +46,7 @@ export interface WriteMetadataInput { branchName: string worktreePath: string issueType: 'branch' | 'issue' | 'pr' + issueKey?: string // Canonical, properly-cased issue key (e.g., "PROJ-123") issue_numbers: string[] pr_numbers: string[] issueTracker: string @@ -75,6 +77,7 @@ export interface LoomMetadata { branchName: string | null worktreePath: string | null issueType: 'branch' | 'issue' | 'pr' | null + issueKey: string | null // Canonical, properly-cased issue key (e.g., "PROJ-123") issue_numbers: string[] pr_numbers: string[] issueTracker: string | null @@ -124,6 +127,7 @@ export class MetadataManager { branchName: data.branchName ?? null, worktreePath: data.worktreePath ?? null, issueType: data.issueType ?? null, + issueKey: data.issueKey ?? null, issue_numbers: data.issue_numbers ?? [], pr_numbers: data.pr_numbers ?? [], issueTracker: data.issueTracker ?? null, @@ -201,6 +205,7 @@ export class MetadataManager { branchName: input.branchName, worktreePath: input.worktreePath, issueType: input.issueType, + ...(input.issueKey && { issueKey: input.issueKey }), issue_numbers: input.issue_numbers, pr_numbers: input.pr_numbers, issueTracker: input.issueTracker, diff --git a/src/lib/providers/jira/JiraIssueTracker.ts b/src/lib/providers/jira/JiraIssueTracker.ts index e5fd583a..b0bb441d 100644 --- a/src/lib/providers/jira/JiraIssueTracker.ts +++ b/src/lib/providers/jira/JiraIssueTracker.ts @@ -40,20 +40,28 @@ export class JiraIssueTracker implements IssueTracker { }) } + /** + * 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 + * Jira issues follow pattern: PROJECTKEY-123 (case-insensitive) */ async detectInputType(input: string): Promise { - // Pattern: PROJECTKEY-123 - const jiraPattern = /^([A-Z][A-Z0-9]+)-(\d+)$/ + // 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 = input + const issueKey = this.normalizeIdentifier(input) getLogger().debug('Checking if input is a Jira issue', { issueKey }) // Verify the issue exists @@ -70,7 +78,7 @@ export class JiraIssueTracker implements IssueTracker { * Fetch issue details */ async fetchIssue(identifier: string | number): Promise { - const issueKey = String(identifier) + const issueKey = this.normalizeIdentifier(identifier) getLogger().debug('Fetching Jira issue', { issueKey }) const jiraIssue = await this.client.getIssue(issueKey) @@ -135,7 +143,7 @@ export class JiraIssueTracker implements IssueTracker { * Get issue URL */ async getIssueUrl(identifier: string | number): Promise { - const issueKey = String(identifier) + const issueKey = this.normalizeIdentifier(identifier) return `${this.config.host}/browse/${issueKey}` } @@ -144,7 +152,7 @@ export class JiraIssueTracker implements IssueTracker { * Uses configured transition mapping or default transition name */ async moveIssueToInProgress(identifier: string | number): Promise { - const issueKey = String(identifier) + const issueKey = this.normalizeIdentifier(identifier) getLogger().debug('Moving Jira issue to In Progress', { issueKey }) // Get available transitions @@ -177,7 +185,7 @@ export class JiraIssueTracker implements IssueTracker { * Uses configured transition mapping or default transition name */ async moveIssueToReadyForReview(identifier: string | number): Promise { - const issueKey = String(identifier) + const issueKey = this.normalizeIdentifier(identifier) getLogger().debug('Moving Jira issue to Ready for Review', { issueKey }) // Get available transitions @@ -238,7 +246,7 @@ ${entity.assignees.length > 0 ? `Assignees: ${entity.assignees.join(', ')}` : '' createdAt: string updatedAt: string }>> { - const issueKey = String(identifier) + const issueKey = this.normalizeIdentifier(identifier) getLogger().debug('Fetching Jira comments', { issueKey }) const comments = await this.client.getComments(issueKey) @@ -257,7 +265,7 @@ ${entity.assignees.length > 0 ? `Assignees: ${entity.assignees.join(', ')}` : '' * Add a comment to an issue */ async addComment(identifier: string | number, body: string): Promise<{ id: string }> { - const issueKey = String(identifier) + const issueKey = this.normalizeIdentifier(identifier) getLogger().debug('Adding Jira comment', { issueKey }) const comment = await this.client.addComment(issueKey, body) @@ -268,7 +276,7 @@ ${entity.assignees.length > 0 ? `Assignees: ${entity.assignees.join(', ')}` : '' * Update an existing comment */ async updateComment(identifier: string | number, commentId: string, body: string): Promise { - const issueKey = String(identifier) + const issueKey = this.normalizeIdentifier(identifier) getLogger().debug('Updating Jira comment', { issueKey, commentId }) await this.client.updateComment(issueKey, commentId, body) diff --git a/src/mcp/JiraIssueManagementProvider.ts b/src/mcp/JiraIssueManagementProvider.ts index aef69345..d1605af9 100644 --- a/src/mcp/JiraIssueManagementProvider.ts +++ b/src/mcp/JiraIssueManagementProvider.ts @@ -204,13 +204,14 @@ export class JiraIssueManagementProvider implements IssueManagementProvider { */ 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(number, body) + const comment = await this.tracker.addComment(normalizedKey, body) return { id: comment.id, - url: `${this.tracker.getConfig().host}/browse/${number}?focusedCommentId=${comment.id}`, + url: `${this.tracker.getConfig().host}/browse/${normalizedKey}?focusedCommentId=${comment.id}`, created_at: new Date().toISOString(), } } @@ -220,13 +221,14 @@ export class JiraIssueManagementProvider implements IssueManagementProvider { */ async updateComment(input: UpdateCommentInput): Promise { const { commentId, number, body } = input + const normalizedKey = this.tracker.normalizeIdentifier(number) // Update comment via tracker - await this.tracker.updateComment(number, commentId, body) + await this.tracker.updateComment(normalizedKey, commentId, body) return { id: commentId, - url: `${this.tracker.getConfig().host}/browse/${number}?focusedCommentId=${commentId}`, + url: `${this.tracker.getConfig().host}/browse/${normalizedKey}?focusedCommentId=${commentId}`, updated_at: new Date().toISOString(), } } From 313a80b999ffcd0d666320411c79fd0c2c326aae Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Wed, 11 Feb 2026 07:46:30 -0800 Subject: [PATCH 27/27] feat: export ILOOM_LOOM and ILOOM_COLOR_HEX env vars in shell and dev-server commands Both `il shell` and `il dev-server` now inject ILOOM_LOOM (loom identifier) and ILOOM_COLOR_HEX (hex color from metadata) into the process environment, allowing downstream tools like Vite to detect which loom they're running in and apply visual identification. Co-Authored-By: Claude Opus 4.6 --- README.md | 9 +++ docs/vite-iloom-color.md | 101 ++++++++++++++++++++++++++++++++ src/commands/dev-server.test.ts | 93 +++++++++++++++++++++++++++-- src/commands/dev-server.ts | 20 ++++++- src/commands/shell.test.ts | 74 ++++++++++++++++++++++- src/commands/shell.ts | 10 +++- 6 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 docs/vite-iloom-color.md diff --git a/README.md b/README.md index 42677a8b..65725eb0 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,15 @@ Each loom is a fully isolated container for your work: * **Environment Variables:** Each loom has its own environment files (`.env`, `.env.local`, `.env.development`, `.env.development.local`). Uses `development` by default, override with `DOTENV_FLOW_NODE_ENV`. See [Secret Storage Limitations](#multi-language-project-support) for frameworks with encrypted credentials. + When inside a loom shell (`il shell`), the following environment variables are automatically set: + + | Variable | Description | Example | + |----------|-------------|---------| + | `ILOOM_LOOM` | Loom identifier for PS1 customization | `issue-87` | + | `ILOOM_COLOR_HEX` | Hex color assigned to this loom (if available) | `#dcebff` | + + `ILOOM_COLOR_HEX` is useful for downstream tools that want to visually distinguish looms. For example, a Vite app can read it via `import.meta.env.VITE_ILOOM_COLOR_HEX` to tint the UI. See [Vite Integration Guide](docs/vite-iloom-color.md) for details. + * **Unique Runtime:** * **Web Apps:** Runs on a deterministic port (e.g., base port 3000 + issue #25 = 3025). diff --git a/docs/vite-iloom-color.md b/docs/vite-iloom-color.md new file mode 100644 index 00000000..a5cf0d1a --- /dev/null +++ b/docs/vite-iloom-color.md @@ -0,0 +1,101 @@ +# Using ILOOM_COLOR_HEX in a Vite App + +When you run a dev server inside a loom shell (`il shell`), the `ILOOM_COLOR_HEX` environment variable is automatically set to the hex color assigned to that loom (e.g., `#dcebff`). This lets your app visually distinguish which loom it's running in. + +## Setup + +### 1. Prefix the variable for Vite + +Vite only exposes env vars that start with `VITE_`. Add a `.env.local` (or use your existing one) in the project root: + +```bash +# .env.local +VITE_ILOOM_COLOR_HEX=$ILOOM_COLOR_HEX +``` + +Or, if you use `il shell` and then start the dev server manually, you can set it inline: + +```bash +VITE_ILOOM_COLOR_HEX=$ILOOM_COLOR_HEX pnpm dev +``` + +### 2. Access it in your app + +```ts +// src/main.ts (or any client-side file) +const loomColor = import.meta.env.VITE_ILOOM_COLOR_HEX + +if (loomColor) { + document.documentElement.style.setProperty('--loom-color', loomColor) +} +``` + +### 3. Use the CSS variable + +```css +/* src/styles.css */ +:root { + --loom-color: transparent; /* fallback when not in a loom */ +} + +body { + border-top: 4px solid var(--loom-color); +} +``` + +## Alternative: Use `define` in vite.config.ts + +If you don't want an extra `.env.local` entry, you can inject the value at build time: + +```ts +// vite.config.ts +import { defineConfig } from 'vite' + +export default defineConfig({ + define: { + __ILOOM_COLOR_HEX__: JSON.stringify(process.env.ILOOM_COLOR_HEX ?? ''), + }, +}) +``` + +Then in your app: + +```ts +declare const __ILOOM_COLOR_HEX__: string + +if (__ILOOM_COLOR_HEX__) { + document.documentElement.style.setProperty('--loom-color', __ILOOM_COLOR_HEX__) +} +``` + +## React Example + +```tsx +// src/components/LoomIndicator.tsx +function LoomIndicator() { + const color = import.meta.env.VITE_ILOOM_COLOR_HEX + if (!color) return null + + return ( +
+ ) +} +``` + +## How It Works + +1. `il shell ` reads the loom's metadata (stored in `~/.config/iloom-ai/looms/`) and exports `ILOOM_COLOR_HEX` into the shell environment. +2. Any process spawned from that shell inherits the variable. +3. Vite picks it up (via `VITE_` prefix or `define`) and makes it available to client code. + +When you're not inside a loom shell, the variable is simply absent and your fallback styles apply. diff --git a/src/commands/dev-server.test.ts b/src/commands/dev-server.test.ts index a565181e..b8ecf8ed 100644 --- a/src/commands/dev-server.test.ts +++ b/src/commands/dev-server.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { DevServerCommand } from './dev-server.js' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js' import { DevServerManager } from '../lib/DevServerManager.js' import { SettingsManager } from '../lib/SettingsManager.js' @@ -12,6 +13,7 @@ import fs from 'fs-extra' // Mock dependencies vi.mock('../lib/GitWorktreeManager.js') +vi.mock('../lib/MetadataManager.js') vi.mock('../lib/ProjectCapabilityDetector.js') vi.mock('../lib/DevServerManager.js') vi.mock('../utils/IdentifierParser.js') @@ -40,6 +42,7 @@ vi.mock('../utils/logger.js', () => ({ describe('DevServerCommand', () => { let command: DevServerCommand let mockGitWorktreeManager: GitWorktreeManager + let mockMetadataManager: MetadataManager let mockCapabilityDetector: ProjectCapabilityDetector let mockDevServerManager: DevServerManager let mockIdentifierParser: IdentifierParser @@ -54,11 +57,17 @@ describe('DevServerCommand', () => { beforeEach(() => { mockGitWorktreeManager = new GitWorktreeManager() + mockMetadataManager = new MetadataManager() mockCapabilityDetector = new ProjectCapabilityDetector() mockDevServerManager = new DevServerManager() mockIdentifierParser = new IdentifierParser(mockGitWorktreeManager) mockSettingsManager = new SettingsManager() + // Mock MetadataManager - default to returning metadata with color + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + colorHex: '#dcebff', + }) + // Mock DevServerManager methods vi.mocked(mockDevServerManager.isServerRunning).mockResolvedValue(false) vi.mocked(mockDevServerManager.runServerForeground).mockImplementation( @@ -83,7 +92,8 @@ describe('DevServerCommand', () => { mockCapabilityDetector, mockIdentifierParser, mockDevServerManager, - mockSettingsManager + mockSettingsManager, + mockMetadataManager ) }) @@ -458,7 +468,7 @@ describe('DevServerCommand', () => { 3087, false, expect.any(Function), - { DATABASE_URL: 'postgres://test', API_KEY: 'secret' } + expect.objectContaining({ DATABASE_URL: 'postgres://test', API_KEY: 'secret', ILOOM_LOOM: '87' }) ) }) @@ -475,7 +485,7 @@ describe('DevServerCommand', () => { 3087, false, expect.any(Function), - {} + expect.objectContaining({ ILOOM_LOOM: '87' }) ) }) @@ -490,7 +500,7 @@ describe('DevServerCommand', () => { 3087, false, expect.any(Function), - {} + expect.objectContaining({ ILOOM_LOOM: '87' }) ) }) @@ -513,7 +523,7 @@ describe('DevServerCommand', () => { 3087, false, expect.any(Function), - {} + expect.objectContaining({ ILOOM_LOOM: '87' }) ) }) @@ -533,4 +543,77 @@ describe('DevServerCommand', () => { expect(mockDevServerManager.runServerForeground).toHaveBeenCalled() }) }) + + describe('loom environment variables', () => { + beforeEach(() => { + vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({ + type: 'issue', + number: 87, + originalInput: '87', + }) + + vi.mocked(mockGitWorktreeManager.findWorktreeForIssue).mockResolvedValue(mockWorktree) + + const mockCapabilities: ProjectCapabilities = { + capabilities: ['web'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue(mockCapabilities) + + vi.mocked(fs.pathExists).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue('PORT=3087\n') + }) + + it('should set ILOOM_LOOM env var to original input', async () => { + await command.execute({ identifier: '87' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).toHaveProperty('ILOOM_LOOM', '87') + }) + + it('should set ILOOM_LOOM for PR identifier', async () => { + vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({ + type: 'pr', + number: 42, + originalInput: '42', + }) + vi.mocked(mockGitWorktreeManager.findWorktreeForPR).mockResolvedValue(mockWorktree) + + await command.execute({ identifier: '42' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).toHaveProperty('ILOOM_LOOM', '42') + }) + + it('should set ILOOM_COLOR_HEX from metadata', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + colorHex: '#dcebff', + }) + + await command.execute({ identifier: '87' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).toHaveProperty('ILOOM_COLOR_HEX', '#dcebff') + }) + + it('should not set ILOOM_COLOR_HEX when metadata has no colorHex', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + colorHex: null, + }) + + await command.execute({ identifier: '87' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).not.toHaveProperty('ILOOM_COLOR_HEX') + }) + + it('should not set ILOOM_COLOR_HEX when metadata is null', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue(null) + + await command.execute({ identifier: '87' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).not.toHaveProperty('ILOOM_COLOR_HEX') + }) + }) }) diff --git a/src/commands/dev-server.ts b/src/commands/dev-server.ts index c1739f72..993f6183 100644 --- a/src/commands/dev-server.ts +++ b/src/commands/dev-server.ts @@ -1,5 +1,6 @@ import path from 'path' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js' import { DevServerManager } from '../lib/DevServerManager.js' import { SettingsManager } from '../lib/SettingsManager.js' @@ -42,7 +43,8 @@ export class DevServerCommand { private capabilityDetector = new ProjectCapabilityDetector(), private identifierParser = new IdentifierParser(new GitWorktreeManager()), private devServerManager = new DevServerManager(), - private settingsManager = new SettingsManager() + private settingsManager = new SettingsManager(), + private metadataManager = new MetadataManager() ) {} /** @@ -82,6 +84,15 @@ export class DevServerCommand { } } + // 3b. Set ILOOM_LOOM for loom identification + envOverrides.ILOOM_LOOM = this.formatLoomIdentifier(parsed) + + // 3c. Set ILOOM_COLOR_HEX from loom metadata if available + const metadata = await this.metadataManager.readMetadata(worktree.path) + if (metadata?.colorHex) { + envOverrides.ILOOM_COLOR_HEX = metadata.colorHex + } + // 4. Detect project capabilities const { capabilities } = await this.capabilityDetector.detectCapabilities(worktree.path) @@ -312,4 +323,11 @@ export class DevServerCommand { } return `branch "${parsed.branchName}"${autoLabel}` } + + /** + * Format loom identifier for ILOOM_LOOM env var + */ + private formatLoomIdentifier(parsed: ParsedDevServerInput): string { + return parsed.originalInput + } } diff --git a/src/commands/shell.test.ts b/src/commands/shell.test.ts index 044786f1..63721038 100644 --- a/src/commands/shell.test.ts +++ b/src/commands/shell.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ShellCommand } from './shell.js' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import { IdentifierParser } from '../utils/IdentifierParser.js' import { SettingsManager } from '../lib/SettingsManager.js' import type { GitWorktree } from '../types/worktree.js' @@ -9,6 +10,7 @@ import { execa } from 'execa' // Mock dependencies vi.mock('../lib/GitWorktreeManager.js') +vi.mock('../lib/MetadataManager.js') vi.mock('../utils/IdentifierParser.js') vi.mock('../lib/SettingsManager.js') vi.mock('fs-extra') @@ -37,6 +39,7 @@ import { loadWorkspaceEnv, getDotenvFlowFiles } from '../utils/env.js' describe('ShellCommand', () => { let command: ShellCommand let mockGitWorktreeManager: GitWorktreeManager + let mockMetadataManager: MetadataManager let mockIdentifierParser: IdentifierParser let mockSettingsManager: SettingsManager @@ -49,6 +52,7 @@ describe('ShellCommand', () => { beforeEach(() => { mockGitWorktreeManager = new GitWorktreeManager() + mockMetadataManager = new MetadataManager() mockIdentifierParser = new IdentifierParser(mockGitWorktreeManager) mockSettingsManager = new SettingsManager() @@ -57,6 +61,27 @@ describe('ShellCommand', () => { sourceEnvOnStart: true, }) + // Default metadata mock - return colorHex + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + description: 'test', + created_at: null, + branchName: null, + worktreePath: null, + issueType: null, + issueKey: null, + issue_numbers: [], + pr_numbers: [], + issueTracker: null, + colorHex: '#dcebff', + sessionId: null, + projectPath: null, + issueUrls: {}, + prUrls: {}, + draftPrNumber: null, + capabilities: [], + parentLoom: null, + }) + // Set up env mocks vi.mocked(loadWorkspaceEnv).mockReturnValue({ parsed: { PORT: '3087', NODE_ENV: 'development' } }) vi.mocked(getDotenvFlowFiles).mockReturnValue(['.env', '.env.local', '.env.development', '.env.development.local']) @@ -64,7 +89,8 @@ describe('ShellCommand', () => { command = new ShellCommand( mockGitWorktreeManager, mockIdentifierParser, - mockSettingsManager + mockSettingsManager, + mockMetadataManager ) }) @@ -227,6 +253,52 @@ describe('ShellCommand', () => { const envArg = execaCall[2]?.env as Record expect(envArg.ILOOM_LOOM).toBe('pr-42') }) + + it('should set ILOOM_COLOR_HEX when metadata has colorHex', async () => { + await command.execute({ identifier: '87' }) + + const execaCall = vi.mocked(execa).mock.calls[0] + const envArg = execaCall[2]?.env as Record + expect(envArg.ILOOM_COLOR_HEX).toBe('#dcebff') + }) + + it('should not set ILOOM_COLOR_HEX when metadata is null', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue(null) + + await command.execute({ identifier: '87' }) + + const execaCall = vi.mocked(execa).mock.calls[0] + const envArg = execaCall[2]?.env as Record + expect(envArg.ILOOM_COLOR_HEX).toBeUndefined() + }) + + it('should not set ILOOM_COLOR_HEX when metadata.colorHex is null', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + description: 'test', + created_at: null, + branchName: null, + worktreePath: null, + issueType: null, + issueKey: null, + issue_numbers: [], + pr_numbers: [], + issueTracker: null, + colorHex: null, + sessionId: null, + projectPath: null, + issueUrls: {}, + prUrls: {}, + draftPrNumber: null, + capabilities: [], + parentLoom: null, + }) + + await command.execute({ identifier: '87' }) + + const execaCall = vi.mocked(execa).mock.calls[0] + const envArg = execaCall[2]?.env as Record + expect(envArg.ILOOM_COLOR_HEX).toBeUndefined() + }) }) describe('shell detection', () => { diff --git a/src/commands/shell.ts b/src/commands/shell.ts index 4a2cd09e..c97d8595 100644 --- a/src/commands/shell.ts +++ b/src/commands/shell.ts @@ -2,6 +2,7 @@ import path from 'path' import { execa } from 'execa' import fs from 'fs-extra' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import { SettingsManager } from '../lib/SettingsManager.js' import { IdentifierParser } from '../utils/IdentifierParser.js' import { loadWorkspaceEnv, getDotenvFlowFiles } from '../utils/env.js' @@ -29,7 +30,8 @@ export class ShellCommand { constructor( private gitWorktreeManager = new GitWorktreeManager(), private identifierParser = new IdentifierParser(new GitWorktreeManager()), - private settingsManager = new SettingsManager() + private settingsManager = new SettingsManager(), + private metadataManager = new MetadataManager() ) {} async execute(input: ShellCommandInput): Promise { @@ -66,6 +68,12 @@ export class ShellCommand { const loomIdentifier = this.formatLoomIdentifier(parsed) envVars.ILOOM_LOOM = loomIdentifier + // 5b. Set ILOOM_COLOR_HEX from loom metadata if available + const metadata = await this.metadataManager.readMetadata(worktree.path) + if (metadata?.colorHex) { + envVars.ILOOM_COLOR_HEX = metadata.colorHex + } + // 6. Detect shell const shell = this.detectShell()