From 8ad8c52fe26e0977a0a80862cbba1a1d6c597165 Mon Sep 17 00:00:00 2001 From: Kazim Zaidi Date: Tue, 17 Mar 2026 12:16:45 +0530 Subject: [PATCH] Add branchFormat support for Jira issues Allow Jira users to configure a custom branch naming template via issueManagement.jira.branchFormat in settings.json. The template supports {ticketId} and {slug} variables, producing branches like "print-1234-fix-deps-bug" instead of the default "feat/issue-PRINT-1234__fix-deps-bug" format. Changes: - Add TemplateBranchNameStrategy and slugify utility - Add branchFormat to Jira settings schema (both v1 and v2) - Wire branchFormat through JiraIssueTracker -> IssueTracker interface -> LoomManager -> BranchNamingService - Expose branchFormat on LinearService for parity - Add comprehensive tests for template strategy Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/BranchNamingService.test.ts | 95 ++++++++++++++++++++++ src/lib/BranchNamingService.ts | 63 +++++++++++--- src/lib/IssueTracker.ts | 3 + src/lib/LinearService.ts | 2 + src/lib/LoomManager.ts | 4 +- src/lib/SettingsManager.ts | 8 ++ src/lib/providers/jira/JiraIssueTracker.ts | 7 ++ src/types/branch-naming.ts | 1 + src/utils/claude.ts | 2 +- 9 files changed, 173 insertions(+), 12 deletions(-) diff --git a/src/lib/BranchNamingService.test.ts b/src/lib/BranchNamingService.test.ts index 819b5f33..fd4a1bb1 100644 --- a/src/lib/BranchNamingService.test.ts +++ b/src/lib/BranchNamingService.test.ts @@ -3,6 +3,8 @@ import { DefaultBranchNamingService, SimpleBranchNameStrategy, ClaudeBranchNameStrategy, + TemplateBranchNameStrategy, + slugify, type BranchNameStrategy, } from './BranchNamingService.js' @@ -191,4 +193,97 @@ describe('BranchNamingService', () => { expect(generateBranchName).toHaveBeenCalledWith('Default Model Test', 789, 'haiku') }) }) + + describe('TemplateBranchNameStrategy', () => { + it('should substitute {ticketId} and {slug}', async () => { + const strategy = new TemplateBranchNameStrategy('{ticketId}-{slug}') + const branchName = await strategy.generate('PRINT-1234', 'Fix dependency bug') + expect(branchName).toBe('print-1234-fix-dependency-bug') + }) + + it('should handle Jira-style issue keys', async () => { + const strategy = new TemplateBranchNameStrategy('{ticketId}-{slug}') + const branchName = await strategy.generate('HB-42', 'Add dark mode toggle') + expect(branchName).toBe('hb-42-add-dark-mode-toggle') + }) + + it('should handle template with only ticketId', async () => { + const strategy = new TemplateBranchNameStrategy('{ticketId}') + const branchName = await strategy.generate('PROJ-99', 'Some title') + expect(branchName).toBe('proj-99') + }) + + it('should handle template with slashes', async () => { + const strategy = new TemplateBranchNameStrategy('feature/{ticketId}-{slug}') + const branchName = await strategy.generate('ENG-500', 'Update auth flow') + expect(branchName).toBe('feature/eng-500-update-auth-flow') + }) + + it('should truncate slug to 40 characters', async () => { + const strategy = new TemplateBranchNameStrategy('{ticketId}-{slug}') + const branchName = await strategy.generate( + 'PRINT-1', + 'This is a very long title that should definitely be truncated at some point' + ) + const slug = branchName.replace('print-1-', '') + expect(slug.length).toBeLessThanOrEqual(40) + }) + + it('should remove trailing hyphens', async () => { + const strategy = new TemplateBranchNameStrategy('{ticketId}-{slug}') + const branchName = await strategy.generate('X-1', '!!!') + expect(branchName).toBe('x-1') + }) + }) + + describe('slugify', () => { + it('should convert to lowercase and replace special chars', () => { + expect(slugify('Fix Bug #123')).toBe('fix-bug-123') + }) + + it('should respect maxLength', () => { + expect(slugify('a very long string here', 10).length).toBeLessThanOrEqual(10) + }) + + it('should trim leading and trailing hyphens', () => { + expect(slugify('---hello---')).toBe('hello') + }) + }) + + describe('DefaultBranchNamingService with branchFormat', () => { + it('should use TemplateBranchNameStrategy when branchFormat is provided', async () => { + const service = new DefaultBranchNamingService({ useClaude: false }) + const branchName = await service.generateBranchName({ + issueNumber: 'PRINT-1234', + title: 'Fix deps bug', + branchFormat: '{ticketId}-{slug}', + }) + expect(branchName).toBe('print-1234-fix-deps-bug') + }) + + it('should prefer explicit strategy over branchFormat', async () => { + class CustomStrategy implements BranchNameStrategy { + async generate(): Promise { + return 'custom/branch' + } + } + const service = new DefaultBranchNamingService({ useClaude: false }) + const branchName = await service.generateBranchName({ + issueNumber: 'PRINT-1234', + title: 'Fix deps bug', + strategy: new CustomStrategy(), + branchFormat: '{ticketId}-{slug}', + }) + expect(branchName).toBe('custom/branch') + }) + + it('should fall back to default strategy when no branchFormat', async () => { + const service = new DefaultBranchNamingService({ useClaude: false }) + const branchName = await service.generateBranchName({ + issueNumber: 123, + title: 'Test Issue', + }) + expect(branchName).toBe('feat/issue-123__test-issue') + }) + }) }) diff --git a/src/lib/BranchNamingService.ts b/src/lib/BranchNamingService.ts index 78f1844d..080edf02 100644 --- a/src/lib/BranchNamingService.ts +++ b/src/lib/BranchNamingService.ts @@ -1,6 +1,21 @@ import type { BranchNameStrategy, BranchGenerationOptions } from '../types/branch-naming.js' import { getLogger } from '../utils/logger-context.js' +// ============================================ +// Shared Utilities +// ============================================ + +/** + * Create a URL-safe slug from a title string + */ +export function slugify(title: string, maxLength = 20): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, maxLength) +} + // ============================================ // Strategy Classes // ============================================ @@ -11,13 +26,7 @@ import { getLogger } from '../utils/logger-context.js' */ export class SimpleBranchNameStrategy implements BranchNameStrategy { async generate(issueNumber: string | number, title: string): Promise { - // Create a simple slug from the title - const slug = title - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .substring(0, 20) // Keep it short for the simple strategy - + const slug = slugify(title) return `feat/issue-${issueNumber}__${slug}` } } @@ -36,6 +45,32 @@ export class ClaudeBranchNameStrategy implements BranchNameStrategy { } } +/** + * Template-based branch naming strategy + * Uses a user-defined template with variable substitution + * + * Supported variables: + * {ticketId} - Full issue identifier (e.g., "PRINT-1234") + * {slug} - Slugified title (lowercase, hyphens, max 40 chars) + * + * Example: "{ticketId}-{slug}" → "PRINT-1234-fix-deps-bug" + */ +export class TemplateBranchNameStrategy implements BranchNameStrategy { + constructor(private template: string) {} + + async generate(issueNumber: string | number, title: string): Promise { + const slug = slugify(title, 40) + const ticketId = String(issueNumber) + + const branchName = this.template + .replace(/\{ticketId\}/g, ticketId) + .replace(/\{slug\}/g, slug) + + // Normalize: lowercase, remove trailing hyphens + return branchName.toLowerCase().replace(/-+$/g, '') + } +} + // ============================================ // Service Interface and Implementation // ============================================ @@ -73,15 +108,23 @@ export class DefaultBranchNamingService implements BranchNamingService { } async generateBranchName(options: BranchGenerationOptions): Promise { - const { issueNumber, title, strategy } = options + const { issueNumber, title, strategy, branchFormat } = options - // Use provided strategy or fall back to default - const nameStrategy = strategy ?? this.defaultStrategy + // Priority: explicit strategy > branchFormat template > default strategy + let nameStrategy: BranchNameStrategy + if (strategy) { + nameStrategy = strategy + } else if (branchFormat) { + nameStrategy = new TemplateBranchNameStrategy(branchFormat) + } else { + nameStrategy = this.defaultStrategy + } getLogger().debug('Generating branch name', { issueNumber, title, strategy: nameStrategy.constructor.name, + branchFormat, }) return nameStrategy.generate(issueNumber, title) diff --git a/src/lib/IssueTracker.ts b/src/lib/IssueTracker.ts index 5e084151..497a635c 100644 --- a/src/lib/IssueTracker.ts +++ b/src/lib/IssueTracker.ts @@ -49,4 +49,7 @@ export interface IssueTracker { // Context extraction - formats issue/PR for AI prompts extractContext(entity: Issue | PullRequest): string + + // Branch naming - optional custom format template + branchFormat?: string | undefined } diff --git a/src/lib/LinearService.ts b/src/lib/LinearService.ts index 6f8f3e47..aff01b5c 100644 --- a/src/lib/LinearService.ts +++ b/src/lib/LinearService.ts @@ -35,6 +35,7 @@ export class LinearService implements IssueTracker { // IssueTracker interface implementation readonly providerName = 'linear' readonly supportsPullRequests = false // Linear doesn't have pull requests + readonly branchFormat?: string | undefined private config: LinearServiceConfig private prompter: (message: string) => Promise @@ -44,6 +45,7 @@ export class LinearService implements IssueTracker { options?: { prompter?: (message: string) => Promise }, ) { this.config = config ?? {} + this.branchFormat = this.config.branchFormat this.prompter = options?.prompter ?? promptConfirmation // Set API token from config if provided (follows mcp.ts pattern) diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts index c7391217..56ba1391 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -652,10 +652,12 @@ export class LoomManager { } if ((input.type === 'issue' || input.type === 'epic') && issueData) { - // Use BranchNamingService for AI-powered branch name generation + // Use BranchNamingService for branch name generation + // Pass branchFormat from issue tracker if configured (e.g., Jira branchFormat) const branchName = await this.branchNaming.generateBranchName({ issueNumber: input.identifier as number, title: issueData.title, + branchFormat: this.issueTracker.branchFormat, }) return branchName } diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index 3305ad6e..68427e4b 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -616,6 +616,10 @@ export const IloomSettingsSchema = z.object({ .optional() .default(['Done']) .describe('Status names to exclude from issue lists (e.g., ["Done", "Closed", "Verify"])'), + branchFormat: z + .string() + .optional() + .describe('Branch naming template for Jira issues. Variables: {ticketId} (e.g., "PROJ-123"), {slug} (slugified title). Example: "{ticketId}-{slug}" → "proj-123-fix-bug"'), }) .optional(), }) @@ -905,6 +909,10 @@ export const IloomSettingsSchemaNoDefaults = z.object({ .optional() .default(['Done']) .describe('Status names to exclude from issue lists (e.g., ["Done", "Closed", "Verify"])'), + branchFormat: z + .string() + .optional() + .describe('Branch naming template for Jira issues. Variables: {ticketId} (e.g., "PROJ-123"), {slug} (slugified title). Example: "{ticketId}-{slug}" → "proj-123-fix-bug"'), }) .optional(), }) diff --git a/src/lib/providers/jira/JiraIssueTracker.ts b/src/lib/providers/jira/JiraIssueTracker.ts index 5bb16bc9..90aeb990 100644 --- a/src/lib/providers/jira/JiraIssueTracker.ts +++ b/src/lib/providers/jira/JiraIssueTracker.ts @@ -17,6 +17,7 @@ export interface JiraTrackerConfig extends JiraConfig { transitionMappings?: Record // Map iloom states to Jira transition names defaultIssueType?: string // Default issue type for creating issues (e.g., "Task", "Story") defaultSubtaskType?: string // Default issue type for creating subtasks (e.g., "Subtask", "Sub-task") + branchFormat?: string // Branch naming template (e.g., "{ticketId}-{slug}") } /** @@ -31,6 +32,7 @@ export interface JiraTrackerConfig extends JiraConfig { export class JiraIssueTracker implements IssueTracker { readonly providerName = 'jira' readonly supportsPullRequests = false + readonly branchFormat?: string | undefined private readonly client: JiraApiClient private readonly config: JiraTrackerConfig @@ -67,6 +69,10 @@ export class JiraIssueTracker implements IssueTracker { config.transitionMappings = jiraSettings.transitionMappings } + if (jiraSettings.branchFormat) { + config.branchFormat = jiraSettings.branchFormat + } + return new JiraIssueTracker(config) } @@ -74,6 +80,7 @@ export class JiraIssueTracker implements IssueTracker { prompter?: (message: string) => Promise }) { this.config = config + this.branchFormat = config.branchFormat this.client = new JiraApiClient({ host: config.host, username: config.username, diff --git a/src/types/branch-naming.ts b/src/types/branch-naming.ts index 91325dab..72a151bb 100644 --- a/src/types/branch-naming.ts +++ b/src/types/branch-naming.ts @@ -15,4 +15,5 @@ export interface BranchGenerationOptions { issueNumber: string | number title: string strategy?: BranchNameStrategy // Optional override + branchFormat?: string | undefined // Template string (e.g., "{ticketId}-{slug}") } diff --git a/src/utils/claude.ts b/src/utils/claude.ts index 9a57fa49..ac1bdde8 100644 --- a/src/utils/claude.ts +++ b/src/utils/claude.ts @@ -669,7 +669,7 @@ Generate a git branch name for the following issue: * Check format: {prefix}/issue-{number}__{description} * Uses case-insensitive matching for issue number (Linear uses uppercase like MARK-1) */ -function isValidBranchName(name: string, issueNumber: string | number): boolean { +export function isValidBranchName(name: string, issueNumber: string | number): boolean { const pattern = new RegExp(`^(feat|fix|docs|refactor|test|chore)/issue-${issueNumber}__[a-z0-9-]+$`, 'i') return pattern.test(name) && name.length <= 50 }