diff --git a/README.md b/README.md index b5fe209..462957b 100644 --- a/README.md +++ b/README.md @@ -149,16 +149,17 @@ linear-release update --stage="in review" --name="Release 1.2.0" ### CLI Options -| Option | Commands | Description | -| ------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. | -| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, selects an existing release with that version (errors if none exists); does not change a release's version. If omitted, targets the most recent started release. | -| `--stage` | `update` | Target deployment stage (required for `update`) | -| `--include-paths` | `sync` | Filter commits by changed file paths | -| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. | -| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. | -| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics | -| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) | +| Option | Commands | Description | +| -------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. | +| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, selects an existing release with that version (errors if none exists); does not change a release's version. If omitted, targets the most recent started release. | +| `--stage` | `update` | Target deployment stage (required for `update`) | +| `--include-paths` | `sync` | Filter commits by changed file paths | +| `--issue-id-pattern` | `sync` | Regex with one capture group around the issue ID, applied per line of the commit message. Anchor with `^` to match once per line; omit `^` to match every occurrence per line. Detects IDs from conventions like `[LIN-123] My title` that don't use magic words. | +| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. | +| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. | +| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics | +| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) | ### Command Targeting @@ -209,11 +210,39 @@ Patterns use [Git pathspec](https://git-scm.com/docs/gitglossary#Documentation/g Path patterns can also be configured in your pipeline settings in Linear. If both are set, the CLI `--include-paths` option takes precedence. +### Issue Detection + +By default, issue IDs are picked up from branch names (e.g. `feat/ENG-123-add-feature`) and from commit messages preceded by a "magic word" like `Fixes ENG-123` or `Closes ENG-123`. Use `--issue-id-pattern` to add a regex-driven detection path that fits your team's commit convention. + +**`--issue-id-pattern=`** detects issue IDs from a regex applied to each line of the commit message. The first capture group must wrap the ID, and every occurrence on a line is matched. Useful for PR-title conventions that don't include a magic word. + +```bash +# Bracketed prefix anchored to line start: [LIN-123] My PR title +linear-release sync --issue-id-pattern='^\[(.+?)\]' + +# Unanchored: catches multi-bracket titles like [LIN-1] [LIN-2] thing +linear-release sync --issue-id-pattern='\[(.+?)\]' + +# Parenthesised prefix: (LIN-1): My PR title +linear-release sync --issue-id-pattern='^\((.+?)\):' + +# Colon-suffixed line-start ID: LIN-1: My PR title +linear-release sync --issue-id-pattern='^(\w{1,7}-[0-9]{1,9}):' +``` + +Anchored patterns (with `^`) match at most once per line; unanchored patterns find every occurrence. The pattern composes with the default magic-word detection — both run, and their results are unioned. Brackets in body prose like Markdown link references `[Release notes]` capture but contribute nothing because their contents don't match the `-` shape. + +#### Flags + +Pass the regex source only, e.g. `'^\[(.+?)\]'`. Do not wrap it in `/.../` delimiters; the CLI will reject that form. Flags such as `i` or `m` cannot be set on the pattern. + +Case-sensitivity rarely matters anyway: ID matching is already case-insensitive, so `[lin-123]` and `[LIN-123]` both resolve to `LIN-123`. + ## How It Works 1. **Fetches the latest release** from your Linear pipeline to determine the commit range 2. **Scans commits** between the commit from the last release and the current commit -3. **Extracts issue identifiers** from branch names and commit messages (e.g., `feat/ENG-123-add-feature`) +3. **Extracts issue identifiers** from branch names and commit messages: by default via magic words (`Fixes LIN-1`); see [Issue Detection](#issue-detection) for prefix conventions 4. **Detects pull/merge request numbers** from commit messages — GitHub `Title (#42)` / `Merge pull request #42`, and GitLab `See merge request /!42` trailers (emitted whenever a merge commit is created) 5. **Syncs data to Linear** that adds issues to a newly created completed release (continuous pipelines) or the currently in-progress release (scheduled pipelines) diff --git a/src/args.test.ts b/src/args.test.ts index 21163b1..350139e 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -145,4 +145,40 @@ describe("parseCLIArgs", () => { it("throws when --quiet and --verbose are both passed", () => { expect(() => parseCLIArgs(["--quiet", "--verbose"])).toThrow("Conflicting log level flags"); }); + + it("defaults --issue-id-pattern to undefined", () => { + const result = parseCLIArgs([]); + expect(result.issueIdPattern).toBeUndefined(); + }); + + it("parses --issue-id-pattern into a RegExp", () => { + const result = parseCLIArgs(["--issue-id-pattern=^\\[(.+?)\\]"]); + expect(result.issueIdPattern).toBeInstanceOf(RegExp); + expect(result.issueIdPattern!.test("[LIN-1] foo")).toBe(true); + }); + + it("throws on invalid --issue-id-pattern regex", () => { + expect(() => parseCLIArgs(["--issue-id-pattern=["])).toThrow("Invalid --issue-id-pattern"); + }); + + it("throws on --issue-id-pattern with no capture group", () => { + expect(() => parseCLIArgs(["--issue-id-pattern=^\\[.+?\\]"])).toThrow("exactly one capture group"); + }); + + it("throws on --issue-id-pattern with multiple capture groups", () => { + expect(() => parseCLIArgs(["--issue-id-pattern=^(\\[)(.+?)\\]"])).toThrow("exactly one capture group"); + }); + + it("treats --issue-id-pattern='' as absent", () => { + const result = parseCLIArgs(["--issue-id-pattern="]); + expect(result.issueIdPattern).toBeUndefined(); + }); + + it("rejects --issue-id-pattern passed as /source/flags literal", () => { + expect(() => parseCLIArgs(["--issue-id-pattern=/^\\[(.+?)\\]/i"])).toThrow("pass the pattern source directly"); + }); + + it("rejects --issue-id-pattern passed as /source/ literal with no flags", () => { + expect(() => parseCLIArgs(["--issue-id-pattern=/^\\[(.+?)\\]/"])).toThrow("pass the pattern source directly"); + }); }); diff --git a/src/args.ts b/src/args.ts index 86ff11a..d8812d8 100644 --- a/src/args.ts +++ b/src/args.ts @@ -10,6 +10,7 @@ export type ParsedCLIArgs = { jsonOutput: boolean; timeoutSeconds: number; logLevel: LogLevel; + issueIdPattern?: RegExp; }; export function parseCLIArgs(argv: string[]): ParsedCLIArgs { @@ -24,6 +25,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { timeout: { type: "string" }, quiet: { type: "boolean", default: false }, verbose: { type: "boolean", default: false }, + "issue-id-pattern": { type: "string" }, }, allowPositionals: true, strict: true, @@ -47,6 +49,32 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { if (values.quiet) logLevel = LogLevel.Quiet; else if (values.verbose) logLevel = LogLevel.Verbose; + let issueIdPattern: RegExp | undefined; + const rawPattern = values["issue-id-pattern"]; + if (rawPattern !== undefined && rawPattern.length > 0) { + // Reject `/source/flags` literal-regex syntax: `new RegExp("/foo/i")` would + // silently match the text `/foo/i`, not what the user meant. The value is + // always treated as the pattern source; flags can't be configured. + if (/^\/.+\/[gimsuy]*$/.test(rawPattern)) { + throw new Error( + `Invalid --issue-id-pattern: pass the pattern source directly (e.g. '^\\[(.+?)\\]'), ` + + `not as a /.../flags literal. Flags inside the value are not supported.`, + ); + } + try { + issueIdPattern = new RegExp(rawPattern); + } catch (error) { + throw new Error(`Invalid --issue-id-pattern: ${(error as Error).message}`); + } + const groupCount = countCaptureGroups(issueIdPattern); + if (groupCount !== 1) { + throw new Error( + `Invalid --issue-id-pattern: expected exactly one capture group, found ${groupCount}. ` + + `Example: '^\\[(.+?)\\]' to detect '[LIN-123] My title'.`, + ); + } + } + return { command: positionals[0] || "sync", releaseName: values.name, @@ -61,9 +89,21 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { jsonOutput: values.json ?? false, timeoutSeconds, logLevel, + issueIdPattern, }; } +/** + * Count the capture groups in a regex by appending an empty alternative — the + * resulting match against "" returns `groupCount + 1` elements. + */ +function countCaptureGroups(re: RegExp): number { + // Append `|` so the empty string always matches; `exec` then returns one + // entry per capture group plus the full match. + const probe = new RegExp(`${re.source}|`); + return probe.exec("")!.length - 1; +} + export function getCLIWarnings(_args: ParsedCLIArgs): string[] { return []; } diff --git a/src/extractors.test.ts b/src/extractors.test.ts index d36f280..6d4c928 100644 --- a/src/extractors.test.ts +++ b/src/extractors.test.ts @@ -50,6 +50,30 @@ describe("extractLinearIssueIdentifiersForCommit", () => { expect(ids(result)).toEqual(["ENG-123"]); }); + it("deduplicates identifiers found via prefix and magic-word paths", () => { + const commit: CommitContext = { + sha: "abc123", + branchName: null, + message: "[LIN-1] title\n\nFixes LIN-1", + }; + + const result = extractLinearIssueIdentifiersForCommit(commit, { issueIdPattern: /^\[(.+?)\]/ }); + + expect(ids(result)).toEqual(["LIN-1"]); + }); + + it("deduplicates identifiers found via prefix, branch, and magic-word paths", () => { + const commit: CommitContext = { + sha: "abc123", + branchName: "feature/LIN-1-thing", + message: "[LIN-1] title\n\nFixes LIN-1", + }; + + const result = extractLinearIssueIdentifiersForCommit(commit, { issueIdPattern: /^\[(.+?)\]/ }); + + expect(ids(result)).toEqual(["LIN-1"]); + }); + it("returns empty array when no identifiers are present", () => { const commit: CommitContext = { sha: "abc123", @@ -714,6 +738,139 @@ describe("extractPullRequestNumbersForCommit", () => { }); }); +describe("issue id pattern option", () => { + const bracketed = /^\[(.+?)\]/; + + it("does not detect prefix-style identifiers without an option", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "[LIN-123] feat: add the thing", + }); + + expect(ids(result)).toEqual([]); + }); + + it.each([ + [/^\[(.+?)\]/, "[LIN-123] feat: add the thing", ["LIN-123"]], + [/^\[(.+?)\]/, "[lin-123] lowercase team key", ["LIN-123"]], + [/^\[(.+?)\]/, "[LIN-123, LIN-456] multiple", ["LIN-123", "LIN-456"]], + [/^\((.+?)\):/, "(LIN-1): parenthesised prefix", ["LIN-1"]], + [/^#(.+?) /, "#LIN-7 hash prefix", ["LIN-7"]], + // Anchored pattern stops after first match per line; second bracket is missed + [/^\[(.+?)\]/, "[LIN-1] [LIN-2] double anchored", ["LIN-1"]], + // Unanchored pattern finds every occurrence on the line + [/\[(.+?)\]/, "[LIN-1] [LIN-2] double unanchored", ["LIN-1", "LIN-2"]], + // Unanchored body markdown like [Release notes] captures but contains no ID shape + [/\[(.+?)\]/, "[LIN-1] Bump foo\n\nBumps [bar] - [Release notes]", ["LIN-1"]], + // Unanchored: prose separators (commas, "and", "thing") between bracketed IDs do not break detection + [/\[(.+?)\]/, "[LIN-1], [LIN-2] and [LIN-3] thing [LIN-4]", ["LIN-1", "LIN-2", "LIN-3", "LIN-4"]], + // Unanchored: prefix path catches bracketed IDs, magic-word path catches the trailing `Closes LIN-5` + [ + /\[(.+?)\]/, + "[LIN-1], [LIN-2] and [LIN-3] thing [LIN-4], Closes LIN-5", + ["LIN-1", "LIN-2", "LIN-3", "LIN-4", "LIN-5"], + ], + // Anchored: only first bracketed ID matches; trailing `Closes LIN-5` still picked up by magic-word path + [/^\[(.+?)\]/, "[LIN-1], [LIN-2] and [LIN-3] thing [LIN-4], Closes LIN-5", ["LIN-1", "LIN-5"]], + // Whitespace inside brackets is harmless: inner ID regex still finds the ID token in the capture + [/\[(.+?)\]/, "[LIN-1] [ LIN-2 ] spaced", ["LIN-1", "LIN-2"]], + // Magic word followed by a bracketed ID: brackets defeat the magic-word grammar, but prefix path still catches it + [/\[(.+?)\]/, "Closes [LIN-5]", ["LIN-5"]], + // Prefix pattern's anchor is per-line: line 2 starts fresh, so its bracketed ID still matches + [/^\[(.+?)\]/, "[LIN-1] first line\n[LIN-2] second line", ["LIN-1", "LIN-2"]], + // Brackets containing non-ID prose contribute nothing, but neighbours still match + [/\[(.+?)\]/, "[LIN-1] Bump [some-dep] from 1 to 2 [LIN-2]", ["LIN-1", "LIN-2"]], + ])("pattern %s on message %j yields %j", (pattern, message, expected) => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", branchName: null, message }, + { issueIdPattern: pattern }, + ); + + expect(ids(result).sort()).toEqual(expected.sort()); + }); + + it("rejects leading-zero identifiers in the prefix capture", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", branchName: null, message: "[LIN-0004] zero" }, + { issueIdPattern: bracketed }, + ); + + expect(ids(result)).toEqual([]); + }); + + it("normalizes a user-supplied `g` flag without producing duplicate matches", () => { + // A pattern already carrying `g` could yield surprising state if not + // normalized; the matcher rebuilds the regex internally to ensure each + // bracketed ID is captured exactly once. + const withGlobalFlag = /\[(.+?)\]/g; + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", branchName: null, message: "[LIN-1] [LIN-2] [LIN-3] foo" }, + { issueIdPattern: withGlobalFlag }, + ); + + expect(ids(result).sort()).toEqual(["LIN-1", "LIN-2", "LIN-3"]); + }); + + it("preserves a user-supplied `i` flag", () => { + // The `i` flag isn't strictly required (`matchAllIdentifiers` is already + // case-insensitive), but the user may have anchored on a case-sensitive + // bracket-prefix and added `i` for the inner content. Make sure the flag + // survives the normalization that strips `g`/`y`. + const caseInsensitive = /^\[(LIN-\d+)\]/i; + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", branchName: null, message: "[lin-42] lowercase team key" }, + { issueIdPattern: caseInsensitive }, + ); + + expect(ids(result)).toEqual(["LIN-42"]); + }); + + it("combines with default magic-word detection", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", branchName: null, message: "[LIN-1] title\n\nFixes LIN-2" }, + { issueIdPattern: bracketed }, + ); + + expect(ids(result).sort()).toEqual(["LIN-1", "LIN-2"]); + }); + + it("matches the prefix on any line of the message", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", branchName: null, message: "feat: do thing\n\n[LIN-999] follow-up note" }, + { issueIdPattern: bracketed }, + ); + + expect(ids(result)).toEqual(["LIN-999"]); + }); + + it("ignores prefix matches inside a stripped squash sub-commit dump", () => { + const message = `[LIN-100] New widget + +Squashed commit of the following: + +commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + [LIN-50] Older shipped work +`; + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", branchName: null, message }, + { issueIdPattern: bracketed }, + ); + + expect(ids(result)).toEqual(["LIN-100"]); + }); + + it("detects the prefix on a reverted inner title", () => { + const result = extractRevertedIssueIdentifiersForCommit( + { sha: "abc", branchName: null, message: 'Revert "[LIN-1] title"' }, + { issueIdPattern: bracketed }, + ); + + expect(ids(result)).toEqual(["LIN-1"]); + }); +}); + describe("extractPullRequestNumbersForCommit — GitLab merge request trailer", () => { // GitLab emits this trailer whenever it creates a merge commit. it.each([ diff --git a/src/extractors.ts b/src/extractors.ts index c59c298..a55c570 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -190,7 +190,45 @@ export type ExtractedIdentifier = { source: "branch_name" | "commit_message"; }; -export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): ExtractedIdentifier[] { +export type ExtractionOptions = { + /** + * Regex applied to each line of the commit message. The first capture group + * must wrap the issue-ID portion, and is fed to the standard identifier + * matcher (which also accepts ID chains like `LIN-1, LIN-2` and rejects + * leading-zero IDs). Anchor with `^` to match once per line. The `g` flag + * is forced and `y` is dropped internally. Example: `^\[(.+?)\]`. + */ + issueIdPattern?: RegExp; +}; + +/** + * Match a user-supplied prefix regex line-by-line, finding all occurrences on + * each line. The regex's first capture group is fed to the standard identifier + * matcher (uppercase normalization + leading-zero rejection + multi-ID list + * grammar). The caller is expected to anchor with `^` if they want a + * prefix-only match; an anchored pattern naturally still matches at most once + * per line. Squashed sub-commit dumps are already removed by `stripSquashBlock` + * before this runs. + */ +function matchIssueIdPattern(text: string, pattern: RegExp): IdentifierMatch[] { + // Force `g` (and drop `y`) so `matchAll` iterates every occurrence per line. + const flags = pattern.flags.replace(/[gy]/g, "") + "g"; + const regex = new RegExp(pattern.source, flags); + + const results: IdentifierMatch[] = []; + for (const line of text.split(/\r?\n/)) { + for (const match of line.matchAll(regex)) { + if (!match[1]) continue; + results.push(...matchAllIdentifiers(match[1])); + } + } + return results; +} + +export function extractLinearIssueIdentifiersForCommit( + commit: CommitContext, + options: ExtractionOptions = {}, +): ExtractedIdentifier[] { if (!commit) { return []; } @@ -218,7 +256,6 @@ export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): E } } - // Commit message: only extract when preceded by a magic word. // Strip any squashed sub-commit dump first so references that came from // already-merged branch history don't get re-attributed to this commit. const message = stripSquashBlock(commit.message ?? ""); @@ -228,6 +265,13 @@ export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): E found.set(match.identifier, { identifier: match.identifier, source: "commit_message" }); } } + if (options.issueIdPattern) { + for (const match of matchIssueIdPattern(message, options.issueIdPattern)) { + if (!found.has(match.identifier)) { + found.set(match.identifier, { identifier: match.identifier, source: "commit_message" }); + } + } + } } return Array.from(found.values()); @@ -362,7 +406,10 @@ export function getRevertMessageDepth(message: string | null | undefined): numbe } /** Extract identifiers being reverted. Returns [] if not an odd-depth revert. */ -export function extractRevertedIssueIdentifiersForCommit(commit: CommitContext): ExtractedIdentifier[] { +export function extractRevertedIssueIdentifiersForCommit( + commit: CommitContext, + options: ExtractionOptions = {}, +): ExtractedIdentifier[] { if (!commit) return []; const { depth: branchDepth, inner: originalBranch } = parseRevertBranch(commit.branchName ?? ""); @@ -381,8 +428,8 @@ export function extractRevertedIssueIdentifiersForCommit(commit: CommitContext): } } - // Use magic-word gating on the inner message, same as the add path, to avoid - // false positives from generic word-number tokens (e.g. "Bump v1-2 to v1-3"). + // Mirror the add path's gating on the inner message to avoid false positives + // from generic word-number tokens (e.g. "Bump v1-2 to v1-3"). if (messageDepth % 2 === 1) { const innerStripped = stripSquashBlock(innerMessage); for (const match of matchMagicWordIdentifiers(innerStripped)) { @@ -390,6 +437,13 @@ export function extractRevertedIssueIdentifiersForCommit(commit: CommitContext): found.set(match.identifier, { identifier: match.identifier, source: "commit_message" }); } } + if (options.issueIdPattern) { + for (const match of matchIssueIdPattern(innerStripped, options.issueIdPattern)) { + if (!found.has(match.identifier)) { + found.set(match.identifier, { identifier: match.identifier, source: "commit_message" }); + } + } + } } return Array.from(found.values()); diff --git a/src/index.ts b/src/index.ts index fb5ac45..e01bf18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,7 @@ Options: --release-version= Release version identifier --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) + --issue-id-pattern= Detect issue IDs via a regex pattern (e.g. ^\\[(.+?)\\] for [LIN-123]) --timeout= Abort if the operation exceeds this duration (default: 60) --json Output result as JSON (logs emitted as JSON Lines on stderr) --quiet Suppress info-level output (warnings and errors still printed) @@ -68,6 +69,7 @@ Examples: linear-release complete linear-release update --stage=production linear-release sync --include-paths="apps/web/**,packages/**" + linear-release sync --issue-id-pattern='^\\[(.+?)\\]' `); process.exit(0); } @@ -87,8 +89,17 @@ try { error(`${message} (run linear-release --help for usage)`); process.exit(1); } -const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds, logLevel } = - parsedArgs; +const { + command, + releaseName, + releaseVersion, + stageName, + includePaths, + jsonOutput, + timeoutSeconds, + logLevel, + issueIdPattern, +} = parsedArgs; const cliWarnings = getCLIWarnings(parsedArgs); setLogLevel(logLevel); if (jsonOutput) { @@ -160,6 +171,10 @@ async function syncCommand(): Promise<{ effectiveIncludePaths = null; } + if (issueIdPattern) { + verbose(`Using CLI --issue-id-pattern: ${issueIdPattern}`); + } + const currentCommit = await getCurrentGitInfo(); if (!currentCommit.commit) { @@ -214,6 +229,7 @@ async function syncCommand(): Promise<{ const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits( commits, effectiveIncludePaths, + { issueIdPattern }, ); verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`); diff --git a/src/scan.test.ts b/src/scan.test.ts index 8dd6cac..946ffb3 100644 --- a/src/scan.test.ts +++ b/src/scan.test.ts @@ -128,4 +128,24 @@ describe("scanCommits", () => { expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); }); + + describe("extraction options", () => { + const bracketed = /^\[(.+?)\]/; + + it("propagates issueIdPattern through to the extractor", () => { + const commits: CommitContext[] = [{ sha: "a1", message: "[LIN-1] do the thing" }]; + const result = scanCommits(commits, null, { issueIdPattern: bracketed }); + expect(ids(result.issueReferences)).toEqual(["LIN-1"]); + }); + + it("applies last-write-wins across prefix-detected add then revert", () => { + const commits: CommitContext[] = [ + { sha: "a1", message: "[LIN-1] add the thing" }, + { sha: "r1", message: 'Revert "[LIN-1] add the thing"' }, + ]; + const result = scanCommits(commits, null, { issueIdPattern: bracketed }); + expect(ids(result.issueReferences)).toEqual([]); + expect(ids(result.revertedIssueReferences)).toEqual(["LIN-1"]); + }); + }); }); diff --git a/src/scan.ts b/src/scan.ts index 35a2fd1..744d69f 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1,4 +1,5 @@ import { + ExtractionOptions, extractLinearIssueIdentifiersForCommit, extractPullRequestNumbersForCommit, extractRevertedIssueIdentifiersForCommit, @@ -14,6 +15,7 @@ import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./t export function scanCommits( commits: CommitContext[], includePaths: string[] | null, + extractionOptions: ExtractionOptions = {}, ): { issueReferences: IssueReference[]; revertedIssueReferences: IssueReference[]; @@ -36,7 +38,7 @@ export function scanCommits( for (const commit of commits) { debugSink.inspectedShas.push(commit.sha); - for (const { identifier, source } of extractRevertedIssueIdentifiersForCommit(commit)) { + for (const { identifier, source } of extractRevertedIssueIdentifiersForCommit(commit, extractionOptions)) { if (!debugSink.revertedIssues[identifier]) { debugSink.revertedIssues[identifier] = []; } @@ -51,7 +53,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, extractionOptions)) { if (!debugSink.issues[identifier]) { debugSink.issues[identifier] = []; }