@enrichlayer/el-linear 1.7.0 → 1.10.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/README.md +82 -1
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-callback.js +14 -7
- package/dist/auth/oauth-fs.d.ts +22 -0
- package/dist/auth/oauth-fs.js +77 -6
- package/dist/auth/oauth-headless.d.ts +13 -8
- package/dist/auth/oauth-headless.js +18 -11
- package/dist/auth/oauth-storage.d.ts +11 -3
- package/dist/auth/oauth-storage.js +15 -8
- package/dist/auth/token-resolver.d.ts +9 -0
- package/dist/auth/token-resolver.js +57 -37
- package/dist/commands/attachments.js +2 -1
- package/dist/commands/comments.js +17 -28
- package/dist/commands/cycles.js +2 -1
- package/dist/commands/documents.js +2 -1
- package/dist/commands/graphql.js +4 -6
- package/dist/commands/init/index.js +2 -0
- package/dist/commands/init/oauth.d.ts +6 -0
- package/dist/commands/init/oauth.js +8 -2
- package/dist/commands/init/token.d.ts +0 -8
- package/dist/commands/init/token.js +13 -1
- package/dist/commands/issue-id.js +1 -3
- package/dist/commands/issues/branch.d.ts +17 -0
- package/dist/commands/issues/branch.js +40 -0
- package/dist/commands/issues/description.d.ts +89 -0
- package/dist/commands/issues/description.js +183 -0
- package/dist/commands/issues/link-references.d.ts +21 -0
- package/dist/commands/issues/link-references.js +171 -0
- package/dist/commands/issues/relations.d.ts +55 -0
- package/dist/commands/issues/relations.js +132 -0
- package/dist/commands/issues.js +97 -497
- package/dist/commands/labels.js +20 -28
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/project-milestones.js +13 -20
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +202 -102
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +10 -1
- package/dist/commands/releases.js +26 -30
- package/dist/commands/search.js +21 -30
- package/dist/commands/teams.js +2 -1
- package/dist/commands/templates.js +128 -7
- package/dist/commands/users.js +3 -1
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.js +44 -6
- package/dist/config/resolver.js +2 -3
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +37 -2
- package/dist/queries/attachments-types.d.ts +30 -0
- package/dist/queries/attachments-types.js +5 -0
- package/dist/queries/comments-types.d.ts +49 -0
- package/dist/queries/comments-types.js +5 -0
- package/dist/queries/documents-types.d.ts +61 -0
- package/dist/queries/documents-types.js +9 -0
- package/dist/queries/introspect-types.d.ts +57 -0
- package/dist/queries/introspect-types.js +10 -0
- package/dist/queries/issues-types.d.ts +416 -0
- package/dist/queries/issues-types.js +23 -0
- package/dist/queries/issues.d.ts +2 -0
- package/dist/queries/issues.js +22 -0
- package/dist/queries/labels-types.d.ts +64 -0
- package/dist/queries/labels-types.js +5 -0
- package/dist/queries/project-milestones-types.d.ts +91 -0
- package/dist/queries/project-milestones-types.js +10 -0
- package/dist/queries/projects-types.d.ts +75 -0
- package/dist/queries/projects-types.js +5 -0
- package/dist/queries/projects.d.ts +2 -0
- package/dist/queries/projects.js +22 -0
- package/dist/queries/releases-types.d.ts +84 -0
- package/dist/queries/releases-types.js +5 -0
- package/dist/queries/search-types.d.ts +86 -0
- package/dist/queries/search-types.js +6 -0
- package/dist/queries/templates-types.d.ts +61 -0
- package/dist/queries/templates-types.js +9 -0
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/types/linear.d.ts +8 -2
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +44 -25
- package/dist/utils/disk-cache.js +2 -1
- package/dist/utils/formatters/summary.d.ts +62 -0
- package/dist/utils/formatters/summary.js +755 -0
- package/dist/utils/graphql-attachments-service.js +6 -9
- package/dist/utils/graphql-documents-service.js +19 -25
- package/dist/utils/graphql-issues-service.d.ts +118 -5
- package/dist/utils/graphql-issues-service.js +202 -209
- package/dist/utils/issue-reference-extractor.d.ts +9 -1
- package/dist/utils/issue-reference-extractor.js +16 -9
- package/dist/utils/issue-reference-wrapper.js +1 -54
- package/dist/utils/linear-service.d.ts +7 -3
- package/dist/utils/linear-service.js +27 -5
- package/dist/utils/markdown-prosemirror.js +17 -1
- package/dist/utils/mention-resolver.js +17 -5
- package/dist/utils/output.d.ts +7 -1
- package/dist/utils/output.js +38 -1
- package/dist/utils/protected-ranges.d.ts +33 -0
- package/dist/utils/protected-ranges.js +73 -0
- package/dist/utils/table-formatter.d.ts +36 -0
- package/dist/utils/table-formatter.js +46 -24
- package/dist/utils/validators.d.ts +9 -1
- package/dist/utils/validators.js +10 -0
- package/dist/utils/workspace-url.d.ts +5 -1
- package/dist/utils/workspace-url.js +35 -5
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ import { resolveMentions } from "../utils/mention-resolver.js";
|
|
|
11
11
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
12
12
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
13
13
|
import { validateReferences } from "../utils/validate-references.js";
|
|
14
|
+
import { parsePositiveInt } from "../utils/validators.js";
|
|
14
15
|
import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
|
|
15
16
|
// Match Linear's bodyData validation error in multiple phrasings so a wording
|
|
16
17
|
// tweak on their side doesn't silently regress the fallback path.
|
|
@@ -28,22 +29,20 @@ function readBody(options) {
|
|
|
28
29
|
async function fetchSelfUserId(graphQLService) {
|
|
29
30
|
try {
|
|
30
31
|
const result = await graphQLService.rawRequest("{ viewer { id } }");
|
|
31
|
-
|
|
32
|
-
return viewer?.id;
|
|
32
|
+
return result.viewer?.id;
|
|
33
33
|
}
|
|
34
34
|
catch {
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
function transformComment(comment) {
|
|
39
|
-
const user = comment.user;
|
|
40
39
|
return {
|
|
41
40
|
id: comment.id,
|
|
42
41
|
body: comment.body,
|
|
43
42
|
user: {
|
|
44
|
-
id: user.id,
|
|
45
|
-
name: resolveUserDisplayName(user.id, user.name),
|
|
46
|
-
url: user.url
|
|
43
|
+
id: comment.user.id,
|
|
44
|
+
name: resolveUserDisplayName(comment.user.id, comment.user.name),
|
|
45
|
+
url: comment.user.url ?? undefined,
|
|
47
46
|
},
|
|
48
47
|
createdAt: comment.createdAt,
|
|
49
48
|
updatedAt: comment.updatedAt,
|
|
@@ -133,9 +132,7 @@ async function handleCreateComment(issueId, options, command) {
|
|
|
133
132
|
}
|
|
134
133
|
let result;
|
|
135
134
|
try {
|
|
136
|
-
result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
|
|
137
|
-
input,
|
|
138
|
-
});
|
|
135
|
+
result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, { input });
|
|
139
136
|
}
|
|
140
137
|
catch (err) {
|
|
141
138
|
// If the bodyData ProseMirror document is rejected (invalid shape,
|
|
@@ -147,16 +144,14 @@ async function handleCreateComment(issueId, options, command) {
|
|
|
147
144
|
issueId: resolvedIssueId,
|
|
148
145
|
body,
|
|
149
146
|
};
|
|
150
|
-
result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
|
|
151
|
-
input: fallbackInput,
|
|
152
|
-
});
|
|
147
|
+
result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, { input: fallbackInput });
|
|
153
148
|
}
|
|
154
149
|
else {
|
|
155
150
|
throw err;
|
|
156
151
|
}
|
|
157
152
|
}
|
|
158
153
|
const mutation = result.commentCreate;
|
|
159
|
-
if (!mutation.success) {
|
|
154
|
+
if (!mutation.success || !mutation.comment) {
|
|
160
155
|
throw new Error("Failed to create comment");
|
|
161
156
|
}
|
|
162
157
|
// We don't have the parent issue's identifier from the create mutation response,
|
|
@@ -201,22 +196,18 @@ async function handleUpdateComment(commentId, options, command) {
|
|
|
201
196
|
else {
|
|
202
197
|
input.body = body;
|
|
203
198
|
}
|
|
204
|
-
const result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, {
|
|
205
|
-
id: commentId,
|
|
206
|
-
input,
|
|
207
|
-
});
|
|
199
|
+
const result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, { id: commentId, input });
|
|
208
200
|
const mutation = result.commentUpdate;
|
|
209
|
-
if (!mutation.success) {
|
|
201
|
+
if (!mutation.success || !mutation.comment) {
|
|
210
202
|
throw new Error("Failed to update comment");
|
|
211
203
|
}
|
|
212
204
|
const comment = mutation.comment;
|
|
213
|
-
const issue = comment.issue;
|
|
214
205
|
let autoLinked;
|
|
215
|
-
if (issue) {
|
|
206
|
+
if (comment.issue) {
|
|
216
207
|
// rawBody (pre-wrap) — see note in handleCreateComment about keyword inference
|
|
217
208
|
autoLinked = await autoLinkCommentReferences({
|
|
218
|
-
parentIssueUuid: issue.id,
|
|
219
|
-
parentIssueIdentifier: issue.identifier
|
|
209
|
+
parentIssueUuid: comment.issue.id,
|
|
210
|
+
parentIssueIdentifier: comment.issue.identifier,
|
|
220
211
|
body: rawBody,
|
|
221
212
|
preResolved,
|
|
222
213
|
options,
|
|
@@ -234,19 +225,17 @@ async function handleListComments(issueId, options, command) {
|
|
|
234
225
|
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
235
226
|
const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
|
|
236
227
|
issueId: resolvedId,
|
|
237
|
-
first:
|
|
228
|
+
first: parsePositiveInt(options.limit, "--limit"),
|
|
238
229
|
});
|
|
239
|
-
|
|
240
|
-
if (!issue) {
|
|
230
|
+
if (!result.issue) {
|
|
241
231
|
throw new Error(`Issue "${issueId}" not found`);
|
|
242
232
|
}
|
|
243
|
-
const
|
|
244
|
-
const nodes = commentsData.nodes.map(transformComment);
|
|
233
|
+
const nodes = result.issue.comments.nodes.map(transformComment);
|
|
245
234
|
outputSuccess({
|
|
246
235
|
data: nodes,
|
|
247
236
|
meta: {
|
|
248
237
|
count: nodes.length,
|
|
249
|
-
issue: issue.identifier,
|
|
238
|
+
issue: result.issue.identifier,
|
|
250
239
|
},
|
|
251
240
|
});
|
|
252
241
|
}
|
package/dist/commands/cycles.js
CHANGED
|
@@ -3,6 +3,7 @@ import { invalidParameterError, notFoundError, requiresParameterError, } from ".
|
|
|
3
3
|
import { createLinearService } from "../utils/linear-service.js";
|
|
4
4
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
5
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
|
+
import { parsePositiveInt } from "../utils/validators.js";
|
|
6
7
|
export function setupCyclesCommands(program) {
|
|
7
8
|
const cycles = program
|
|
8
9
|
.command("cycles")
|
|
@@ -23,7 +24,7 @@ export function setupCyclesCommands(program) {
|
|
|
23
24
|
const rootOpts = getRootOpts(command);
|
|
24
25
|
const teamFilter = options.team ? resolveTeam(options.team) : undefined;
|
|
25
26
|
const linearService = await createLinearService(rootOpts);
|
|
26
|
-
const allCycles = await linearService.getCycles(teamFilter, options.active || undefined,
|
|
27
|
+
const allCycles = await linearService.getCycles(teamFilter, options.active || undefined, parsePositiveInt(options.limit, "--limit"));
|
|
27
28
|
if (options.aroundActive) {
|
|
28
29
|
const n = Number.parseInt(options.aroundActive, 10);
|
|
29
30
|
if (Number.isNaN(n) || n < 0) {
|
|
@@ -3,6 +3,7 @@ import { createGraphQLDocumentsService } from "../utils/graphql-documents-servic
|
|
|
3
3
|
import { createLinearService } from "../utils/linear-service.js";
|
|
4
4
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
5
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
|
+
import { parsePositiveInt } from "../utils/validators.js";
|
|
6
7
|
function extractDocumentIdFromUrl(url) {
|
|
7
8
|
try {
|
|
8
9
|
const parsed = new URL(url);
|
|
@@ -70,7 +71,7 @@ async function handleListDocuments(options, command) {
|
|
|
70
71
|
const rootOpts = getRootOpts(command);
|
|
71
72
|
const documentsService = await createGraphQLDocumentsService(rootOpts);
|
|
72
73
|
const linearService = await createLinearService(rootOpts);
|
|
73
|
-
const limit =
|
|
74
|
+
const limit = parsePositiveInt(options.limit || "50", "--limit");
|
|
74
75
|
if (Number.isNaN(limit) || limit < 1) {
|
|
75
76
|
throw new Error(`Invalid limit "${options.limit}": must be a positive number`);
|
|
76
77
|
}
|
package/dist/commands/graphql.js
CHANGED
|
@@ -46,16 +46,14 @@ export function setupGraphQLCommands(program) {
|
|
|
46
46
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
47
47
|
if (typeName) {
|
|
48
48
|
const result = await graphQLService.rawRequest(INTROSPECT_TYPE_QUERY, { typeName });
|
|
49
|
-
|
|
50
|
-
if (!typeInfo) {
|
|
49
|
+
if (!result.__type) {
|
|
51
50
|
throw new Error(`Type "${typeName}" not found in schema`);
|
|
52
51
|
}
|
|
53
|
-
outputSuccess(
|
|
52
|
+
outputSuccess(result.__type);
|
|
54
53
|
}
|
|
55
54
|
else {
|
|
56
55
|
const result = await graphQLService.rawRequest(INTROSPECT_ROOT_QUERY);
|
|
57
|
-
|
|
58
|
-
let fields = queryType?.fields ?? [];
|
|
56
|
+
let fields = result.__type?.fields ?? [];
|
|
59
57
|
if (options.filter) {
|
|
60
58
|
const pattern = options.filter.toLowerCase();
|
|
61
59
|
fields = fields.filter((f) => f.name.toLowerCase().includes(pattern));
|
|
@@ -63,7 +61,7 @@ export function setupGraphQLCommands(program) {
|
|
|
63
61
|
const summary = fields.map((f) => ({
|
|
64
62
|
name: f.name,
|
|
65
63
|
description: f.description ?? null,
|
|
66
|
-
args:
|
|
64
|
+
args: f.args.map((a) => a.name),
|
|
67
65
|
}));
|
|
68
66
|
outputSuccess({ fields: summary, count: summary.length });
|
|
69
67
|
}
|
|
@@ -63,6 +63,7 @@ export function setupInitCommands(program) {
|
|
|
63
63
|
.option("--revoke", "revoke and remove the stored OAuth tokens")
|
|
64
64
|
.option("--no-browser", "skip the browser-open + localhost listener; paste the code manually")
|
|
65
65
|
.option("--port <port>", "localhost callback port (default 8765)", (value) => Number.parseInt(value, 10))
|
|
66
|
+
.option("--unsafe-bare-code", "allow pasting a bare authorization code in the headless flow (skips the OAuth `state` CSRF check; opt-in only)")
|
|
66
67
|
.action(withCleanExit(async (options) => {
|
|
67
68
|
printStep("oauth", "Linear OAuth (PKCE)");
|
|
68
69
|
if (options.revoke) {
|
|
@@ -75,6 +76,7 @@ export function setupInitCommands(program) {
|
|
|
75
76
|
// commander's `--no-browser` produces `browser: false`.
|
|
76
77
|
noBrowser: options.browser === false,
|
|
77
78
|
port: options.port,
|
|
79
|
+
unsafeBareCode: options.unsafeBareCode === true,
|
|
78
80
|
});
|
|
79
81
|
}));
|
|
80
82
|
init
|
|
@@ -39,6 +39,12 @@ export interface OAuthStepOptions {
|
|
|
39
39
|
noBrowser?: boolean;
|
|
40
40
|
/** Override the localhost port. Default 8765. */
|
|
41
41
|
port?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Allow pasting a bare authorization code (no surrounding URL) in
|
|
44
|
+
* the headless flow. Bypasses the OAuth `state` CSRF check, so
|
|
45
|
+
* opt-in only — see `oauth-headless.ts` for the rationale.
|
|
46
|
+
*/
|
|
47
|
+
unsafeBareCode?: boolean;
|
|
42
48
|
/** Test seam for the OAuth token endpoint. */
|
|
43
49
|
fetchImpl?: FetchLike;
|
|
44
50
|
/**
|
|
@@ -270,11 +270,17 @@ export async function runOAuthStep(options = {}) {
|
|
|
270
270
|
catch (err) {
|
|
271
271
|
const raw = err instanceof Error ? err.message : String(err);
|
|
272
272
|
logLine(TS(`Localhost listener failed (${sanitizeForLog(raw)}). Falling back to manual paste.`));
|
|
273
|
-
callback = await promptForPastedCode({
|
|
273
|
+
callback = await promptForPastedCode({
|
|
274
|
+
expectedState: state,
|
|
275
|
+
unsafeBareCode: options.unsafeBareCode,
|
|
276
|
+
});
|
|
274
277
|
}
|
|
275
278
|
}
|
|
276
279
|
else {
|
|
277
|
-
callback = await promptForPastedCode({
|
|
280
|
+
callback = await promptForPastedCode({
|
|
281
|
+
expectedState: state,
|
|
282
|
+
unsafeBareCode: options.unsafeBareCode,
|
|
283
|
+
});
|
|
278
284
|
}
|
|
279
285
|
logLine(TS("Exchanging authorization code for tokens…"));
|
|
280
286
|
const exchanged = await exchangeCodeForTokens({
|
|
@@ -21,14 +21,6 @@ export interface TokenStepResult {
|
|
|
21
21
|
token: string;
|
|
22
22
|
viewer: ViewerResponse["viewer"];
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
25
|
-
* Strip anything that looks like a Linear API token from a string. Defense in
|
|
26
|
-
* depth: today the @linear/sdk error message embeds {query, variables} but not
|
|
27
|
-
* the Authorization header. A future SDK upgrade that includes headers (which
|
|
28
|
-
* upstream graphql-request has done historically) would otherwise silently
|
|
29
|
-
* write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
|
|
30
|
-
* also catches token shapes that may show up in custom error wrappers.
|
|
31
|
-
*/
|
|
32
24
|
export declare function sanitizeForLog(text: string): string;
|
|
33
25
|
/**
|
|
34
26
|
* Validate a Linear API token by fetching the viewer. Throws with a
|
|
@@ -31,8 +31,20 @@ const VIEWER_QUERY = /* GraphQL */ `
|
|
|
31
31
|
* write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
|
|
32
32
|
* also catches token shapes that may show up in custom error wrappers.
|
|
33
33
|
*/
|
|
34
|
+
// Personal-API tokens (`lin_api_…`) and OAuth access/refresh tokens
|
|
35
|
+
// (`lin_oauth_…`). Pre-fix the regex only matched personal tokens.
|
|
36
|
+
const TOKEN_PREFIX_RE = /lin_(api|oauth)_[A-Za-z0-9_-]{16,}/g;
|
|
37
|
+
// High-entropy bearer payload fallback: catches generic Bearer-style
|
|
38
|
+
// strings adjacent to Authorization / Bearer keywords. Useful for
|
|
39
|
+
// future SDK error wrappers that might leak headers without the
|
|
40
|
+
// `lin_` prefix.
|
|
41
|
+
const BEARER_PAYLOAD_RE = /(\b(?:Authorization|Bearer)\b[:\s]*)([A-Za-z0-9_\-/+=]{40,})/gi;
|
|
34
42
|
export function sanitizeForLog(text) {
|
|
35
|
-
return text
|
|
43
|
+
return text
|
|
44
|
+
.replace(TOKEN_PREFIX_RE, (m) => m.startsWith("lin_oauth_")
|
|
45
|
+
? "lin_oauth_***REDACTED***"
|
|
46
|
+
: "lin_api_***REDACTED***")
|
|
47
|
+
.replace(BEARER_PAYLOAD_RE, "$1***REDACTED***");
|
|
36
48
|
}
|
|
37
49
|
/**
|
|
38
50
|
* Strict shape check on the viewer response. Treats whitespace-only fields as
|
|
@@ -75,9 +75,7 @@ export function setupIssueIdCommand(program) {
|
|
|
75
75
|
const service = await createGraphQLService({
|
|
76
76
|
apiToken: rootOpts.apiToken,
|
|
77
77
|
});
|
|
78
|
-
const result =
|
|
79
|
-
id: parsed.issueId,
|
|
80
|
-
}));
|
|
78
|
+
const result = await service.rawRequest(ISSUE_QUERY, { id: parsed.issueId });
|
|
81
79
|
outputSuccess({ ...parsed, issue: result.issue ?? null });
|
|
82
80
|
}));
|
|
83
81
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git branch helpers used by `issues create --branch` and the
|
|
3
|
+
* `issues retrolink` flow.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `commands/issues.ts` (ALL-938) to keep that file
|
|
6
|
+
* focused on commander wiring + handlers.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
|
|
10
|
+
* "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
|
|
11
|
+
*/
|
|
12
|
+
export declare function toBranchName(linearBranchName: string, prefix?: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
15
|
+
* Throws if the branch already exists.
|
|
16
|
+
*/
|
|
17
|
+
export declare function gitCheckoutBranch(branchName: string): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git branch helpers used by `issues create --branch` and the
|
|
3
|
+
* `issues retrolink` flow.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `commands/issues.ts` (ALL-938) to keep that file
|
|
6
|
+
* focused on commander wiring + handlers.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { outputWarning } from "../../utils/output.js";
|
|
10
|
+
const LINEAR_BRANCH_REGEX = /^([a-zA-Z]+)-(\d+)-(.+)$/;
|
|
11
|
+
/**
|
|
12
|
+
* Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
|
|
13
|
+
* "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
|
|
14
|
+
*/
|
|
15
|
+
export function toBranchName(linearBranchName, prefix = "feature/") {
|
|
16
|
+
// Linear branch names look like "dev-123-some-slug"
|
|
17
|
+
// We need to uppercase the team key: "DEV-123-some-slug"
|
|
18
|
+
const match = linearBranchName.match(LINEAR_BRANCH_REGEX);
|
|
19
|
+
if (!match) {
|
|
20
|
+
return `${prefix}${linearBranchName}`;
|
|
21
|
+
}
|
|
22
|
+
const [, teamKey, number, slug] = match;
|
|
23
|
+
return `${prefix}${teamKey.toUpperCase()}-${number}-${slug}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
27
|
+
* Throws if the branch already exists.
|
|
28
|
+
*/
|
|
29
|
+
export function gitCheckoutBranch(branchName) {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
32
|
+
stdio: "pipe",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
outputWarning("Not inside a git repository — skipping branch checkout.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
execFileSync("git", ["checkout", "-b", branchName], { stdio: "pipe" });
|
|
40
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Description-handling helpers for issues create/update and the
|
|
3
|
+
* `issues link-references --rewrite-description` flow.
|
|
4
|
+
*
|
|
5
|
+
* Three concerns live here:
|
|
6
|
+
*
|
|
7
|
+
* 1. Resolving the description value from `--description`,
|
|
8
|
+
* `--description-file`, or `--template`.
|
|
9
|
+
* 2. The shared wrap-and-resolve pipeline (`wrapAndResolveRefs`)
|
|
10
|
+
* used by the create/update path and the rewrite-description
|
|
11
|
+
* path. Both wrap valid identifiers as markdown links and
|
|
12
|
+
* return the resolved id→uuid map for downstream auto-link.
|
|
13
|
+
* 3. The post-create/update `maybeAutoLink` hook that creates
|
|
14
|
+
* sidebar relations for refs in the description.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `commands/issues.ts` (ALL-938) so that file can
|
|
17
|
+
* focus on commander wiring + handlers.
|
|
18
|
+
*/
|
|
19
|
+
import type { OptionValues } from "commander";
|
|
20
|
+
import { type AutoLinkResult } from "../../utils/auto-link-references.js";
|
|
21
|
+
import type { GraphQLService } from "../../utils/graphql-service.js";
|
|
22
|
+
import type { LinearService } from "../../utils/linear-service.js";
|
|
23
|
+
/**
|
|
24
|
+
* Read description from a file path or stdin ("-").
|
|
25
|
+
* Avoids shell escaping issues when descriptions contain special characters.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readDescriptionFile(filePath: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the description from --description, --description-file, or
|
|
30
|
+
* --template (looked up in `config.descriptionTemplates`).
|
|
31
|
+
*
|
|
32
|
+
* Precedence: --description-file > --description > --template
|
|
33
|
+
*
|
|
34
|
+
* Passing --template alongside --description / --description-file is
|
|
35
|
+
* a usage error — the explicit body and a template both producing
|
|
36
|
+
* content would silently drop one. We throw so the user picks one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveDescription(options: OptionValues): string | undefined;
|
|
39
|
+
export interface PreparedDescription {
|
|
40
|
+
/** The (possibly rewritten) description text to send to Linear */
|
|
41
|
+
description: string | undefined;
|
|
42
|
+
/** Map<identifier, uuid> of refs that resolved — passed to autoLink to avoid re-resolution */
|
|
43
|
+
preResolved: Map<string, string>;
|
|
44
|
+
/** True when the original description was rewritten (i.e. at least one link was wrapped) */
|
|
45
|
+
rewritten: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Pre-process a description before sending to Linear:
|
|
49
|
+
* 1. Extract all issue references.
|
|
50
|
+
* 2. Validate them (drop ones that don't resolve in the workspace —
|
|
51
|
+
* handles "ISO-1424"-style false positives).
|
|
52
|
+
* 3. Wrap valid identifiers as markdown links — skipping any
|
|
53
|
+
* already inside a link, code block, or backtick span.
|
|
54
|
+
*
|
|
55
|
+
* No-ops (returns the original description with an empty map) when:
|
|
56
|
+
* - description is empty/undefined
|
|
57
|
+
* - the user passed `--no-auto-link`
|
|
58
|
+
*/
|
|
59
|
+
export declare function prepareAutoLinkedDescription(description: string | undefined, options: OptionValues, selfIdentifier: string | undefined, linearService: LinearService, graphQLService: GraphQLService): Promise<PreparedDescription>;
|
|
60
|
+
export interface PreparedRewrite {
|
|
61
|
+
/** Resolved id→uuid map (passed to autoLink to skip duplicate resolution) */
|
|
62
|
+
preResolved: Map<string, string> | undefined;
|
|
63
|
+
/** New description text — undefined when wrapping wouldn't change anything */
|
|
64
|
+
wrapped: string | undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Prepare a description rewrite for `link-references --rewrite-description`.
|
|
68
|
+
* Same wrap-and-resolve core; different return shape so the caller can
|
|
69
|
+
* skip the rewrite mutation when nothing would change.
|
|
70
|
+
*/
|
|
71
|
+
export declare function prepareDescriptionRewrite(description: string, selfIdentifier: string, linearService: LinearService, graphQLService: GraphQLService): Promise<PreparedRewrite>;
|
|
72
|
+
export declare function pushDescriptionUpdate(issueUuid: string, description: string, graphQLService: GraphQLService): Promise<void>;
|
|
73
|
+
interface MaybeAutoLinkInput {
|
|
74
|
+
description: string | null | undefined;
|
|
75
|
+
graphQLService: GraphQLService;
|
|
76
|
+
identifier: string;
|
|
77
|
+
issueId: string;
|
|
78
|
+
linearService: LinearService;
|
|
79
|
+
options: OptionValues;
|
|
80
|
+
preResolved?: Map<string, string>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Run auto-linking for issue references found in a description, unless
|
|
84
|
+
* the user opted out with `--no-auto-link` or no description was
|
|
85
|
+
* provided. Returns undefined when nothing was linked / skipped /
|
|
86
|
+
* failed (so callers can omit the field from JSON output).
|
|
87
|
+
*/
|
|
88
|
+
export declare function maybeAutoLink(input: MaybeAutoLinkInput): Promise<AutoLinkResult | undefined>;
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Description-handling helpers for issues create/update and the
|
|
3
|
+
* `issues link-references --rewrite-description` flow.
|
|
4
|
+
*
|
|
5
|
+
* Three concerns live here:
|
|
6
|
+
*
|
|
7
|
+
* 1. Resolving the description value from `--description`,
|
|
8
|
+
* `--description-file`, or `--template`.
|
|
9
|
+
* 2. The shared wrap-and-resolve pipeline (`wrapAndResolveRefs`)
|
|
10
|
+
* used by the create/update path and the rewrite-description
|
|
11
|
+
* path. Both wrap valid identifiers as markdown links and
|
|
12
|
+
* return the resolved id→uuid map for downstream auto-link.
|
|
13
|
+
* 3. The post-create/update `maybeAutoLink` hook that creates
|
|
14
|
+
* sidebar relations for refs in the description.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `commands/issues.ts` (ALL-938) so that file can
|
|
17
|
+
* focus on commander wiring + handlers.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import { loadConfig } from "../../config/config.js";
|
|
21
|
+
import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
|
|
22
|
+
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
23
|
+
import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
|
|
24
|
+
import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
|
|
25
|
+
import { validateReferences } from "../../utils/validate-references.js";
|
|
26
|
+
import { getWorkspaceUrlKey } from "../../utils/workspace-url.js";
|
|
27
|
+
/**
|
|
28
|
+
* Read description from a file path or stdin ("-").
|
|
29
|
+
* Avoids shell escaping issues when descriptions contain special characters.
|
|
30
|
+
*/
|
|
31
|
+
export function readDescriptionFile(filePath) {
|
|
32
|
+
if (filePath === "-") {
|
|
33
|
+
return fs.readFileSync(0, "utf8").trim();
|
|
34
|
+
}
|
|
35
|
+
if (!fs.existsSync(filePath)) {
|
|
36
|
+
throw new Error(`Description file not found: ${filePath}`);
|
|
37
|
+
}
|
|
38
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the description from --description, --description-file, or
|
|
42
|
+
* --template (looked up in `config.descriptionTemplates`).
|
|
43
|
+
*
|
|
44
|
+
* Precedence: --description-file > --description > --template
|
|
45
|
+
*
|
|
46
|
+
* Passing --template alongside --description / --description-file is
|
|
47
|
+
* a usage error — the explicit body and a template both producing
|
|
48
|
+
* content would silently drop one. We throw so the user picks one.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveDescription(options) {
|
|
51
|
+
const hasInline = typeof options.description === "string" && options.description.length > 0;
|
|
52
|
+
const hasFile = Boolean(options.descriptionFile);
|
|
53
|
+
const hasTemplate = typeof options.template === "string" && options.template;
|
|
54
|
+
if (hasTemplate && (hasInline || hasFile)) {
|
|
55
|
+
throw new Error("--template is mutually exclusive with --description / --description-file. " +
|
|
56
|
+
"Pick one.");
|
|
57
|
+
}
|
|
58
|
+
if (hasFile) {
|
|
59
|
+
return readDescriptionFile(options.descriptionFile);
|
|
60
|
+
}
|
|
61
|
+
if (hasInline) {
|
|
62
|
+
return options.description;
|
|
63
|
+
}
|
|
64
|
+
if (hasTemplate) {
|
|
65
|
+
const templates = loadConfig().descriptionTemplates ?? {};
|
|
66
|
+
const body = templates[options.template];
|
|
67
|
+
if (!body) {
|
|
68
|
+
const available = Object.keys(templates).sort();
|
|
69
|
+
const hint = available.length
|
|
70
|
+
? `Available templates: ${available.join(", ")}`
|
|
71
|
+
: "No templates configured. Add one under `descriptionTemplates` in your config.";
|
|
72
|
+
throw new Error(`Template "${options.template}" not found. ${hint}`);
|
|
73
|
+
}
|
|
74
|
+
return body;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Shared core for the wrap-and-resolve pipeline used by both the
|
|
80
|
+
* create/update path (`prepareAutoLinkedDescription`) and the
|
|
81
|
+
* `link-references --rewrite-description` path
|
|
82
|
+
* (`prepareDescriptionRewrite`).
|
|
83
|
+
*
|
|
84
|
+
* Returns:
|
|
85
|
+
* - `wrapped: undefined` when the original description had no
|
|
86
|
+
* resolvable refs to wrap (no-op for the caller),
|
|
87
|
+
* - `wrapped: <string>` when at least one ref was wrapped,
|
|
88
|
+
* - `preResolved` — the validated id→uuid map, passed downstream
|
|
89
|
+
* to `autoLinkReferences` to skip a second resolve roundtrip.
|
|
90
|
+
*/
|
|
91
|
+
async function wrapAndResolveRefs(description, selfIdentifier, linearService, graphQLService) {
|
|
92
|
+
const refs = extractIssueReferences(description, selfIdentifier);
|
|
93
|
+
if (refs.length === 0) {
|
|
94
|
+
return { wrapped: undefined, preResolved: new Map() };
|
|
95
|
+
}
|
|
96
|
+
const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
|
|
97
|
+
if (preResolved.size === 0) {
|
|
98
|
+
return { wrapped: undefined, preResolved };
|
|
99
|
+
}
|
|
100
|
+
const validIds = new Set(preResolved.keys());
|
|
101
|
+
const urlKey = await getWorkspaceUrlKey(graphQLService);
|
|
102
|
+
const rewritten = wrapIssueReferencesAsLinks(description, validIds, urlKey);
|
|
103
|
+
return {
|
|
104
|
+
wrapped: rewritten === description ? undefined : rewritten,
|
|
105
|
+
preResolved,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Pre-process a description before sending to Linear:
|
|
110
|
+
* 1. Extract all issue references.
|
|
111
|
+
* 2. Validate them (drop ones that don't resolve in the workspace —
|
|
112
|
+
* handles "ISO-1424"-style false positives).
|
|
113
|
+
* 3. Wrap valid identifiers as markdown links — skipping any
|
|
114
|
+
* already inside a link, code block, or backtick span.
|
|
115
|
+
*
|
|
116
|
+
* No-ops (returns the original description with an empty map) when:
|
|
117
|
+
* - description is empty/undefined
|
|
118
|
+
* - the user passed `--no-auto-link`
|
|
119
|
+
*/
|
|
120
|
+
export async function prepareAutoLinkedDescription(description, options, selfIdentifier, linearService, graphQLService) {
|
|
121
|
+
if (!description || options.autoLink === false) {
|
|
122
|
+
return { description, preResolved: new Map(), rewritten: false };
|
|
123
|
+
}
|
|
124
|
+
const { wrapped, preResolved } = await wrapAndResolveRefs(description, selfIdentifier, linearService, graphQLService);
|
|
125
|
+
return {
|
|
126
|
+
description: wrapped ?? description,
|
|
127
|
+
preResolved,
|
|
128
|
+
rewritten: wrapped !== undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Prepare a description rewrite for `link-references --rewrite-description`.
|
|
133
|
+
* Same wrap-and-resolve core; different return shape so the caller can
|
|
134
|
+
* skip the rewrite mutation when nothing would change.
|
|
135
|
+
*/
|
|
136
|
+
export async function prepareDescriptionRewrite(description, selfIdentifier, linearService, graphQLService) {
|
|
137
|
+
if (!description) {
|
|
138
|
+
return { preResolved: undefined, wrapped: undefined };
|
|
139
|
+
}
|
|
140
|
+
const { wrapped, preResolved } = await wrapAndResolveRefs(description, selfIdentifier, linearService, graphQLService);
|
|
141
|
+
return {
|
|
142
|
+
// `link-references --rewrite-description` historically used
|
|
143
|
+
// `preResolved: undefined` to mean "no refs at all"; preserve
|
|
144
|
+
// that signal so the existing call site keeps the same shape.
|
|
145
|
+
preResolved: preResolved.size === 0 ? undefined : preResolved,
|
|
146
|
+
wrapped,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
|
|
150
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, { id: issueUuid, input: { description } });
|
|
151
|
+
if (!updateResult.issueUpdate.success) {
|
|
152
|
+
throw new Error("Failed to rewrite description");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Run auto-linking for issue references found in a description, unless
|
|
157
|
+
* the user opted out with `--no-auto-link` or no description was
|
|
158
|
+
* provided. Returns undefined when nothing was linked / skipped /
|
|
159
|
+
* failed (so callers can omit the field from JSON output).
|
|
160
|
+
*/
|
|
161
|
+
export async function maybeAutoLink(input) {
|
|
162
|
+
const { issueId, identifier, description, options, graphQLService, linearService, preResolved, } = input;
|
|
163
|
+
if (options.autoLink === false) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!description) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const result = await autoLinkReferences({
|
|
170
|
+
issueId,
|
|
171
|
+
identifier,
|
|
172
|
+
description,
|
|
173
|
+
graphQLService,
|
|
174
|
+
linearService,
|
|
175
|
+
preResolved,
|
|
176
|
+
});
|
|
177
|
+
if (result.linked.length === 0 &&
|
|
178
|
+
result.skipped.length === 0 &&
|
|
179
|
+
result.failed.length === 0) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `el-linear issues link-references` handler.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* - Single-issue (`<issueId>`): scan one issue's description (and
|
|
6
|
+
* optionally its comments) for issue refs, create sidebar
|
|
7
|
+
* relations for any that aren't already linked, optionally
|
|
8
|
+
* rewrite the description to wrap the bare refs as markdown
|
|
9
|
+
* links.
|
|
10
|
+
* - Batch (`--team <key>`): walk the team's recent issues and run
|
|
11
|
+
* the same auto-link pass on each. Description rewrite is
|
|
12
|
+
* intentionally disabled in batch mode — bulk rewrites
|
|
13
|
+
* bot-author every issue's revision history, which makes
|
|
14
|
+
* "who edited this?" attribution noisy.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `commands/issues.ts` (ALL-938) so that file can
|
|
17
|
+
* focus on commander wiring + the create / update / read / search
|
|
18
|
+
* handlers.
|
|
19
|
+
*/
|
|
20
|
+
import type { Command, OptionValues } from "commander";
|
|
21
|
+
export declare function handleLinkReferencesIssue(issueIdOrTeamFlag: string | undefined, options: OptionValues, command: Command): Promise<void>;
|