semantic-release-linear-app 0.2.0 → 0.3.0-next.2

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.
@@ -2,14 +2,9 @@ import { SuccessContext } from "semantic-release";
2
2
  import { PluginConfig, LinearContext } from "../types";
3
3
  interface ExtendedContext extends SuccessContext {
4
4
  linear?: LinearContext;
5
- branch: {
6
- name: string;
7
- [key: string]: unknown;
8
- };
9
5
  }
10
6
  /**
11
7
  * Update Linear issues after a successful release
12
- * Only uses branch name for issue detection - single source of truth
13
8
  */
14
9
  export declare function success(pluginConfig: PluginConfig, context: ExtendedContext): Promise<void>;
15
10
  export {};
@@ -1,61 +1,94 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.success = success;
4
+ const execa_1 = require("execa");
4
5
  const linear_client_1 = require("./linear-client");
5
6
  const parse_issues_1 = require("./parse-issues");
7
+ /**
8
+ * Find source branches that contain the given commits
9
+ */
10
+ async function findSourceBranches(commits, logger) {
11
+ const branches = new Set();
12
+ if (commits.length === 0)
13
+ return branches;
14
+ try {
15
+ // Get all branches that contain these commits
16
+ const { stdout } = await (0, execa_1.execa)("git", [
17
+ "branch",
18
+ "-r",
19
+ "--contains",
20
+ commits[0].hash,
21
+ "--merged",
22
+ ]);
23
+ // Parse remote branch names
24
+ const branchLines = stdout.split("\n").map((b) => b.trim());
25
+ for (const line of branchLines) {
26
+ // Extract branch name from origin/feature/ENG-123-description
27
+ const match = line.match(/origin\/(.+)/);
28
+ if (match &&
29
+ !["main", "master", "develop", "stable", "HEAD"].includes(match[1])) {
30
+ branches.add(match[1]);
31
+ logger.log(`Found source branch: ${match[1]}`);
32
+ }
33
+ }
34
+ }
35
+ catch (error) {
36
+ logger.debug(`Git branch lookup failed: ${error.message}`);
37
+ }
38
+ return branches;
39
+ }
6
40
  /**
7
41
  * Update Linear issues after a successful release
8
- * Only uses branch name for issue detection - single source of truth
9
42
  */
10
43
  async function success(pluginConfig, context) {
11
- const { logger, nextRelease, linear, branch } = context;
44
+ const { logger, nextRelease, linear, commits } = context;
12
45
  if (!linear) {
13
46
  logger.log("Linear context not found, skipping issue updates");
14
47
  return;
15
48
  }
16
- if (!branch.name) {
17
- logger.log("No branch name available, skipping Linear updates");
49
+ if (!commits || commits.length === 0) {
50
+ logger.log("No commits found in release, skipping Linear updates");
18
51
  return;
19
52
  }
20
- const { removeOldLabels = true, addComment = false, dryRun = false, skipBranches = ["main", "master", "develop", "staging", "production"], requireIssueInBranch = true, } = pluginConfig;
21
- // Check if this branch should be processed
22
- if (requireIssueInBranch && !(0, parse_issues_1.shouldProcessBranch)(branch.name, skipBranches)) {
23
- logger.log(`Branch "${branch.name}" doesn't contain Linear issues or is in skip list, skipping updates`);
53
+ const { removeOldLabels = true, addComment = false, dryRun = false, } = pluginConfig;
54
+ // Find all branches that contributed to this release
55
+ const sourceBranches = await findSourceBranches(commits, logger);
56
+ if (sourceBranches.size === 0) {
57
+ logger.log("No source branches found, skipping Linear updates");
24
58
  return;
25
59
  }
26
- const client = new linear_client_1.LinearClient(linear.apiKey);
27
- const version = nextRelease.version;
28
- const channel = nextRelease.channel || "latest";
29
- // Format the label based on configuration
30
- const labelName = `${linear.labelPrefix}${version}`;
31
- const labelColor = getLabelColor(nextRelease.type);
32
- logger.log(`Updating Linear issues for release ${version} (${channel}) from branch "${branch.name}"`);
33
- // Extract issue IDs from branch name ONLY
34
- const issueIds = (0, parse_issues_1.parseIssuesFromBranch)(branch.name, linear.teamKeys);
35
- if (issueIds.length === 0) {
36
- logger.log(`No Linear issues found in branch name "${branch.name}"`);
37
- if (requireIssueInBranch) {
38
- logger.warn("⚠️ Consider using branch names like: feature/ENG-123-description");
39
- }
60
+ // Extract Linear issue IDs from all found branches
61
+ const issueIds = new Set();
62
+ for (const branchName of Array.from(sourceBranches)) {
63
+ const branchIssues = (0, parse_issues_1.parseIssuesFromBranch)(branchName, linear.teamKeys);
64
+ branchIssues.forEach((id) => issueIds.add(id));
65
+ }
66
+ if (issueIds.size === 0) {
67
+ logger.log(`No Linear issues found in branches: ${Array.from(sourceBranches).join(", ")}`);
40
68
  return;
41
69
  }
42
- logger.log(`Found ${issueIds.length} Linear issue(s) in branch "${branch.name}": ${issueIds.join(", ")}`);
70
+ logger.log(`Found ${issueIds.size} Linear issue(s): ${Array.from(issueIds).join(", ")} ` +
71
+ `from ${sourceBranches.size} branch(es)`);
43
72
  if (dryRun) {
44
- logger.log("[Dry run] Would update issues:", issueIds);
45
- logger.log(`[Dry run] Would apply label: ${labelName}`);
46
- logger.log(`[Dry run] Branch: ${branch.name}`);
73
+ logger.log("[Dry run] Would update issues:", Array.from(issueIds));
74
+ logger.log(`[Dry run] Would apply label: ${linear.labelPrefix}${nextRelease.version}`);
47
75
  return;
48
76
  }
77
+ // Initialize Linear client and prepare label
78
+ const client = new linear_client_1.LinearClient(linear.apiKey);
79
+ const version = nextRelease.version;
80
+ const channel = nextRelease.channel || "latest";
81
+ const labelName = `${linear.labelPrefix}${version}`;
82
+ const labelColor = getLabelColor(nextRelease.type);
49
83
  // Ensure the version label exists
50
84
  const label = await client.ensureLabel(labelName, labelColor);
51
85
  logger.log(`✓ Ensured label exists: ${labelName}`);
52
- // Update each issue
53
- const results = await Promise.allSettled(issueIds.map(async (issueId) => {
86
+ // Update each Linear issue
87
+ const results = await Promise.allSettled(Array.from(issueIds).map(async (issueId) => {
54
88
  try {
55
- // Get the issue first
56
89
  const issue = await client.getIssue(issueId);
57
90
  if (!issue) {
58
- logger.warn(`Issue ${issueId} not found in Linear, skipping`);
91
+ logger.warn(`Issue ${issueId} not found in Linear`);
59
92
  return { issueId, status: "not_found" };
60
93
  }
61
94
  // Remove old version labels if configured
@@ -66,7 +99,9 @@ async function success(pluginConfig, context) {
66
99
  await client.addLabelToIssue(issue.id, label.id);
67
100
  // Add comment if configured
68
101
  if (addComment) {
69
- const comment = formatComment(version, channel, nextRelease, branch.name);
102
+ const emoji = channel === "latest" ? "🚀" : "🔬";
103
+ const channelText = channel === "latest" ? "" : ` (${channel} channel)`;
104
+ const comment = `${emoji} Released in version ${version}${channelText}`;
70
105
  await client.addComment(issue.id, comment);
71
106
  }
72
107
  logger.log(`✓ Updated issue ${issueId}`);
@@ -79,10 +114,9 @@ async function success(pluginConfig, context) {
79
114
  }
80
115
  }));
81
116
  // Log summary
82
- const updated = results.filter((r) => r.status === "fulfilled" && r.value.status === "updated").length;
83
- const failed = results.filter((r) => r.status === "rejected" ||
84
- (r.status === "fulfilled" && r.value.status === "failed")).length;
85
- const notFound = results.filter((r) => r.status === "fulfilled" && r.value.status === "not_found").length;
117
+ const updated = results.filter((r) => r.status === "fulfilled" && r.value?.status === "updated").length;
118
+ const failed = results.filter((r) => r.status === "rejected" || r.value?.status === "failed").length;
119
+ const notFound = results.filter((r) => r.status === "fulfilled" && r.value?.status === "not_found").length;
86
120
  logger.log(`Linear update complete: ${updated} updated, ${failed} failed, ${notFound} not found`);
87
121
  }
88
122
  /**
@@ -100,17 +134,3 @@ function getLabelColor(releaseType) {
100
134
  };
101
135
  return colors[releaseType] || "#4752C4"; // Default blue
102
136
  }
103
- /**
104
- * Format comment for Linear issue
105
- */
106
- function formatComment(version, channel, release, branchName) {
107
- const emoji = channel === "latest" ? "🚀" : "🔬";
108
- const channelText = channel === "latest" ? "stable" : channel;
109
- let comment = `${emoji} **Released in \`v${version}\`** (${channelText})\n\n`;
110
- comment += `📌 Released from branch: \`${branchName}\`\n\n`;
111
- const githubRepo = process.env.GITHUB_REPOSITORY;
112
- if (release.gitTag && githubRepo) {
113
- comment += `[View release →](https://github.com/${githubRepo}/releases/tag/${release.gitTag})`;
114
- }
115
- return comment;
116
- }
@@ -18,8 +18,12 @@ async function verifyConditions(pluginConfig, context) {
18
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
- if (teamKeys.length > 0 && !teamKeys.every((key) => /^[A-Z]+$/.test(key))) {
22
- throw new error_1.default("Invalid team key format", "EINVALIDTEAMKEY", `Team keys must be uppercase letters only. Got: ${teamKeys.join(", ")}`);
21
+ const teamKeyPattern = /^[A-Z]+$/;
22
+ const branchPattern = /^[A-Za-z0-9._-]+\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
23
+ const invalidTeamKeys = teamKeys.filter((key) => !teamKeyPattern.test(key) && !branchPattern.test(key));
24
+ if (invalidTeamKeys.length > 0) {
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(", ")}`);
23
27
  }
24
28
  // Test API connection
25
29
  const client = new linear_client_1.LinearClient(linearApiKey);
@@ -11,6 +11,11 @@ jest.mock("@semantic-release/error", () => {
11
11
  }
12
12
  return { __esModule: true, default: SemanticReleaseError };
13
13
  });
14
+ jest.mock("./linear-client", () => ({
15
+ LinearClient: jest.fn().mockImplementation(() => ({
16
+ testConnection: jest.fn().mockResolvedValue({}),
17
+ })),
18
+ }));
14
19
  jest.mock("node-fetch", () => ({ __esModule: true, default: jest.fn() }));
15
20
  const verify_1 = require("./verify");
16
21
  describe("verify", () => {
@@ -27,4 +32,11 @@ describe("verify", () => {
27
32
  // Validation happens BEFORE the API call, so it fails fast
28
33
  await expect((0, verify_1.verifyConditions)({ apiKey: "test", teamKeys: ["eng-123"] }, mockContext)).rejects.toThrow("Invalid team key format");
29
34
  });
35
+ test("accepts valid branch like team key", async () => {
36
+ // Accepts branch patterns without hitting API
37
+ await expect((0, verify_1.verifyConditions)({
38
+ apiKey: "test",
39
+ teamKeys: ["caio/tk-519-title"],
40
+ }, mockContext)).resolves.toBeUndefined();
41
+ });
30
42
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "semantic-release-linear-app",
3
- "version": "0.2.0",
3
+ "version": "0.3.0-next.2",
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,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@semantic-release/error": "^4.0.0",
49
+ "execa": "^9.6.0",
49
50
  "node-fetch": "^3.3.2"
50
51
  },
51
52
  "devDependencies": {
@@ -1,38 +1,70 @@
1
1
  import { SuccessContext } from "semantic-release";
2
+ import { execa } from "execa";
2
3
  import { LinearClient } from "./linear-client";
3
- import { parseIssuesFromBranch, shouldProcessBranch } from "./parse-issues";
4
- import {
5
- PluginConfig,
6
- LinearContext,
7
- IssueUpdateResult,
8
- ReleaseType,
9
- } from "../types";
4
+ import { parseIssuesFromBranch } from "./parse-issues";
5
+ import { PluginConfig, LinearContext, ReleaseType } from "../types";
10
6
 
11
7
  interface ExtendedContext extends SuccessContext {
12
8
  linear?: LinearContext;
13
- branch: {
14
- name: string;
15
- [key: string]: unknown;
16
- };
9
+ }
10
+
11
+ /**
12
+ * Find source branches that contain the given commits
13
+ */
14
+ async function findSourceBranches(
15
+ commits: readonly { hash: string }[],
16
+ logger: SuccessContext["logger"],
17
+ ): Promise<Set<string>> {
18
+ const branches = new Set<string>();
19
+
20
+ if (commits.length === 0) return branches;
21
+
22
+ try {
23
+ // Get all branches that contain these commits
24
+ const { stdout } = await execa("git", [
25
+ "branch",
26
+ "-r",
27
+ "--contains",
28
+ commits[0].hash,
29
+ "--merged",
30
+ ]);
31
+
32
+ // Parse remote branch names
33
+ const branchLines = stdout.split("\n").map((b: string) => b.trim());
34
+ for (const line of branchLines) {
35
+ // Extract branch name from origin/feature/ENG-123-description
36
+ const match = line.match(/origin\/(.+)/);
37
+ if (
38
+ match &&
39
+ !["main", "master", "develop", "stable", "HEAD"].includes(match[1])
40
+ ) {
41
+ branches.add(match[1]);
42
+ logger.log(`Found source branch: ${match[1]}`);
43
+ }
44
+ }
45
+ } catch (error) {
46
+ logger.debug(`Git branch lookup failed: ${(error as Error).message}`);
47
+ }
48
+
49
+ return branches;
17
50
  }
18
51
 
19
52
  /**
20
53
  * Update Linear issues after a successful release
21
- * Only uses branch name for issue detection - single source of truth
22
54
  */
23
55
  export async function success(
24
56
  pluginConfig: PluginConfig,
25
57
  context: ExtendedContext,
26
58
  ): Promise<void> {
27
- const { logger, nextRelease, linear, branch } = context;
59
+ const { logger, nextRelease, linear, commits } = context;
28
60
 
29
61
  if (!linear) {
30
62
  logger.log("Linear context not found, skipping issue updates");
31
63
  return;
32
64
  }
33
65
 
34
- if (!branch.name) {
35
- logger.log("No branch name available, skipping Linear updates");
66
+ if (!commits || commits.length === 0) {
67
+ logger.log("No commits found in release, skipping Linear updates");
36
68
  return;
37
69
  }
38
70
 
@@ -40,67 +72,61 @@ export async function success(
40
72
  removeOldLabels = true,
41
73
  addComment = false,
42
74
  dryRun = false,
43
- skipBranches = ["main", "master", "develop", "staging", "production"],
44
- requireIssueInBranch = true,
45
75
  } = pluginConfig;
46
76
 
47
- // Check if this branch should be processed
48
- if (requireIssueInBranch && !shouldProcessBranch(branch.name, skipBranches)) {
49
- logger.log(
50
- `Branch "${branch.name}" doesn't contain Linear issues or is in skip list, skipping updates`,
51
- );
77
+ // Find all branches that contributed to this release
78
+ const sourceBranches = await findSourceBranches(commits, logger);
79
+
80
+ if (sourceBranches.size === 0) {
81
+ logger.log("No source branches found, skipping Linear updates");
52
82
  return;
53
83
  }
54
84
 
55
- const client = new LinearClient(linear.apiKey);
56
- const version = nextRelease.version;
57
- const channel = nextRelease.channel || "latest";
58
-
59
- // Format the label based on configuration
60
- const labelName = `${linear.labelPrefix}${version}`;
61
- const labelColor = getLabelColor(nextRelease.type as ReleaseType);
62
-
63
- logger.log(
64
- `Updating Linear issues for release ${version} (${channel}) from branch "${branch.name}"`,
65
- );
66
-
67
- // Extract issue IDs from branch name ONLY
68
- const issueIds = parseIssuesFromBranch(branch.name, linear.teamKeys);
85
+ // Extract Linear issue IDs from all found branches
86
+ const issueIds = new Set<string>();
87
+ for (const branchName of Array.from(sourceBranches)) {
88
+ const branchIssues = parseIssuesFromBranch(branchName, linear.teamKeys);
89
+ branchIssues.forEach((id) => issueIds.add(id));
90
+ }
69
91
 
70
- if (issueIds.length === 0) {
71
- logger.log(`No Linear issues found in branch name "${branch.name}"`);
72
- if (requireIssueInBranch) {
73
- logger.warn(
74
- "⚠️ Consider using branch names like: feature/ENG-123-description",
75
- );
76
- }
92
+ if (issueIds.size === 0) {
93
+ logger.log(
94
+ `No Linear issues found in branches: ${Array.from(sourceBranches).join(", ")}`,
95
+ );
77
96
  return;
78
97
  }
79
98
 
80
99
  logger.log(
81
- `Found ${issueIds.length} Linear issue(s) in branch "${branch.name}": ${issueIds.join(", ")}`,
100
+ `Found ${issueIds.size} Linear issue(s): ${Array.from(issueIds).join(", ")} ` +
101
+ `from ${sourceBranches.size} branch(es)`,
82
102
  );
83
103
 
84
104
  if (dryRun) {
85
- logger.log("[Dry run] Would update issues:", issueIds);
86
- logger.log(`[Dry run] Would apply label: ${labelName}`);
87
- logger.log(`[Dry run] Branch: ${branch.name}`);
105
+ logger.log("[Dry run] Would update issues:", Array.from(issueIds));
106
+ logger.log(
107
+ `[Dry run] Would apply label: ${linear.labelPrefix}${nextRelease.version}`,
108
+ );
88
109
  return;
89
110
  }
90
111
 
112
+ // Initialize Linear client and prepare label
113
+ const client = new LinearClient(linear.apiKey);
114
+ const version = nextRelease.version;
115
+ const channel = nextRelease.channel || "latest";
116
+ const labelName = `${linear.labelPrefix}${version}`;
117
+ const labelColor = getLabelColor(nextRelease.type as ReleaseType);
118
+
91
119
  // Ensure the version label exists
92
120
  const label = await client.ensureLabel(labelName, labelColor);
93
121
  logger.log(`✓ Ensured label exists: ${labelName}`);
94
122
 
95
- // Update each issue
123
+ // Update each Linear issue
96
124
  const results = await Promise.allSettled(
97
- issueIds.map(async (issueId): Promise<IssueUpdateResult> => {
125
+ Array.from(issueIds).map(async (issueId) => {
98
126
  try {
99
- // Get the issue first
100
127
  const issue = await client.getIssue(issueId);
101
-
102
128
  if (!issue) {
103
- logger.warn(`Issue ${issueId} not found in Linear, skipping`);
129
+ logger.warn(`Issue ${issueId} not found in Linear`);
104
130
  return { issueId, status: "not_found" };
105
131
  }
106
132
 
@@ -114,12 +140,10 @@ export async function success(
114
140
 
115
141
  // Add comment if configured
116
142
  if (addComment) {
117
- const comment = formatComment(
118
- version,
119
- channel,
120
- nextRelease,
121
- branch.name,
122
- );
143
+ const emoji = channel === "latest" ? "🚀" : "🔬";
144
+ const channelText =
145
+ channel === "latest" ? "" : ` (${channel} channel)`;
146
+ const comment = `${emoji} Released in version ${version}${channelText}`;
123
147
  await client.addComment(issue.id, comment);
124
148
  }
125
149
 
@@ -135,17 +159,13 @@ export async function success(
135
159
 
136
160
  // Log summary
137
161
  const updated = results.filter(
138
- (r) => r.status === "fulfilled" && r.value.status === "updated",
162
+ (r) => r.status === "fulfilled" && r.value?.status === "updated",
139
163
  ).length;
140
-
141
164
  const failed = results.filter(
142
- (r) =>
143
- r.status === "rejected" ||
144
- (r.status === "fulfilled" && r.value.status === "failed"),
165
+ (r) => r.status === "rejected" || r.value?.status === "failed",
145
166
  ).length;
146
-
147
167
  const notFound = results.filter(
148
- (r) => r.status === "fulfilled" && r.value.status === "not_found",
168
+ (r) => r.status === "fulfilled" && r.value?.status === "not_found",
149
169
  ).length;
150
170
 
151
171
  logger.log(
@@ -169,26 +189,3 @@ function getLabelColor(releaseType: ReleaseType): string {
169
189
 
170
190
  return colors[releaseType] || "#4752C4"; // Default blue
171
191
  }
172
-
173
- /**
174
- * Format comment for Linear issue
175
- */
176
- function formatComment(
177
- version: string,
178
- channel: string,
179
- release: SuccessContext["nextRelease"],
180
- branchName: string,
181
- ): string {
182
- const emoji = channel === "latest" ? "🚀" : "🔬";
183
- const channelText = channel === "latest" ? "stable" : channel;
184
-
185
- let comment = `${emoji} **Released in \`v${version}\`** (${channelText})\n\n`;
186
- comment += `📌 Released from branch: \`${branchName}\`\n\n`;
187
-
188
- const githubRepo = process.env.GITHUB_REPOSITORY;
189
- if (release.gitTag && githubRepo) {
190
- comment += `[View release →](https://github.com/${githubRepo}/releases/tag/${release.gitTag})`;
191
- }
192
-
193
- return comment;
194
- }
@@ -10,6 +10,12 @@ jest.mock("@semantic-release/error", () => {
10
10
  return { __esModule: true, default: SemanticReleaseError };
11
11
  });
12
12
 
13
+ jest.mock("./linear-client", () => ({
14
+ LinearClient: jest.fn().mockImplementation(() => ({
15
+ testConnection: jest.fn().mockResolvedValue({}),
16
+ })),
17
+ }));
18
+
13
19
  jest.mock("node-fetch", () => ({ __esModule: true, default: jest.fn() }));
14
20
 
15
21
  import { verifyConditions } from "./verify";
@@ -36,4 +42,17 @@ describe("verify", () => {
36
42
  verifyConditions({ apiKey: "test", teamKeys: ["eng-123"] }, mockContext),
37
43
  ).rejects.toThrow("Invalid team key format");
38
44
  });
45
+
46
+ test("accepts valid branch like team key", async () => {
47
+ // Accepts branch patterns without hitting API
48
+ await expect(
49
+ verifyConditions(
50
+ {
51
+ apiKey: "test",
52
+ teamKeys: ["caio/tk-519-title"],
53
+ },
54
+ mockContext,
55
+ ),
56
+ ).resolves.toBeUndefined();
57
+ });
39
58
  });
package/src/lib/verify.ts CHANGED
@@ -25,11 +25,18 @@ export async function verifyConditions(
25
25
  }
26
26
 
27
27
  // Validate team keys format if provided
28
- if (teamKeys.length > 0 && !teamKeys.every((key) => /^[A-Z]+$/.test(key))) {
28
+ const teamKeyPattern = /^[A-Z]+$/;
29
+ const branchPattern = /^[A-Za-z0-9._-]+\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
30
+ const invalidTeamKeys = teamKeys.filter(
31
+ (key) => !teamKeyPattern.test(key) && !branchPattern.test(key),
32
+ );
33
+
34
+ if (invalidTeamKeys.length > 0) {
29
35
  throw new SemanticReleaseError(
30
36
  "Invalid team key format",
31
37
  "EINVALIDTEAMKEY",
32
- `Team keys must be uppercase letters only. Got: ${teamKeys.join(", ")}`,
38
+ "Team keys must be uppercase letters (e.g. SD) or branch names (e.g. caio/tk-519-title). " +
39
+ `Invalid: ${invalidTeamKeys.join(", ")}`,
33
40
  );
34
41
  }
35
42