Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/extractors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,62 @@ describe("commit message magic word behavior", () => {
});
});

describe("bracketed identifier in commit subject", () => {
it("extracts identifier from a [KEY-N] prefix on the subject line", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
branchName: null,
message: "[ENG-123] My change",
});
expect(ids(result)).toEqual(["ENG-123"]);
});

it("extracts identifier from a lowercase [key-n] prefix", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
branchName: null,
message: "[eng-123] adjust thing",
});
expect(ids(result)).toEqual(["ENG-123"]);
});

it("does not extract bracketed identifier from elsewhere in the message", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
branchName: null,
message: "Title\n\nSee [ENG-123] in the docs",
});
expect(ids(result)).toEqual([]);
});

it("extracts identifier from a parenthesized (KEY-N) prefix on the subject line", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
branchName: null,
message: "(ENG-123) My change",
});
expect(ids(result)).toEqual(["ENG-123"]);
});

it("extracts identifier from a bare KEY-N prefix on the subject line", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
branchName: null,
message: "ENG-123 My change",
});
expect(ids(result)).toEqual(["ENG-123"]);
});

it("does not extract bare prefix when the subject does not start with it", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
branchName: null,
message: "My change for ENG-123",
});
expect(ids(result)).toEqual([]);
});
});

describe("revert branch handling", () => {
it("blocks extraction from merge commit with revert branch name", () => {
const result = extractLinearIssueIdentifiersForCommit({
Expand Down
83 changes: 74 additions & 9 deletions src/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ const ISSUE_IDENTIFIER_REGEX = new RegExp(

const LINEAR_ISSUE_URL_REGEX = /https?:\/\/linear\.app\/[\w-]+\/issue\/(\w{1,7}-[0-9]{1,9})(?:\/[\w-]*)*/gi;

/**
* Patterns for common manual subject-line conventions that aren't gated by a
* magic word. Each regex is anchored to the start of the subject and must
* capture team key in group 1 and issue number in group 2.
*
* Add more entries here as new conventions appear in the wild.
*/
const COMMON_SUBJECT_PATTERNS: RegExp[] = [
// `[ENG-123] My change`
new RegExp(`^\\s*\\[(\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})\\]`, "i"),
// `(ENG-123) My change`
new RegExp(`^\\s*\\((\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})\\)`, "i"),
// `ENG-123 My change`
new RegExp(`^\\s*(\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})(?=\\s)`, "i"),
];

/**
* `git merge --squash` followed by `git commit` writes a body containing this
* header and then dumps the full message of every commit pulled in via the
Expand Down Expand Up @@ -160,6 +176,28 @@ function matchAllIdentifiers(text: string): IdentifierMatch[] {
return results;
}

/**
* Extract identifiers from common, manually-written subject-line conventions
* (e.g. `[ENG-123] My change`). These don't require a magic word — the
* convention itself signals intent.
*/
function matchCommonSubjectPatterns(message: string): IdentifierMatch[] {
const subject = message.split(/\r?\n/)[0] ?? "";
const results: IdentifierMatch[] = [];
for (const pattern of COMMON_SUBJECT_PATTERNS) {
const match = subject.match(pattern);
if (!match) continue;
const [, teamKey, numberString] = match;
if (!teamKey || !numberString) continue;
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.
Expand Down Expand Up @@ -208,7 +246,10 @@ export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): E
if (branchDepth % 2 === 0 && strippedBranch.length > 0) {
for (const match of matchAllIdentifiers(strippedBranch)) {
if (!found.has(match.identifier)) {
found.set(match.identifier, { identifier: match.identifier, source: "branch_name" });
found.set(match.identifier, {
identifier: match.identifier,
source: "branch_name",
});
}
}
} else if (branchDepth % 2 === 1) {
Expand All @@ -224,9 +265,12 @@ 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 matchMagicWordIdentifiers(message)) {
for (const match of [...matchCommonSubjectPatterns(message), ...matchMagicWordIdentifiers(message)]) {
if (!found.has(match.identifier)) {
found.set(match.identifier, { identifier: match.identifier, source: "commit_message" });
found.set(match.identifier, {
identifier: match.identifier,
source: "commit_message",
});
}
}
}
Expand Down Expand Up @@ -291,18 +335,29 @@ function extractGithubPrNumbers(message: string): PrMatch[] {
const title = message.split(/\r?\n/)[0] ?? "";

const squash = title.match(GITHUB_SQUASH_RE);
if (squash) matches.push({ number: Number.parseInt(squash[1]!, 10), source: "github squash" });
if (squash)
matches.push({
number: Number.parseInt(squash[1]!, 10),
source: "github squash",
});

const merge = message.match(GITHUB_MERGE_RE);
if (merge) matches.push({ number: Number.parseInt(merge[1]!, 10), source: "github merge" });
if (merge)
matches.push({
number: Number.parseInt(merge[1]!, 10),
source: "github merge",
});

// Fallback for non-canonical merge titles (e.g. a direct push that put the PR
// number somewhere other than the trailing parens). Restrict to the title —
// scanning the body would re-pick up cross-references like "builds on #85"
// and stale references inside squashed-in sub-commit history.
if (matches.length === 0) {
for (const m of title.matchAll(GITHUB_TITLE_SCAN_RE)) {
matches.push({ number: Number.parseInt(m[1]!, 10), source: "github title scan" });
matches.push({
number: Number.parseInt(m[1]!, 10),
source: "github title scan",
});
}
}

Expand Down Expand Up @@ -348,7 +403,11 @@ export function getRevertBranchDepth(branchName: string | null | undefined): num
* content on the subject line plus the body), so callers can scan it for the
* revert author's own references.
*/
function parseRevertMessage(message: string): { depth: number; inner: string; afterTitle: string } {
function parseRevertMessage(message: string): {
depth: number;
inner: string;
afterTitle: string;
} {
const newlineIdx = message.search(/\r?\n/);
const subject = newlineIdx === -1 ? message : message.slice(0, newlineIdx);
const body = newlineIdx === -1 ? "" : message.slice(newlineIdx);
Expand Down Expand Up @@ -390,7 +449,10 @@ export function extractRevertedIssueIdentifiersForCommit(commit: CommitContext):
if (branchDepth % 2 === 1) {
for (const match of matchAllIdentifiers(originalBranch)) {
if (!found.has(match.identifier)) {
found.set(match.identifier, { identifier: match.identifier, source: "branch_name" });
found.set(match.identifier, {
identifier: match.identifier,
source: "branch_name",
});
}
}
}
Expand All @@ -401,7 +463,10 @@ export function extractRevertedIssueIdentifiersForCommit(commit: CommitContext):
const innerStripped = stripSquashBlock(innerMessage);
for (const match of matchMagicWordIdentifiers(innerStripped)) {
if (!found.has(match.identifier)) {
found.set(match.identifier, { identifier: match.identifier, source: "commit_message" });
found.set(match.identifier, {
identifier: match.identifier,
source: "commit_message",
});
}
}
}
Expand Down
Loading