diff --git a/README.md b/README.md index 235ddc0..899433a 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ linear-release update --stage="in review" --name="Release 1.2.0" | `--stage` | `update` | Target deployment stage (required for `update`) | | `--include-paths` | `sync` | Filter commits by changed file paths | | `--include-subjects` | `sync` | Filter commits whose subject (first line) matches a regex | +| `--issue-pattern` | `sync` | Extract issue identifiers from a custom subject format via a regex (group 1 = team key, group 2 = issue number). Use for conventions the built-in detection misses, e.g. Conventional Commits. | | `--link` | `sync`, `complete`, `update` | Add a link to the targeted release. Use `--link "https://example.com"` or `--link "Label=https://example.com"`; repeat the flag to add multiple links. | | `--document` | `sync`, `complete`, `update` | Attach a document. `--document "Title=...markdown..."`; repeat for multiple docs. Existing documents with the same title on the release are updated. | | `--document-file` | `sync`, `complete`, `update` | Same as `--document` but reads the body from a file: `--document-file "Title=path/to/file.md"`. Use `-` to read from stdin. | @@ -233,6 +234,29 @@ The regex is matched against the commit subject only (everything before the firs `--include-subjects` composes with `--include-paths`: a commit must pass both filters to be scanned. +### Custom Issue Patterns + +Out of the box, identifiers are detected from branch names, magic words (`Fixes ENG-123`), and common subject conventions where the identifier leads the subject — `[ENG-123] …`, `(ENG-123) …`, `ENG-123: …`. Some teams instead put the identifier _after_ a [Conventional Commits](https://www.conventionalcommits.org/) type and scope, which the built-in patterns don't recognize: + +``` +feat(routing)[ENG-123]: add stop reordering +fix[ENG-123]: handle empty payload +``` + +Use `--issue-pattern` to teach the scanner your convention. The flag takes a regex whose **first capture group is the team key** and **second capture group is the issue number**: + +```bash +# Conventional Commits: type, optional (scope), optional !, then [TEAM-NUMBER] +linear-release sync --issue-pattern="\w+(?:\([^)]*\))?!?\[(\w+)-(\d+)\]" + +# A bespoke convention, e.g. "JIRA: ENG-123 | …" +linear-release sync --issue-pattern="^[A-Z]+:\s+(\w+)-(\d+)" +``` + +The pattern is matched against the commit subject (first line) and scanned globally, so a subject may carry more than one identifier. Matching is case-insensitive; the team key is upper-cased and leading zeros on the number are stripped (`eng-0045` → `ENG-45`), and numbers written with a leading zero are rejected as invalid identifiers. Matches are merged with the built-in detection rather than replacing it, and de-duplicated across branch name and message. + +> The CLI validates that the regex compiles and has at least two capturing groups, but it cannot know which substrings you intend as the team key and number — double-check the capture groups against a sample commit with `--dry-run --verbose`. + ### Release Links `--link` attaches external URLs to the release — a GitHub release page, a CI run, a deployment dashboard. diff --git a/src/args.test.ts b/src/args.test.ts index c680ab2..4922731 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -120,6 +120,30 @@ describe("parseCLIArgs", () => { expect(() => parseCLIArgs(["--include-subjects", "([unclosed"])).toThrow(/Invalid --include-subjects regex/); }); + it("defaults --issue-pattern to null", () => { + const result = parseCLIArgs([]); + expect(result.issuePattern).toBeNull(); + }); + + it("returns --issue-pattern as the raw pattern string", () => { + const pattern = "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]"; + const result = parseCLIArgs(["--issue-pattern", pattern]); + expect(result.issuePattern).toBe(pattern); + }); + + it("treats empty --issue-pattern as no pattern", () => { + const result = parseCLIArgs(["--issue-pattern", ""]); + expect(result.issuePattern).toBeNull(); + }); + + it("throws a helpful error on invalid --issue-pattern regex", () => { + expect(() => parseCLIArgs(["--issue-pattern", "([unclosed"])).toThrow(/Invalid --issue-pattern regex/); + }); + + it("throws when --issue-pattern has fewer than two capturing groups", () => { + expect(() => parseCLIArgs(["--issue-pattern", "\\[(\\w+-\\d+)\\]"])).toThrow(/at least two capturing groups/); + }); + it("parses repeatable --link values", () => { const result = parseCLIArgs([ "sync", diff --git a/src/args.ts b/src/args.ts index 73c7552..a879418 100644 --- a/src/args.ts +++ b/src/args.ts @@ -27,6 +27,7 @@ export type ParsedCLIArgs = { baseRef?: string; includePaths: string[]; includeSubjects: string | null; + issuePattern: string | null; links: ReleaseLink[]; documents: ReleaseDocumentSpec[]; releaseNotes?: ReleaseNoteSpec; @@ -71,6 +72,21 @@ function parseAbsoluteUrl(value: string): URL | undefined { } } +/** + * Counts capturing groups in an already-valid regex source by appending an + * empty alternative (`|`) — which forces a match against the empty string — and + * reading the result arity. Returns Infinity if the probe can't be built, so a + * regex that already compiled is never wrongly rejected for group count. + */ +function countCapturingGroups(source: string): number { + try { + const probe = new RegExp(`${source}|`); + return (probe.exec("")?.length ?? 1) - 1; + } catch { + return Number.POSITIVE_INFINITY; + } +} + /** Splits `Title=value` once on `=`. Title is trimmed; value is returned verbatim so markdown whitespace survives. */ function splitTitleAndValue(raw: string, flag: string): { title: string; value: string } { const separatorIndex = raw.indexOf("="); @@ -133,6 +149,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { "base-ref": { type: "string" }, "include-paths": { type: "string" }, "include-subjects": { type: "string" }, + "issue-pattern": { type: "string" }, link: { type: "string", multiple: true }, document: { type: "string", multiple: true }, "document-file": { type: "string", multiple: true }, @@ -178,6 +195,24 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { } includeSubjects = rawIncludeSubjects; } + + let issuePattern: string | null = null; + const rawIssuePattern = values["issue-pattern"]; + if (rawIssuePattern !== undefined && rawIssuePattern.length > 0) { + try { + new RegExp(rawIssuePattern); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid --issue-pattern regex: ${detail}`); + } + if (countCapturingGroups(rawIssuePattern) < 2) { + throw new Error( + `Invalid --issue-pattern: regex must have at least two capturing groups — group 1 for the team key and group 2 for the issue number ` + + `(e.g. "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]" for Conventional Commits).`, + ); + } + issuePattern = rawIssuePattern; + } const command = positionals[0] || "sync"; const links = (values.link ?? []).map(parseReleaseLink); @@ -226,6 +261,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { .filter((p) => p.length > 0) : [], includeSubjects, + issuePattern, links, documents, releaseNotes, diff --git a/src/extractors.test.ts b/src/extractors.test.ts index ab8fc32..443f7b0 100644 --- a/src/extractors.test.ts +++ b/src/extractors.test.ts @@ -895,3 +895,74 @@ describe("extractPullRequestNumbersForCommit — GitLab merge request trailer", expect(result).toEqual([]); }); }); + +describe("custom issue pattern (--issue-pattern)", () => { + // Conventional Commits: the identifier follows the type and optional scope, so + // the built-in start-anchored subject patterns do not see it. + const CONVENTIONAL = "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]"; + + it.each([ + ["with scope", "feat(routing)[ENG-123]: add stop reordering", "ENG-123"], + ["without scope", "fix[ENG-123]: handle empty payload", "ENG-123"], + ["with breaking-change bang", "feat(api)![ENG-7]: drop v1", "ENG-7"], + ])("extracts a Conventional Commits identifier %s", (_name, message, expected) => { + const result = extractLinearIssueIdentifiersForCommit({ sha: "abc", message }, { issuePattern: CONVENTIONAL }); + expect(ids(result)).toEqual([expected]); + expect(result[0]!.source).toBe("commit_message"); + }); + + it("is not applied when no pattern is provided", () => { + const result = extractLinearIssueIdentifiersForCommit({ sha: "abc", message: "feat(routing)[ENG-123]: x" }); + expect(ids(result)).toEqual([]); + }); + + it("normalizes case and strips leading zeros on the number", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "feat[eng-45]: lowercase team key" }, + { issuePattern: CONVENTIONAL }, + ); + expect(ids(result)).toEqual(["ENG-45"]); + }); + + it("rejects identifiers whose number has a leading zero", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "feat[ENG-007]: bond" }, + { issuePattern: CONVENTIONAL }, + ); + expect(ids(result)).toEqual([]); + }); + + it("extracts multiple identifiers from one subject", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "feat[ENG-1] feat[ENG-2]: two" }, + { issuePattern: CONVENTIONAL }, + ); + expect(ids(result)).toEqual(["ENG-1", "ENG-2"]); + }); + + it("only scans the subject, not the body", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "chore: bump\n\nfeat[ENG-9]: stale body reference" }, + { issuePattern: CONVENTIONAL }, + ); + expect(ids(result)).toEqual([]); + }); + + it("composes with branch-name extraction without duplicating", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", branchName: "feature/ENG-123-x", message: "feat[ENG-123]: same issue" }, + { issuePattern: CONVENTIONAL }, + ); + expect(ids(result)).toEqual(["ENG-123"]); + // Branch name wins the source since it is scanned first. + expect(result[0]!.source).toBe("branch_name"); + }); + + it("does not loop forever on a zero-width-capable pattern", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "ENG-1 ENG-2" }, + { issuePattern: "(\\w*)-(\\d*)" }, + ); + expect(ids(result)).toEqual(["ENG-1", "ENG-2"]); + }); +}); diff --git a/src/extractors.ts b/src/extractors.ts index 2864400..6de2fa3 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -200,6 +200,43 @@ function matchCommonSubjectPatterns(message: string): IdentifierMatch[] { return results; } +/** + * Extract identifiers from a user-supplied subject pattern (the `--issue-pattern` + * flag). The regex must capture the team key in group 1 and the issue number in + * group 2. It is matched against the commit subject (first line) and scanned + * globally, so a single subject can carry more than one identifier. + * + * The flag exists for subject conventions the built-in patterns don't recognize + * — most commonly Conventional Commits, where the identifier follows the type and + * scope (`feat(scope)[ENG-123]: …`, `fix[ENG-123]: …`) rather than leading the + * subject. Example pattern: `\w+(?:\([^)]*\))?!?\[(\w+)-(\d+)\]`. + */ +function matchCustomSubjectPattern(message: string, pattern: string | null | undefined): IdentifierMatch[] { + if (!pattern) return []; + + const subject = getCommitSubject(message); + // Force global so the loop advances; force case-insensitive to match the + // built-in identifier regexes. The team key is uppercased on output anyway. + const regex = new RegExp(pattern, "gi"); + const results: IdentifierMatch[] = []; + let match; + while ((match = regex.exec(subject)) !== null) { + // A pattern that can match empty (e.g. `(\w*)-(\d*)`) would loop forever. + if (match.index === regex.lastIndex) regex.lastIndex++; + + const [, teamKey, numberString] = match; + if (!teamKey || !numberString || !/^[0-9]+$/.test(numberString)) continue; + // Reject leading zeros (e.g. ENG-0004), matching parseMatch. + if (Number(numberString).toString().length !== numberString.length) continue; + + results.push({ + rawIdentifier: `${teamKey}-${numberString}`, + identifier: `${teamKey.toUpperCase()}-${Number(numberString)}`, + }); + } + return results; +} + /** * Extract issue identifiers from text only when preceded by a magic word. * Processes text line-by-line, matching Linear's detection behavior. @@ -230,7 +267,19 @@ export type ExtractedIdentifier = { source: "branch_name" | "commit_message"; }; -export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): ExtractedIdentifier[] { +export type ExtractOptions = { + /** + * User-supplied regex (source string) capturing the team key in group 1 and + * the issue number in group 2. Applied to the commit subject. See the + * `--issue-pattern` flag and matchCustomSubjectPattern. + */ + issuePattern?: string | null; +}; + +export function extractLinearIssueIdentifiersForCommit( + commit: CommitContext, + options: ExtractOptions = {}, +): ExtractedIdentifier[] { if (!commit) { return []; } @@ -267,7 +316,11 @@ export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): E const scanTarget = messageDepth % 2 === 1 ? afterTitle : (commit.message ?? ""); const message = stripSquashBlock(scanTarget); if (message.length > 0) { - for (const match of [...matchCommonSubjectPatterns(message), ...matchMagicWordIdentifiers(message)]) { + for (const match of [ + ...matchCommonSubjectPatterns(message), + ...matchCustomSubjectPattern(message, options.issuePattern), + ...matchMagicWordIdentifiers(message), + ]) { if (!found.has(match.identifier)) { found.set(match.identifier, { identifier: match.identifier, diff --git a/src/index.ts b/src/index.ts index 711877c..420e0dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ Options: --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) --include-subjects= Filter commits whose subject (first line) matches the regex + --issue-pattern= Extract issue identifiers from a custom subject format; capture the team key in group 1 and the issue number in group 2 --link Add a link to the targeted release (repeatable) --document Attach a document to the release (repeatable, Title required) --document-file <[Title=]path> Attach a document from a file (title inferred from basename if omitted; "-" for stdin requires Title=-; repeatable) @@ -83,6 +84,7 @@ Examples: linear-release update --stage=production linear-release sync --include-paths="apps/web/**,packages/**" linear-release sync --include-subjects="[A-Z]{2,}-[0-9]+" + linear-release sync --issue-pattern="\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]" linear-release sync --link "https://ci.example.com/run/123" linear-release sync --link "Pipeline=https://ci.example.com/run/123" linear-release sync --document-file "Changelog=./CHANGELOG.md" @@ -116,6 +118,7 @@ const { baseRef, includePaths, includeSubjects, + issuePattern, links, documents: documentSpecs, releaseNotes: releaseNotesSpec, @@ -350,6 +353,7 @@ async function syncCommand(): Promise<{ const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits(commits, { includePaths: effectiveIncludePaths, includeSubjects, + issuePattern, }); verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`); diff --git a/src/scan.test.ts b/src/scan.test.ts index 4e891de..7e5eea8 100644 --- a/src/scan.test.ts +++ b/src/scan.test.ts @@ -228,4 +228,33 @@ describe("scanCommits", () => { expect(ids(result.revertedIssueReferences)).toEqual([]); }); }); + + describe("--issue-pattern extraction", () => { + const CONVENTIONAL = "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]"; + + it("extracts identifiers from Conventional Commits subjects", () => { + const commits: CommitContext[] = [ + { sha: "c1", message: "feat(routing)[ENG-100]: add stop reordering" }, + { sha: "c2", message: "fix[ENG-200]: handle empty payload" }, + ]; + const result = scanCommits(commits, { issuePattern: CONVENTIONAL }); + expect(ids(result.issueReferences)).toEqual(["ENG-100", "ENG-200"]); + }); + + it("extracts nothing from those subjects without the pattern", () => { + const commits: CommitContext[] = [{ sha: "c1", message: "feat(routing)[ENG-100]: add stop reordering" }]; + const result = scanCommits(commits, {}); + expect(ids(result.issueReferences)).toEqual([]); + }); + + it("records the pattern on the debug sink", () => { + const result = scanCommits([{ sha: "c1", message: "feat[ENG-1]: x" }], { issuePattern: CONVENTIONAL }); + expect(result.debugSink.issuePattern).toBe(CONVENTIONAL); + }); + + it("leaves issuePattern null when not provided", () => { + const result = scanCommits([{ sha: "c1", message: "anything" }], {}); + expect(result.debugSink.issuePattern).toBeNull(); + }); + }); }); diff --git a/src/scan.ts b/src/scan.ts index 0398428..110008d 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -10,6 +10,7 @@ import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./t export type ScanOptions = { includePaths?: string[] | null; includeSubjects?: string | null; + issuePattern?: string | null; }; /** @@ -26,7 +27,7 @@ export function scanCommits( prNumbers: number[]; debugSink: DebugSink; } { - const { includePaths = null, includeSubjects = null } = options; + const { includePaths = null, includeSubjects = null, issuePattern = null } = options; const subjectRegex = includeSubjects ? new RegExp(includeSubjects) : null; const lastAction = new Map(); const addedRefs = new Map(); @@ -40,6 +41,7 @@ export function scanCommits( pullRequests: [], includePaths, includeSubjects, + issuePattern, }; for (const commit of commits) { @@ -68,7 +70,7 @@ export function scanCommits( verbose(`Detected reverted issue key ${identifier} from commit ${commit.sha}`); } - for (const { identifier, source } of extractLinearIssueIdentifiersForCommit(commit)) { + for (const { identifier, source } of extractLinearIssueIdentifiersForCommit(commit, { issuePattern })) { if (!debugSink.issues[identifier]) { debugSink.issues[identifier] = []; } diff --git a/src/types.ts b/src/types.ts index 6f5d5f5..a0fb3ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,4 +109,5 @@ export type DebugSink = { pullRequests: PullRequestSource[]; // PR numbers found in commits includePaths: string[] | null; // Path filters applied during commit scanning includeSubjects: string | null; // Subject regex source applied during scanning + issuePattern: string | null; // Custom issue-identifier regex source applied during scanning };