@enrichlayer/el-linear 1.2.0
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/LICENSE +21 -0
- package/README.md +219 -0
- package/claude-skills/linear-operations/SKILL.md +315 -0
- package/claude-skills/linear-operations/evals/evals.json +46 -0
- package/dist/commands/attachments.d.ts +2 -0
- package/dist/commands/attachments.js +57 -0
- package/dist/commands/batch.d.ts +2 -0
- package/dist/commands/batch.js +309 -0
- package/dist/commands/comments.d.ts +2 -0
- package/dist/commands/comments.js +272 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +15 -0
- package/dist/commands/cycles.d.ts +2 -0
- package/dist/commands/cycles.js +63 -0
- package/dist/commands/documents.d.ts +2 -0
- package/dist/commands/documents.js +175 -0
- package/dist/commands/embeds.d.ts +2 -0
- package/dist/commands/embeds.js +65 -0
- package/dist/commands/gdoc.d.ts +2 -0
- package/dist/commands/gdoc.js +37 -0
- package/dist/commands/graphql.d.ts +2 -0
- package/dist/commands/graphql.js +70 -0
- package/dist/commands/init/aliases.d.ts +109 -0
- package/dist/commands/init/aliases.js +569 -0
- package/dist/commands/init/defaults.d.ts +25 -0
- package/dist/commands/init/defaults.js +112 -0
- package/dist/commands/init/index.d.ts +18 -0
- package/dist/commands/init/index.js +182 -0
- package/dist/commands/init/shared.d.ts +88 -0
- package/dist/commands/init/shared.js +164 -0
- package/dist/commands/init/token.d.ts +50 -0
- package/dist/commands/init/token.js +141 -0
- package/dist/commands/init/workspace.d.ts +20 -0
- package/dist/commands/init/workspace.js +80 -0
- package/dist/commands/issue-id.d.ts +28 -0
- package/dist/commands/issue-id.js +81 -0
- package/dist/commands/issues.d.ts +2 -0
- package/dist/commands/issues.js +1145 -0
- package/dist/commands/labels.d.ts +2 -0
- package/dist/commands/labels.js +100 -0
- package/dist/commands/project-milestones.d.ts +2 -0
- package/dist/commands/project-milestones.js +143 -0
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +336 -0
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +69 -0
- package/dist/commands/releases.d.ts +2 -0
- package/dist/commands/releases.js +142 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +171 -0
- package/dist/commands/teams.d.ts +2 -0
- package/dist/commands/teams.js +19 -0
- package/dist/commands/templates.d.ts +2 -0
- package/dist/commands/templates.js +58 -0
- package/dist/commands/users.d.ts +2 -0
- package/dist/commands/users.js +17 -0
- package/dist/config/config.d.ts +43 -0
- package/dist/config/config.js +81 -0
- package/dist/config/issue-validation.d.ts +39 -0
- package/dist/config/issue-validation.js +264 -0
- package/dist/config/paths.d.ts +20 -0
- package/dist/config/paths.js +22 -0
- package/dist/config/resolver.d.ts +25 -0
- package/dist/config/resolver.js +183 -0
- package/dist/config/status-defaults.d.ts +13 -0
- package/dist/config/status-defaults.js +20 -0
- package/dist/config/term-enforcer.d.ts +31 -0
- package/dist/config/term-enforcer.js +69 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +76 -0
- package/dist/queries/attachments.d.ts +3 -0
- package/dist/queries/attachments.js +35 -0
- package/dist/queries/comments.d.ts +3 -0
- package/dist/queries/comments.js +64 -0
- package/dist/queries/common.d.ts +2 -0
- package/dist/queries/common.js +109 -0
- package/dist/queries/cycles.d.ts +2 -0
- package/dist/queries/cycles.js +40 -0
- package/dist/queries/documents.d.ts +5 -0
- package/dist/queries/documents.js +67 -0
- package/dist/queries/introspect.d.ts +2 -0
- package/dist/queries/introspect.js +24 -0
- package/dist/queries/issues.d.ts +23 -0
- package/dist/queries/issues.js +380 -0
- package/dist/queries/labels.d.ts +4 -0
- package/dist/queries/labels.js +53 -0
- package/dist/queries/project-milestones.d.ts +6 -0
- package/dist/queries/project-milestones.js +125 -0
- package/dist/queries/projects.d.ts +7 -0
- package/dist/queries/projects.js +104 -0
- package/dist/queries/releases.d.ts +4 -0
- package/dist/queries/releases.js +85 -0
- package/dist/queries/search.d.ts +1 -0
- package/dist/queries/search.js +35 -0
- package/dist/queries/templates.d.ts +2 -0
- package/dist/queries/templates.js +30 -0
- package/dist/types/linear.d.ts +217 -0
- package/dist/types/linear.js +6 -0
- package/dist/utils/auth.d.ts +4 -0
- package/dist/utils/auth.js +23 -0
- package/dist/utils/auto-link-references.d.ts +47 -0
- package/dist/utils/auto-link-references.js +188 -0
- package/dist/utils/date-format.d.ts +4 -0
- package/dist/utils/date-format.js +8 -0
- package/dist/utils/download-uploads.d.ts +7 -0
- package/dist/utils/download-uploads.js +88 -0
- package/dist/utils/embed-parser.d.ts +8 -0
- package/dist/utils/embed-parser.js +54 -0
- package/dist/utils/error-messages.d.ts +4 -0
- package/dist/utils/error-messages.js +17 -0
- package/dist/utils/file-service.d.ts +13 -0
- package/dist/utils/file-service.js +239 -0
- package/dist/utils/gdoc-parser.d.ts +45 -0
- package/dist/utils/gdoc-parser.js +107 -0
- package/dist/utils/graphql-attachments-service.d.ts +12 -0
- package/dist/utils/graphql-attachments-service.js +46 -0
- package/dist/utils/graphql-documents-service.d.ts +18 -0
- package/dist/utils/graphql-documents-service.js +97 -0
- package/dist/utils/graphql-issues-service.d.ts +49 -0
- package/dist/utils/graphql-issues-service.js +925 -0
- package/dist/utils/graphql-service.d.ts +8 -0
- package/dist/utils/graphql-service.js +35 -0
- package/dist/utils/identifier-parser.d.ts +7 -0
- package/dist/utils/identifier-parser.js +20 -0
- package/dist/utils/issue-reference-extractor.d.ts +18 -0
- package/dist/utils/issue-reference-extractor.js +95 -0
- package/dist/utils/issue-reference-wrapper.d.ts +12 -0
- package/dist/utils/issue-reference-wrapper.js +91 -0
- package/dist/utils/linear-service.d.ts +26 -0
- package/dist/utils/linear-service.js +442 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.js +8 -0
- package/dist/utils/markdown-prosemirror.d.ts +24 -0
- package/dist/utils/markdown-prosemirror.js +325 -0
- package/dist/utils/mention-resolver.d.ts +31 -0
- package/dist/utils/mention-resolver.js +234 -0
- package/dist/utils/output.d.ts +7 -0
- package/dist/utils/output.js +125 -0
- package/dist/utils/table-formatter.d.ts +4 -0
- package/dist/utils/table-formatter.js +249 -0
- package/dist/utils/usage.d.ts +2 -0
- package/dist/utils/usage.js +24 -0
- package/dist/utils/uuid.d.ts +2 -0
- package/dist/utils/uuid.js +8 -0
- package/dist/utils/validate-references.d.ts +10 -0
- package/dist/utils/validate-references.js +33 -0
- package/dist/utils/validators.d.ts +7 -0
- package/dist/utils/validators.js +71 -0
- package/dist/utils/workspace-url.d.ts +4 -0
- package/dist/utils/workspace-url.js +44 -0
- package/package.json +71 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 1 of the wizard: Linear API token.
|
|
3
|
+
*
|
|
4
|
+
* The only required step. Validates the token by calling `viewer { ... }`
|
|
5
|
+
* before saving. Token is stored at ~/.config/el-linear/token (mode 0600),
|
|
6
|
+
* never embedded in config.json.
|
|
7
|
+
*/
|
|
8
|
+
import { confirm, password } from "@inquirer/prompts";
|
|
9
|
+
import { GraphQLService } from "../../utils/graphql-service.js";
|
|
10
|
+
import { readToken, writeToken } from "./shared.js";
|
|
11
|
+
const TOKEN_GENERATION_URL = "https://linear.app/settings/account/security";
|
|
12
|
+
const VIEWER_QUERY = /* GraphQL */ `
|
|
13
|
+
query {
|
|
14
|
+
viewer {
|
|
15
|
+
id
|
|
16
|
+
name
|
|
17
|
+
email
|
|
18
|
+
displayName
|
|
19
|
+
organization {
|
|
20
|
+
urlKey
|
|
21
|
+
name
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
/**
|
|
27
|
+
* Strip anything that looks like a Linear API token from a string. Defense in
|
|
28
|
+
* depth: today the @linear/sdk error message embeds {query, variables} but not
|
|
29
|
+
* the Authorization header. A future SDK upgrade that includes headers (which
|
|
30
|
+
* upstream graphql-request has done historically) would otherwise silently
|
|
31
|
+
* write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
|
|
32
|
+
* also catches token shapes that may show up in custom error wrappers.
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeForLog(text) {
|
|
35
|
+
return text.replace(/lin_api_[A-Za-z0-9_-]{16,}/g, "lin_api_***REDACTED***");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Strict shape check on the viewer response. Treats whitespace-only fields as
|
|
39
|
+
* "validated to nothing" — easy to forge with a malformed but truthy stub
|
|
40
|
+
* response, so we require a UUID-shaped id and a basic urlKey.
|
|
41
|
+
*/
|
|
42
|
+
function viewerIsValid(viewer) {
|
|
43
|
+
if (!viewer || typeof viewer !== "object")
|
|
44
|
+
return false;
|
|
45
|
+
const v = viewer;
|
|
46
|
+
const id = typeof v.id === "string" ? v.id.trim() : "";
|
|
47
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))
|
|
48
|
+
return false;
|
|
49
|
+
const org = v.organization;
|
|
50
|
+
if (!org || typeof org !== "object")
|
|
51
|
+
return false;
|
|
52
|
+
const urlKey = typeof org.urlKey === "string" ? org.urlKey.trim() : "";
|
|
53
|
+
if (!/^[a-z0-9-]+$/i.test(urlKey))
|
|
54
|
+
return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Validate a Linear API token by fetching the viewer. Throws with a
|
|
59
|
+
* sanitized user-readable message on auth failure — the error string is
|
|
60
|
+
* always run through sanitizeForLog so a leaked token in an upstream error
|
|
61
|
+
* is redacted before it hits stdout.
|
|
62
|
+
*/
|
|
63
|
+
export async function validateToken(token) {
|
|
64
|
+
const service = new GraphQLService(token);
|
|
65
|
+
let data;
|
|
66
|
+
try {
|
|
67
|
+
data = await service.rawRequest(VIEWER_QUERY);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
71
|
+
const message = sanitizeForLog(raw);
|
|
72
|
+
if (/AuthenticationFailed|Unauthorized|invalid|expired/i.test(message)) {
|
|
73
|
+
throw new Error(`Token rejected by Linear: ${message}`);
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Could not validate token: ${message}`);
|
|
76
|
+
}
|
|
77
|
+
if (!viewerIsValid(data?.viewer)) {
|
|
78
|
+
throw new Error("Token validated but the response is missing a viewer with a valid id and organization. " +
|
|
79
|
+
"Try a different token.");
|
|
80
|
+
}
|
|
81
|
+
return data.viewer;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Run the interactive token step. Returns the validated token + viewer info.
|
|
85
|
+
*
|
|
86
|
+
* On re-run with an existing valid token, the prompt defaults to "keep" — so
|
|
87
|
+
* pressing enter is a no-op.
|
|
88
|
+
*/
|
|
89
|
+
export async function runTokenStep(options = {}) {
|
|
90
|
+
const existing = await readToken();
|
|
91
|
+
if (existing && !options.force) {
|
|
92
|
+
try {
|
|
93
|
+
const viewer = await validateToken(existing);
|
|
94
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
95
|
+
console.log(` Existing token works — authenticated as ${viewer.displayName} <${viewer.email}>.`);
|
|
96
|
+
const replace = await confirm({
|
|
97
|
+
message: "Replace this token?",
|
|
98
|
+
default: false,
|
|
99
|
+
});
|
|
100
|
+
if (!replace) {
|
|
101
|
+
return { token: existing, viewer };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
106
|
+
const message = sanitizeForLog(raw);
|
|
107
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
108
|
+
console.log(` Existing token failed validation (${message}). You'll need to provide a new one.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
112
|
+
console.log(` Generate a personal API token: ${TOKEN_GENERATION_URL}`);
|
|
113
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
114
|
+
console.log(" The token stays on your machine. el-linear only sends it to Linear's API. " +
|
|
115
|
+
"It's stored at ~/.config/el-linear/token (mode 0600).");
|
|
116
|
+
// Up to three attempts before giving up.
|
|
117
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
118
|
+
const token = (await password({
|
|
119
|
+
message: attempt === 0
|
|
120
|
+
? "Linear API token (input hidden):"
|
|
121
|
+
: "Try again (input hidden):",
|
|
122
|
+
mask: "*",
|
|
123
|
+
validate: (input) => input.trim().length > 0 || "Token cannot be empty",
|
|
124
|
+
})).trim();
|
|
125
|
+
try {
|
|
126
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
127
|
+
console.log(" Validating…");
|
|
128
|
+
const viewer = await validateToken(token);
|
|
129
|
+
await writeToken(token);
|
|
130
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
131
|
+
console.log(` ✓ Authenticated as ${viewer.displayName} <${viewer.email}> (${viewer.organization.name}).`);
|
|
132
|
+
return { token, viewer };
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
136
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
137
|
+
console.log(` ✗ ${sanitizeForLog(raw)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw new Error("Could not validate a Linear API token after 3 attempts. Aborting.");
|
|
141
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 2 of the wizard: workspace defaults.
|
|
3
|
+
*
|
|
4
|
+
* Auto-fetches the Linear workspace urlKey (no prompt) since it's deterministic.
|
|
5
|
+
* Lets the user pick a default team, optional. Skip-by-default.
|
|
6
|
+
*/
|
|
7
|
+
import type { WizardConfig } from "./shared.js";
|
|
8
|
+
export interface WorkspaceStepResult {
|
|
9
|
+
workspaceUrlKey: string;
|
|
10
|
+
defaultTeam: string | undefined;
|
|
11
|
+
teams: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Run the workspace step. Reads existing config to default the team picker
|
|
15
|
+
* to the current default. Returns values to merge into the on-disk config.
|
|
16
|
+
*
|
|
17
|
+
* No-op safe: if the user keeps everything as-is, returns values that match
|
|
18
|
+
* the existing config exactly.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runWorkspaceStep(token: string, workspaceUrlKey: string, existing: WizardConfig): Promise<WorkspaceStepResult>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 2 of the wizard: workspace defaults.
|
|
3
|
+
*
|
|
4
|
+
* Auto-fetches the Linear workspace urlKey (no prompt) since it's deterministic.
|
|
5
|
+
* Lets the user pick a default team, optional. Skip-by-default.
|
|
6
|
+
*/
|
|
7
|
+
import { confirm, select } from "@inquirer/prompts";
|
|
8
|
+
import { GraphQLService } from "../../utils/graphql-service.js";
|
|
9
|
+
const TEAMS_QUERY = /* GraphQL */ `
|
|
10
|
+
query InitTeams {
|
|
11
|
+
teams(first: 100) {
|
|
12
|
+
nodes {
|
|
13
|
+
id
|
|
14
|
+
key
|
|
15
|
+
name
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
/**
|
|
21
|
+
* Run the workspace step. Reads existing config to default the team picker
|
|
22
|
+
* to the current default. Returns values to merge into the on-disk config.
|
|
23
|
+
*
|
|
24
|
+
* No-op safe: if the user keeps everything as-is, returns values that match
|
|
25
|
+
* the existing config exactly.
|
|
26
|
+
*/
|
|
27
|
+
export async function runWorkspaceStep(token, workspaceUrlKey, existing) {
|
|
28
|
+
const service = new GraphQLService(token);
|
|
29
|
+
// Fetch teams once so we can populate both the picker and the cached id map.
|
|
30
|
+
const data = await service.rawRequest(TEAMS_QUERY);
|
|
31
|
+
const teams = data?.teams?.nodes ?? [];
|
|
32
|
+
const teamMap = {};
|
|
33
|
+
for (const t of teams) {
|
|
34
|
+
teamMap[t.key] = t.id;
|
|
35
|
+
}
|
|
36
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
37
|
+
console.log(` Workspace: ${workspaceUrlKey} (${teams.length} teams)`);
|
|
38
|
+
const currentDefault = existing.defaultTeam || "";
|
|
39
|
+
// `change` reflects what the user agreed to: `true` means "open the picker
|
|
40
|
+
// and change the default team", `false` means "leave the existing config
|
|
41
|
+
// alone". Default false makes pressing enter a no-op (keep-as-is).
|
|
42
|
+
const change = await confirm({
|
|
43
|
+
message: currentDefault
|
|
44
|
+
? `Current default team: ${currentDefault}. Change it?`
|
|
45
|
+
: "Set a default team for new issues?",
|
|
46
|
+
default: false,
|
|
47
|
+
});
|
|
48
|
+
if (!change) {
|
|
49
|
+
return {
|
|
50
|
+
workspaceUrlKey,
|
|
51
|
+
defaultTeam: currentDefault || undefined,
|
|
52
|
+
teams: teamMap,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (teams.length === 0) {
|
|
56
|
+
// biome-ignore lint/suspicious/noConsole: wizard
|
|
57
|
+
console.log(" No teams visible to this token. Skipping default-team picker.");
|
|
58
|
+
return {
|
|
59
|
+
workspaceUrlKey,
|
|
60
|
+
defaultTeam: currentDefault || undefined,
|
|
61
|
+
teams: teamMap,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const choice = await select({
|
|
65
|
+
message: "Default team:",
|
|
66
|
+
choices: [
|
|
67
|
+
...teams.map((t) => ({
|
|
68
|
+
name: `${t.key}${t.name && t.name !== t.key ? ` — ${t.name}` : ""}`,
|
|
69
|
+
value: t.key,
|
|
70
|
+
})),
|
|
71
|
+
{ name: "(no default — pick per-issue)", value: "__skip" },
|
|
72
|
+
],
|
|
73
|
+
default: currentDefault || teams[0]?.key,
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
workspaceUrlKey,
|
|
77
|
+
defaultTeam: choice === "__skip" ? undefined : choice,
|
|
78
|
+
teams: teamMap,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `el-linear issue-id` — extract the Linear issue ID from the current
|
|
3
|
+
* git branch (or any branch passed as an argument).
|
|
4
|
+
*
|
|
5
|
+
* Replaces the regex that was duplicated across 4 skills: commit-guard,
|
|
6
|
+
* glab-commit-push-mr, stray-file-triage, git-branch-from-linear. Each
|
|
7
|
+
* had its own copy of `feature/(DEV|ALL|...)-\d+-...` parsing.
|
|
8
|
+
*
|
|
9
|
+
* Output (when issueId found):
|
|
10
|
+
* { branch, issueId: "DEV-3900", team: "DEV", number: 3900, slug: "..." }
|
|
11
|
+
* Output (when no match):
|
|
12
|
+
* { branch, issueId: null, team: null, number: null, slug: null }
|
|
13
|
+
*
|
|
14
|
+
* `--fetch` (optional): also fetches the issue from Linear and includes
|
|
15
|
+
* { title, description, state, assignee }. Skip when you only need the
|
|
16
|
+
* parsed ID — saves an API round-trip.
|
|
17
|
+
*/
|
|
18
|
+
import type { Command } from "commander";
|
|
19
|
+
interface ParsedBranch {
|
|
20
|
+
branch: string;
|
|
21
|
+
issueId: string | null;
|
|
22
|
+
number: number | null;
|
|
23
|
+
slug: string | null;
|
|
24
|
+
team: string | null;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseBranchName(branch: string): ParsedBranch;
|
|
27
|
+
export declare function setupIssueIdCommand(program: Command): void;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `el-linear issue-id` — extract the Linear issue ID from the current
|
|
3
|
+
* git branch (or any branch passed as an argument).
|
|
4
|
+
*
|
|
5
|
+
* Replaces the regex that was duplicated across 4 skills: commit-guard,
|
|
6
|
+
* glab-commit-push-mr, stray-file-triage, git-branch-from-linear. Each
|
|
7
|
+
* had its own copy of `feature/(DEV|ALL|...)-\d+-...` parsing.
|
|
8
|
+
*
|
|
9
|
+
* Output (when issueId found):
|
|
10
|
+
* { branch, issueId: "DEV-3900", team: "DEV", number: 3900, slug: "..." }
|
|
11
|
+
* Output (when no match):
|
|
12
|
+
* { branch, issueId: null, team: null, number: null, slug: null }
|
|
13
|
+
*
|
|
14
|
+
* `--fetch` (optional): also fetches the issue from Linear and includes
|
|
15
|
+
* { title, description, state, assignee }. Skip when you only need the
|
|
16
|
+
* parsed ID — saves an API round-trip.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
20
|
+
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
21
|
+
// Branch naming conventions — kept in sync with branch-name validators
|
|
22
|
+
// elsewhere in the workspace. Any addition here is the single point of
|
|
23
|
+
// truth that all skills rely on.
|
|
24
|
+
const BRANCH_RE = /^(?:feature|fix|chore|refactor|dev)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
|
|
25
|
+
export function parseBranchName(branch) {
|
|
26
|
+
const m = branch.match(BRANCH_RE);
|
|
27
|
+
if (!m) {
|
|
28
|
+
return { branch, issueId: null, team: null, number: null, slug: null };
|
|
29
|
+
}
|
|
30
|
+
const team = m[1].toUpperCase();
|
|
31
|
+
const number = Number.parseInt(m[2], 10);
|
|
32
|
+
return {
|
|
33
|
+
branch,
|
|
34
|
+
issueId: `${team}-${number}`,
|
|
35
|
+
team,
|
|
36
|
+
number,
|
|
37
|
+
slug: m[3] ?? null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function getCurrentBranch() {
|
|
41
|
+
const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
});
|
|
44
|
+
if (result.status !== 0) {
|
|
45
|
+
throw new Error(`git rev-parse failed: ${result.stderr.trim() || "non-zero exit"}`);
|
|
46
|
+
}
|
|
47
|
+
return result.stdout.trim();
|
|
48
|
+
}
|
|
49
|
+
const ISSUE_QUERY = /* GraphQL */ `
|
|
50
|
+
query IssueByIdentifier($id: String!) {
|
|
51
|
+
issue(id: $id) {
|
|
52
|
+
id
|
|
53
|
+
identifier
|
|
54
|
+
title
|
|
55
|
+
description
|
|
56
|
+
branchName
|
|
57
|
+
state { name type }
|
|
58
|
+
assignee { id name email }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
export function setupIssueIdCommand(program) {
|
|
63
|
+
program
|
|
64
|
+
.command("issue-id [branch]")
|
|
65
|
+
.description("Extract the Linear issue ID from a git branch name (defaults to current branch). Use --fetch to also pull the issue from Linear.")
|
|
66
|
+
.option("--fetch", "also fetch issue title/description/state from Linear")
|
|
67
|
+
.action(handleAsyncCommand(async (branchArg, options, command) => {
|
|
68
|
+
const branch = branchArg ?? getCurrentBranch();
|
|
69
|
+
const parsed = parseBranchName(branch);
|
|
70
|
+
if (!(options.fetch && parsed.issueId)) {
|
|
71
|
+
outputSuccess(parsed);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const rootOpts = command.parent?.opts() ?? {};
|
|
75
|
+
const service = createGraphQLService({ apiToken: rootOpts.apiToken });
|
|
76
|
+
const result = (await service.rawRequest(ISSUE_QUERY, {
|
|
77
|
+
id: parsed.issueId,
|
|
78
|
+
}));
|
|
79
|
+
outputSuccess({ ...parsed, issue: result.issue ?? null });
|
|
80
|
+
}));
|
|
81
|
+
}
|