semantic-release-linear-app 0.3.0-next.2 → 0.3.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -225
- package/dist/lib/linear-client.d.ts +1 -1
- package/dist/lib/linear-client.js +5 -5
- package/dist/lib/parse-issues.d.ts +0 -7
- package/dist/lib/parse-issues.js +2 -25
- package/dist/lib/parse-issues.test.js +13 -26
- package/dist/lib/success.d.ts +2 -2
- package/dist/lib/success.js +52 -47
- package/dist/lib/verify.d.ts +2 -2
- package/dist/lib/verify.js +7 -7
- package/dist/lib/verify.test.js +11 -11
- package/dist/types.d.ts +1 -10
- package/package.json +12 -13
- package/src/lib/linear-client.ts +10 -21
- package/src/lib/parse-issues.test.ts +14 -35
- package/src/lib/parse-issues.ts +2 -29
- package/src/lib/success.ts +63 -72
- package/src/lib/verify.test.ts +14 -16
- package/src/lib/verify.ts +16 -16
- package/src/types/semantic-release-error.d.ts +1 -1
- package/src/types.ts +7 -19
package/dist/lib/verify.js
CHANGED
|
@@ -15,30 +15,30 @@ async function verifyConditions(pluginConfig, context) {
|
|
|
15
15
|
// Check for API key in config or environment
|
|
16
16
|
const linearApiKey = apiKey || process.env.LINEAR_API_KEY;
|
|
17
17
|
if (!linearApiKey) {
|
|
18
|
-
throw new error_1.default(
|
|
18
|
+
throw new error_1.default('No Linear API key found', 'ENOLINEARTOKEN', 'Please provide a Linear API key via plugin config or LINEAR_API_KEY environment variable.');
|
|
19
19
|
}
|
|
20
20
|
// Validate team keys format if provided
|
|
21
21
|
const teamKeyPattern = /^[A-Z]+$/;
|
|
22
22
|
const branchPattern = /^[A-Za-z0-9._-]+\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
23
23
|
const invalidTeamKeys = teamKeys.filter((key) => !teamKeyPattern.test(key) && !branchPattern.test(key));
|
|
24
24
|
if (invalidTeamKeys.length > 0) {
|
|
25
|
-
throw new error_1.default(
|
|
26
|
-
`Invalid: ${invalidTeamKeys.join(
|
|
25
|
+
throw new error_1.default('Invalid team key format', 'EINVALIDTEAMKEY', 'Team keys must be uppercase letters (e.g. SD) or branch names (e.g. caio/tk-519-title). ' +
|
|
26
|
+
`Invalid: ${invalidTeamKeys.join(', ')}`);
|
|
27
27
|
}
|
|
28
28
|
// Test API connection
|
|
29
29
|
const client = new linear_client_1.LinearClient(linearApiKey);
|
|
30
30
|
try {
|
|
31
|
-
logger.log(
|
|
31
|
+
logger.log('Verifying Linear API access...');
|
|
32
32
|
await client.testConnection();
|
|
33
|
-
logger.log(
|
|
33
|
+
logger.log('✓ Linear API access verified');
|
|
34
34
|
}
|
|
35
35
|
catch (error) {
|
|
36
|
-
throw new error_1.default(
|
|
36
|
+
throw new error_1.default('Failed to connect to Linear API', 'ELINEARCONNECTION', `Could not connect to Linear API: ${error.message}`);
|
|
37
37
|
}
|
|
38
38
|
// Store validated config in context for other lifecycle methods
|
|
39
39
|
context.linear = {
|
|
40
40
|
apiKey: linearApiKey,
|
|
41
41
|
teamKeys: teamKeys.length > 0 ? teamKeys : null,
|
|
42
|
-
labelPrefix: pluginConfig.labelPrefix ||
|
|
42
|
+
labelPrefix: pluginConfig.labelPrefix || 'v',
|
|
43
43
|
};
|
|
44
44
|
}
|
package/dist/lib/verify.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
// Minimal ESM mocks
|
|
4
|
-
jest.mock(
|
|
4
|
+
jest.mock('@semantic-release/error', () => {
|
|
5
5
|
class SemanticReleaseError extends Error {
|
|
6
6
|
code;
|
|
7
7
|
constructor(message, code) {
|
|
@@ -11,32 +11,32 @@ jest.mock("@semantic-release/error", () => {
|
|
|
11
11
|
}
|
|
12
12
|
return { __esModule: true, default: SemanticReleaseError };
|
|
13
13
|
});
|
|
14
|
-
jest.mock(
|
|
14
|
+
jest.mock('./linear-client', () => ({
|
|
15
15
|
LinearClient: jest.fn().mockImplementation(() => ({
|
|
16
16
|
testConnection: jest.fn().mockResolvedValue({}),
|
|
17
17
|
})),
|
|
18
18
|
}));
|
|
19
|
-
jest.mock(
|
|
19
|
+
jest.mock('node-fetch', () => ({ __esModule: true, default: jest.fn() }));
|
|
20
20
|
const verify_1 = require("./verify");
|
|
21
|
-
describe(
|
|
21
|
+
describe('verify', () => {
|
|
22
22
|
const mockContext = {
|
|
23
23
|
logger: { log: jest.fn(), error: jest.fn() },
|
|
24
24
|
};
|
|
25
25
|
beforeEach(() => {
|
|
26
26
|
delete process.env.LINEAR_API_KEY;
|
|
27
27
|
});
|
|
28
|
-
test(
|
|
29
|
-
await expect((0, verify_1.verifyConditions)({}, mockContext)).rejects.toThrow(
|
|
28
|
+
test('throws without API key', async () => {
|
|
29
|
+
await expect((0, verify_1.verifyConditions)({}, mockContext)).rejects.toThrow('No Linear API key found');
|
|
30
30
|
});
|
|
31
|
-
test(
|
|
31
|
+
test('validates team key format', async () => {
|
|
32
32
|
// Validation happens BEFORE the API call, so it fails fast
|
|
33
|
-
await expect((0, verify_1.verifyConditions)({ apiKey:
|
|
33
|
+
await expect((0, verify_1.verifyConditions)({ apiKey: 'test', teamKeys: ['eng-123'] }, mockContext)).rejects.toThrow('Invalid team key format');
|
|
34
34
|
});
|
|
35
|
-
test(
|
|
35
|
+
test('accepts valid branch like team key', async () => {
|
|
36
36
|
// Accepts branch patterns without hitting API
|
|
37
37
|
await expect((0, verify_1.verifyConditions)({
|
|
38
|
-
apiKey:
|
|
39
|
-
teamKeys: [
|
|
38
|
+
apiKey: 'test',
|
|
39
|
+
teamKeys: ['caio/tk-519-title'],
|
|
40
40
|
}, mockContext)).resolves.toBeUndefined();
|
|
41
41
|
});
|
|
42
42
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -11,10 +11,6 @@ export interface PluginConfig {
|
|
|
11
11
|
addComment?: boolean;
|
|
12
12
|
/** Preview changes without updating Linear (default: false) */
|
|
13
13
|
dryRun?: boolean;
|
|
14
|
-
/** Branches to skip unless they contain issue IDs (default: ["main", "master", "develop", "staging", "production"]) */
|
|
15
|
-
skipBranches?: string[];
|
|
16
|
-
/** Require issues in branch name to process (default: true) */
|
|
17
|
-
requireIssueInBranch?: boolean;
|
|
18
14
|
}
|
|
19
15
|
export interface LinearContext {
|
|
20
16
|
apiKey: string;
|
|
@@ -34,13 +30,8 @@ export interface LinearLabel {
|
|
|
34
30
|
name: string;
|
|
35
31
|
color?: string;
|
|
36
32
|
}
|
|
37
|
-
export interface IssueUpdateResult {
|
|
38
|
-
issueId: string;
|
|
39
|
-
status: "updated" | "failed" | "not_found";
|
|
40
|
-
error?: string;
|
|
41
|
-
}
|
|
42
33
|
export interface LinearViewer {
|
|
43
34
|
id: string;
|
|
44
35
|
name: string;
|
|
45
36
|
}
|
|
46
|
-
export type ReleaseType =
|
|
37
|
+
export type ReleaseType = 'major' | 'premajor' | 'minor' | 'preminor' | 'patch' | 'prepatch' | 'prerelease';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "semantic-release-linear-app",
|
|
3
|
-
"version": "0.3.0-next.
|
|
3
|
+
"version": "0.3.0-next.4",
|
|
4
4
|
"description": "Semantic-release plugin to update Linear issues with version labels",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,29 +46,28 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@semantic-release/error": "^4.0.0",
|
|
49
|
-
"execa": "^9.6.
|
|
49
|
+
"execa": "^9.6.1",
|
|
50
50
|
"node-fetch": "^3.3.2"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@semantic-release/changelog": "^6.0.3",
|
|
54
53
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
55
54
|
"@semantic-release/git": "^10.0.1",
|
|
56
|
-
"@semantic-release/github": "^
|
|
57
|
-
"@semantic-release/npm": "^
|
|
55
|
+
"@semantic-release/github": "^12.0.2",
|
|
56
|
+
"@semantic-release/npm": "^13.1.3",
|
|
58
57
|
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
59
58
|
"@types/jest": "^30.0.0",
|
|
60
|
-
"@types/node": "^
|
|
59
|
+
"@types/node": "^25.0.1",
|
|
61
60
|
"@types/node-fetch": "^2.6.13",
|
|
62
61
|
"@types/semantic-release": "^21.1.0",
|
|
63
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
64
|
-
"@typescript-eslint/parser": "^8.
|
|
65
|
-
"eslint": "^9.
|
|
62
|
+
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
|
63
|
+
"@typescript-eslint/parser": "^8.49.0",
|
|
64
|
+
"eslint": "^9.39.2",
|
|
66
65
|
"husky": "^9.1.7",
|
|
67
66
|
"jest": "^30.2.0",
|
|
68
|
-
"lint-staged": "^16.2.
|
|
69
|
-
"prettier": "^3.
|
|
70
|
-
"semantic-release": "^
|
|
71
|
-
"ts-jest": "^29.4.
|
|
67
|
+
"lint-staged": "^16.2.7",
|
|
68
|
+
"prettier": "^3.7.4",
|
|
69
|
+
"semantic-release": "^25.0.2",
|
|
70
|
+
"ts-jest": "^29.4.6",
|
|
72
71
|
"typescript": "^5.9.3"
|
|
73
72
|
},
|
|
74
73
|
"files": [
|
package/src/lib/linear-client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import fetch from
|
|
2
|
-
import { LinearIssue, LinearLabel, LinearViewer } from
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import { LinearIssue, LinearLabel, LinearViewer } from '../types';
|
|
3
3
|
|
|
4
4
|
interface GraphQLResponse<T> {
|
|
5
5
|
data?: T;
|
|
@@ -11,7 +11,7 @@ interface GraphQLResponse<T> {
|
|
|
11
11
|
*/
|
|
12
12
|
export class LinearClient {
|
|
13
13
|
private apiKey: string;
|
|
14
|
-
private apiUrl: string =
|
|
14
|
+
private apiUrl: string = 'https://api.linear.app/graphql';
|
|
15
15
|
|
|
16
16
|
constructor(apiKey: string) {
|
|
17
17
|
this.apiKey = apiKey;
|
|
@@ -25,10 +25,10 @@ export class LinearClient {
|
|
|
25
25
|
variables: Record<string, unknown> = {},
|
|
26
26
|
): Promise<T> {
|
|
27
27
|
const response = await fetch(this.apiUrl, {
|
|
28
|
-
method:
|
|
28
|
+
method: 'POST',
|
|
29
29
|
headers: {
|
|
30
30
|
Authorization: this.apiKey,
|
|
31
|
-
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
32
|
},
|
|
33
33
|
body: JSON.stringify({ query, variables }),
|
|
34
34
|
});
|
|
@@ -40,7 +40,7 @@ export class LinearClient {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
if (!data.data) {
|
|
43
|
-
throw new Error(
|
|
43
|
+
throw new Error('No data returned from Linear API');
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
return data.data;
|
|
@@ -66,10 +66,7 @@ export class LinearClient {
|
|
|
66
66
|
/**
|
|
67
67
|
* Find or create a label
|
|
68
68
|
*/
|
|
69
|
-
async ensureLabel(
|
|
70
|
-
name: string,
|
|
71
|
-
color: string = "#4752C4",
|
|
72
|
-
): Promise<LinearLabel> {
|
|
69
|
+
async ensureLabel(name: string, color: string = '#4752C4'): Promise<LinearLabel> {
|
|
73
70
|
// First, try to find existing label
|
|
74
71
|
const searchQuery = `
|
|
75
72
|
query FindLabel($name: String!) {
|
|
@@ -143,10 +140,7 @@ export class LinearClient {
|
|
|
143
140
|
/**
|
|
144
141
|
* Add label to issue
|
|
145
142
|
*/
|
|
146
|
-
async addLabelToIssue(
|
|
147
|
-
issueId: string,
|
|
148
|
-
labelId: string,
|
|
149
|
-
): Promise<LinearIssue> {
|
|
143
|
+
async addLabelToIssue(issueId: string, labelId: string): Promise<LinearIssue> {
|
|
150
144
|
const mutation = `
|
|
151
145
|
mutation AddLabel($issueId: String!, $labelId: String!) {
|
|
152
146
|
issueUpdate(
|
|
@@ -178,16 +172,11 @@ export class LinearClient {
|
|
|
178
172
|
/**
|
|
179
173
|
* Remove old version labels from issue
|
|
180
174
|
*/
|
|
181
|
-
async removeVersionLabels(
|
|
182
|
-
issueId: string,
|
|
183
|
-
labelPrefix: string,
|
|
184
|
-
): Promise<LinearIssue | null> {
|
|
175
|
+
async removeVersionLabels(issueId: string, labelPrefix: string): Promise<LinearIssue | null> {
|
|
185
176
|
const issue = await this.getIssue(issueId);
|
|
186
177
|
if (!issue) return null;
|
|
187
178
|
|
|
188
|
-
const versionLabels = issue.labels.nodes.filter((label) =>
|
|
189
|
-
label.name.startsWith(labelPrefix),
|
|
190
|
-
);
|
|
179
|
+
const versionLabels = issue.labels.nodes.filter((label) => label.name.startsWith(labelPrefix));
|
|
191
180
|
|
|
192
181
|
if (versionLabels.length === 0) return issue;
|
|
193
182
|
|
|
@@ -1,49 +1,28 @@
|
|
|
1
|
-
import { parseIssuesFromBranch
|
|
1
|
+
import { parseIssuesFromBranch } from './parse-issues';
|
|
2
2
|
|
|
3
|
-
describe(
|
|
4
|
-
test(
|
|
5
|
-
const branchName =
|
|
3
|
+
describe('parse-issues', () => {
|
|
4
|
+
test('extracts Linear issue IDs from branch name', () => {
|
|
5
|
+
const branchName = 'feature/ENG-123-add-new-feature';
|
|
6
6
|
const result = parseIssuesFromBranch(branchName);
|
|
7
|
-
expect(result).toEqual([
|
|
7
|
+
expect(result).toEqual(['ENG-123']);
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
-
test(
|
|
11
|
-
const branchName =
|
|
10
|
+
test('extracts multiple issue IDs from branch name', () => {
|
|
11
|
+
const branchName = 'fix/ENG-123-FEAT-456-bug-fix';
|
|
12
12
|
const result = parseIssuesFromBranch(branchName);
|
|
13
|
-
expect(result).toEqual(expect.arrayContaining([
|
|
13
|
+
expect(result).toEqual(expect.arrayContaining(['ENG-123', 'FEAT-456']));
|
|
14
14
|
expect(result).toHaveLength(2);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test(
|
|
18
|
-
const branchName =
|
|
19
|
-
const result = parseIssuesFromBranch(branchName, [
|
|
20
|
-
expect(result).toEqual([
|
|
17
|
+
test('filters by team keys when provided', () => {
|
|
18
|
+
const branchName = 'feature/ENG-123-OTHER-456';
|
|
19
|
+
const result = parseIssuesFromBranch(branchName, ['ENG']);
|
|
20
|
+
expect(result).toEqual(['ENG-123']);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test(
|
|
24
|
-
const branchName =
|
|
23
|
+
test('returns empty array for branch without issues', () => {
|
|
24
|
+
const branchName = 'feature/no-issues-here';
|
|
25
25
|
const result = parseIssuesFromBranch(branchName);
|
|
26
26
|
expect(result).toEqual([]);
|
|
27
27
|
});
|
|
28
|
-
|
|
29
|
-
test("shouldProcessBranch returns false for skip branches without issues", () => {
|
|
30
|
-
expect(shouldProcessBranch("main", ["main", "master"])).toBe(false);
|
|
31
|
-
expect(shouldProcessBranch("master", ["main", "master"])).toBe(false);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("shouldProcessBranch returns true for skip branches with issues", () => {
|
|
35
|
-
expect(shouldProcessBranch("main-ENG-123", ["main", "master"])).toBe(true);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("shouldProcessBranch returns true for feature branches with issues", () => {
|
|
39
|
-
expect(shouldProcessBranch("feature/ENG-123", ["main", "master"])).toBe(
|
|
40
|
-
true,
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("shouldProcessBranch returns false for feature branches without issues", () => {
|
|
45
|
-
expect(shouldProcessBranch("feature/no-issues", ["main", "master"])).toBe(
|
|
46
|
-
false,
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
28
|
});
|
package/src/lib/parse-issues.ts
CHANGED
|
@@ -16,10 +16,10 @@ export function parseIssuesFromBranch(
|
|
|
16
16
|
const issues = new Set<string>();
|
|
17
17
|
|
|
18
18
|
// Build regex pattern based on team keys
|
|
19
|
-
const teamPattern = teamKeys ? `(?:${teamKeys.join(
|
|
19
|
+
const teamPattern = teamKeys ? `(?:${teamKeys.join('|')})` : '[A-Z]+';
|
|
20
20
|
|
|
21
21
|
// Pattern matches: feature/ENG-123-description, ENG-123, bugfix/FEAT-45, etc.
|
|
22
|
-
const issuePattern = new RegExp(`\\b(${teamPattern}-\\d+)\\b`,
|
|
22
|
+
const issuePattern = new RegExp(`\\b(${teamPattern}-\\d+)\\b`, 'gi');
|
|
23
23
|
|
|
24
24
|
const matches = Array.from(branchName.matchAll(issuePattern));
|
|
25
25
|
for (const match of matches) {
|
|
@@ -28,30 +28,3 @@ export function parseIssuesFromBranch(
|
|
|
28
28
|
|
|
29
29
|
return Array.from(issues);
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Check if branch should be processed for Linear updates
|
|
34
|
-
* @param branchName - The branch name to check
|
|
35
|
-
* @param skipBranches - Branches to skip (default: main, master, develop without issue IDs)
|
|
36
|
-
* @returns true if branch should be processed
|
|
37
|
-
*/
|
|
38
|
-
export function shouldProcessBranch(
|
|
39
|
-
branchName: string,
|
|
40
|
-
skipBranches: string[] = [
|
|
41
|
-
"main",
|
|
42
|
-
"master",
|
|
43
|
-
"develop",
|
|
44
|
-
"staging",
|
|
45
|
-
"production",
|
|
46
|
-
],
|
|
47
|
-
): boolean {
|
|
48
|
-
// Skip certain branches UNLESS they contain an issue ID
|
|
49
|
-
if (skipBranches.includes(branchName)) {
|
|
50
|
-
// These branches are OK if they contain an issue ID
|
|
51
|
-
const hasIssue = /[A-Z]+-\d+/.test(branchName);
|
|
52
|
-
return hasIssue;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Process any other branch that contains an issue ID
|
|
56
|
-
return /[A-Z]+-\d+/.test(branchName);
|
|
57
|
-
}
|
package/src/lib/success.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { SuccessContext } from
|
|
2
|
-
import { execa } from
|
|
3
|
-
import { LinearClient } from
|
|
4
|
-
import { parseIssuesFromBranch } from
|
|
5
|
-
import { PluginConfig, LinearContext, ReleaseType } from
|
|
1
|
+
import { SuccessContext } from 'semantic-release';
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { LinearClient } from './linear-client';
|
|
4
|
+
import { parseIssuesFromBranch } from './parse-issues';
|
|
5
|
+
import { PluginConfig, LinearContext, ReleaseType } from '../types';
|
|
6
6
|
|
|
7
7
|
interface ExtendedContext extends SuccessContext {
|
|
8
8
|
linear?: LinearContext;
|
|
@@ -13,37 +13,38 @@ interface ExtendedContext extends SuccessContext {
|
|
|
13
13
|
*/
|
|
14
14
|
async function findSourceBranches(
|
|
15
15
|
commits: readonly { hash: string }[],
|
|
16
|
-
logger: SuccessContext[
|
|
16
|
+
logger: SuccessContext['logger'],
|
|
17
17
|
): Promise<Set<string>> {
|
|
18
18
|
const branches = new Set<string>();
|
|
19
|
+
const skipBranches = ['main', 'master', 'develop', 'stable', 'HEAD'];
|
|
19
20
|
|
|
20
21
|
if (commits.length === 0) return branches;
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
!["main", "master", "develop", "stable", "HEAD"].includes(match[1])
|
|
40
|
-
) {
|
|
41
|
-
branches.add(match[1]);
|
|
42
|
-
logger.log(`Found source branch: ${match[1]}`);
|
|
23
|
+
// Check all commits to find all source branches
|
|
24
|
+
for (const commit of commits) {
|
|
25
|
+
try {
|
|
26
|
+
const { stdout } = await execa('git', [
|
|
27
|
+
'branch',
|
|
28
|
+
'-r',
|
|
29
|
+
'--contains',
|
|
30
|
+
commit.hash,
|
|
31
|
+
'--merged',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const branchLines = stdout.split('\n').map((b: string) => b.trim());
|
|
35
|
+
for (const line of branchLines) {
|
|
36
|
+
const match = line.match(/origin\/(.+)/);
|
|
37
|
+
if (match && !skipBranches.includes(match[1])) {
|
|
38
|
+
branches.add(match[1]);
|
|
39
|
+
}
|
|
43
40
|
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Commit might not exist or other git error, continue with next commit
|
|
44
43
|
}
|
|
45
|
-
}
|
|
46
|
-
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (branches.size > 0) {
|
|
47
|
+
logger.log(`Found source branches: ${Array.from(branches).join(', ')}`);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
return branches;
|
|
@@ -52,33 +53,26 @@ async function findSourceBranches(
|
|
|
52
53
|
/**
|
|
53
54
|
* Update Linear issues after a successful release
|
|
54
55
|
*/
|
|
55
|
-
export async function success(
|
|
56
|
-
pluginConfig: PluginConfig,
|
|
57
|
-
context: ExtendedContext,
|
|
58
|
-
): Promise<void> {
|
|
56
|
+
export async function success(pluginConfig: PluginConfig, context: ExtendedContext): Promise<void> {
|
|
59
57
|
const { logger, nextRelease, linear, commits } = context;
|
|
60
58
|
|
|
61
59
|
if (!linear) {
|
|
62
|
-
logger.log(
|
|
60
|
+
logger.log('Linear context not found, skipping issue updates');
|
|
63
61
|
return;
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
if (!commits || commits.length === 0) {
|
|
67
|
-
logger.log(
|
|
65
|
+
logger.log('No commits found in release, skipping Linear updates');
|
|
68
66
|
return;
|
|
69
67
|
}
|
|
70
68
|
|
|
71
|
-
const {
|
|
72
|
-
removeOldLabels = true,
|
|
73
|
-
addComment = false,
|
|
74
|
-
dryRun = false,
|
|
75
|
-
} = pluginConfig;
|
|
69
|
+
const { removeOldLabels = true, addComment = false, dryRun = false } = pluginConfig;
|
|
76
70
|
|
|
77
71
|
// Find all branches that contributed to this release
|
|
78
72
|
const sourceBranches = await findSourceBranches(commits, logger);
|
|
79
73
|
|
|
80
74
|
if (sourceBranches.size === 0) {
|
|
81
|
-
logger.log(
|
|
75
|
+
logger.log('No source branches found, skipping Linear updates');
|
|
82
76
|
return;
|
|
83
77
|
}
|
|
84
78
|
|
|
@@ -90,30 +84,30 @@ export async function success(
|
|
|
90
84
|
}
|
|
91
85
|
|
|
92
86
|
if (issueIds.size === 0) {
|
|
93
|
-
logger.log(
|
|
94
|
-
`No Linear issues found in branches: ${Array.from(sourceBranches).join(", ")}`,
|
|
95
|
-
);
|
|
87
|
+
logger.log(`No Linear issues found in branches: ${Array.from(sourceBranches).join(', ')}`);
|
|
96
88
|
return;
|
|
97
89
|
}
|
|
98
90
|
|
|
99
91
|
logger.log(
|
|
100
|
-
`Found ${issueIds.size} Linear issue(s): ${Array.from(issueIds).join(
|
|
92
|
+
`Found ${issueIds.size} Linear issue(s): ${Array.from(issueIds).join(', ')} ` +
|
|
101
93
|
`from ${sourceBranches.size} branch(es)`,
|
|
102
94
|
);
|
|
103
95
|
|
|
96
|
+
// Build label name with optional channel suffix
|
|
97
|
+
const version = nextRelease.version;
|
|
98
|
+
const channel = nextRelease.channel;
|
|
99
|
+
const labelName = channel
|
|
100
|
+
? `${linear.labelPrefix}${version}-${channel}`
|
|
101
|
+
: `${linear.labelPrefix}${version}`;
|
|
102
|
+
|
|
104
103
|
if (dryRun) {
|
|
105
|
-
logger.log(
|
|
106
|
-
logger.log(
|
|
107
|
-
`[Dry run] Would apply label: ${linear.labelPrefix}${nextRelease.version}`,
|
|
108
|
-
);
|
|
104
|
+
logger.log('[Dry run] Would update issues:', Array.from(issueIds));
|
|
105
|
+
logger.log(`[Dry run] Would apply label: ${labelName}`);
|
|
109
106
|
return;
|
|
110
107
|
}
|
|
111
108
|
|
|
112
109
|
// Initialize Linear client and prepare label
|
|
113
110
|
const client = new LinearClient(linear.apiKey);
|
|
114
|
-
const version = nextRelease.version;
|
|
115
|
-
const channel = nextRelease.channel || "latest";
|
|
116
|
-
const labelName = `${linear.labelPrefix}${version}`;
|
|
117
111
|
const labelColor = getLabelColor(nextRelease.type as ReleaseType);
|
|
118
112
|
|
|
119
113
|
// Ensure the version label exists
|
|
@@ -127,7 +121,7 @@ export async function success(
|
|
|
127
121
|
const issue = await client.getIssue(issueId);
|
|
128
122
|
if (!issue) {
|
|
129
123
|
logger.warn(`Issue ${issueId} not found in Linear`);
|
|
130
|
-
return { issueId, status:
|
|
124
|
+
return { issueId, status: 'not_found' };
|
|
131
125
|
}
|
|
132
126
|
|
|
133
127
|
// Remove old version labels if configured
|
|
@@ -140,37 +134,34 @@ export async function success(
|
|
|
140
134
|
|
|
141
135
|
// Add comment if configured
|
|
142
136
|
if (addComment) {
|
|
143
|
-
const emoji = channel
|
|
144
|
-
const channelText =
|
|
145
|
-
channel === "latest" ? "" : ` (${channel} channel)`;
|
|
137
|
+
const emoji = channel ? '🔬' : '🚀';
|
|
138
|
+
const channelText = channel ? ` (${channel} channel)` : '';
|
|
146
139
|
const comment = `${emoji} Released in version ${version}${channelText}`;
|
|
147
140
|
await client.addComment(issue.id, comment);
|
|
148
141
|
}
|
|
149
142
|
|
|
150
143
|
logger.log(`✓ Updated issue ${issueId}`);
|
|
151
|
-
return { issueId, status:
|
|
144
|
+
return { issueId, status: 'updated' };
|
|
152
145
|
} catch (error) {
|
|
153
146
|
const message = error instanceof Error ? error.message : String(error);
|
|
154
147
|
logger.error(`Failed to update issue ${issueId}: ${message}`);
|
|
155
|
-
return { issueId, status:
|
|
148
|
+
return { issueId, status: 'failed', error: message };
|
|
156
149
|
}
|
|
157
150
|
}),
|
|
158
151
|
);
|
|
159
152
|
|
|
160
153
|
// Log summary
|
|
161
154
|
const updated = results.filter(
|
|
162
|
-
(r) => r.status ===
|
|
155
|
+
(r) => r.status === 'fulfilled' && r.value?.status === 'updated',
|
|
163
156
|
).length;
|
|
164
157
|
const failed = results.filter(
|
|
165
|
-
(r) => r.status ===
|
|
158
|
+
(r) => r.status === 'rejected' || r.value?.status === 'failed',
|
|
166
159
|
).length;
|
|
167
160
|
const notFound = results.filter(
|
|
168
|
-
(r) => r.status ===
|
|
161
|
+
(r) => r.status === 'fulfilled' && r.value?.status === 'not_found',
|
|
169
162
|
).length;
|
|
170
163
|
|
|
171
|
-
logger.log(
|
|
172
|
-
`Linear update complete: ${updated} updated, ${failed} failed, ${notFound} not found`,
|
|
173
|
-
);
|
|
164
|
+
logger.log(`Linear update complete: ${updated} updated, ${failed} failed, ${notFound} not found`);
|
|
174
165
|
}
|
|
175
166
|
|
|
176
167
|
/**
|
|
@@ -178,14 +169,14 @@ export async function success(
|
|
|
178
169
|
*/
|
|
179
170
|
function getLabelColor(releaseType: ReleaseType): string {
|
|
180
171
|
const colors: Record<ReleaseType, string> = {
|
|
181
|
-
major:
|
|
182
|
-
premajor:
|
|
183
|
-
minor:
|
|
184
|
-
preminor:
|
|
185
|
-
patch:
|
|
186
|
-
prepatch:
|
|
187
|
-
prerelease:
|
|
172
|
+
major: '#F44336', // Red for breaking changes
|
|
173
|
+
premajor: '#E91E63', // Pink for pre-major
|
|
174
|
+
minor: '#FF9800', // Orange for new features
|
|
175
|
+
preminor: '#FFC107', // Amber for pre-minor
|
|
176
|
+
patch: '#4CAF50', // Green for fixes
|
|
177
|
+
prepatch: '#8BC34A', // Light green for pre-patch
|
|
178
|
+
prerelease: '#9C27B0', // Purple for prereleases
|
|
188
179
|
};
|
|
189
180
|
|
|
190
|
-
return colors[releaseType] ||
|
|
181
|
+
return colors[releaseType] || '#4752C4'; // Default blue
|
|
191
182
|
}
|
package/src/lib/verify.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Minimal ESM mocks
|
|
2
|
-
jest.mock(
|
|
2
|
+
jest.mock('@semantic-release/error', () => {
|
|
3
3
|
class SemanticReleaseError extends Error {
|
|
4
4
|
code: string;
|
|
5
5
|
constructor(message: string, code: string) {
|
|
@@ -10,18 +10,18 @@ jest.mock("@semantic-release/error", () => {
|
|
|
10
10
|
return { __esModule: true, default: SemanticReleaseError };
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
jest.mock(
|
|
13
|
+
jest.mock('./linear-client', () => ({
|
|
14
14
|
LinearClient: jest.fn().mockImplementation(() => ({
|
|
15
15
|
testConnection: jest.fn().mockResolvedValue({}),
|
|
16
16
|
})),
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
|
-
jest.mock(
|
|
19
|
+
jest.mock('node-fetch', () => ({ __esModule: true, default: jest.fn() }));
|
|
20
20
|
|
|
21
|
-
import { verifyConditions } from
|
|
22
|
-
import { VerifyConditionsContext } from
|
|
21
|
+
import { verifyConditions } from './verify';
|
|
22
|
+
import { VerifyConditionsContext } from 'semantic-release';
|
|
23
23
|
|
|
24
|
-
describe(
|
|
24
|
+
describe('verify', () => {
|
|
25
25
|
const mockContext = {
|
|
26
26
|
logger: { log: jest.fn(), error: jest.fn() },
|
|
27
27
|
} as VerifyConditionsContext;
|
|
@@ -30,26 +30,24 @@ describe("verify", () => {
|
|
|
30
30
|
delete process.env.LINEAR_API_KEY;
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
test(
|
|
34
|
-
await expect(verifyConditions({}, mockContext)).rejects.toThrow(
|
|
35
|
-
"No Linear API key found",
|
|
36
|
-
);
|
|
33
|
+
test('throws without API key', async () => {
|
|
34
|
+
await expect(verifyConditions({}, mockContext)).rejects.toThrow('No Linear API key found');
|
|
37
35
|
});
|
|
38
36
|
|
|
39
|
-
test(
|
|
37
|
+
test('validates team key format', async () => {
|
|
40
38
|
// Validation happens BEFORE the API call, so it fails fast
|
|
41
39
|
await expect(
|
|
42
|
-
verifyConditions({ apiKey:
|
|
43
|
-
).rejects.toThrow(
|
|
40
|
+
verifyConditions({ apiKey: 'test', teamKeys: ['eng-123'] }, mockContext),
|
|
41
|
+
).rejects.toThrow('Invalid team key format');
|
|
44
42
|
});
|
|
45
43
|
|
|
46
|
-
test(
|
|
44
|
+
test('accepts valid branch like team key', async () => {
|
|
47
45
|
// Accepts branch patterns without hitting API
|
|
48
46
|
await expect(
|
|
49
47
|
verifyConditions(
|
|
50
48
|
{
|
|
51
|
-
apiKey:
|
|
52
|
-
teamKeys: [
|
|
49
|
+
apiKey: 'test',
|
|
50
|
+
teamKeys: ['caio/tk-519-title'],
|
|
53
51
|
},
|
|
54
52
|
mockContext,
|
|
55
53
|
),
|