@enrichlayer/el-linear 1.9.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 +13 -0
- 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/issue-id.js +1 -3
- package/dist/commands/issues/description.js +2 -6
- 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 +65 -284
- package/dist/commands/labels.js +13 -23
- package/dist/commands/project-milestones.js +13 -20
- package/dist/commands/projects.js +67 -38
- package/dist/commands/refs.js +8 -0
- 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 +9 -14
- package/dist/commands/users.js +2 -1
- package/dist/config/resolver.js +2 -3
- 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/types/linear.d.ts +8 -2
- package/dist/utils/auto-link-references.js +29 -24
- 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 +12 -2
- package/dist/utils/graphql-issues-service.js +151 -172
- package/dist/utils/output.d.ts +2 -0
- package/dist/utils/output.js +16 -6
- package/dist/utils/workspace-url.d.ts +5 -1
- package/dist/utils/workspace-url.js +35 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -237,6 +237,19 @@ A full reference with every key documented lives in [config.example.json](./conf
|
|
|
237
237
|
UUIDs come from the Linear UI (URL bars, settings pages) or via el-linear
|
|
238
238
|
itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
|
|
239
239
|
|
|
240
|
+
### Workspace URL key
|
|
241
|
+
|
|
242
|
+
`refs wrap` and the auto-link paths build canonical issue URLs like
|
|
243
|
+
`https://linear.app/<workspaceUrlKey>/issue/<id>/`. By default the key is
|
|
244
|
+
fetched once per CLI invocation via `viewer.organization.urlKey`. To skip the
|
|
245
|
+
network call (offline use, perf, `--no-validate`), provide it via any of:
|
|
246
|
+
|
|
247
|
+
1. `--workspace-url-key <key>` flag on `refs wrap` (per-invocation, highest priority)
|
|
248
|
+
2. `EL_LINEAR_WORKSPACE_URL_KEY` env var
|
|
249
|
+
3. `workspaceUrlKey` field in `config.json`
|
|
250
|
+
|
|
251
|
+
When any of these is set, no GraphQL request is made.
|
|
252
|
+
|
|
240
253
|
## Term enforcement (with brand-promotion examples)
|
|
241
254
|
|
|
242
255
|
The `terms` rules let you keep a list of canonical names and the misspellings
|
|
@@ -3,6 +3,7 @@ import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-se
|
|
|
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 setupAttachmentsCommands(program) {
|
|
7
8
|
const attachments = program
|
|
8
9
|
.command("attachments")
|
|
@@ -40,7 +41,7 @@ export function setupAttachmentsCommands(program) {
|
|
|
40
41
|
const resolvedIssueId = await linearService.resolveIssueId(issueId);
|
|
41
42
|
const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
|
|
42
43
|
const allAttachments = await attachmentsService.listAttachments(resolvedIssueId);
|
|
43
|
-
const limit =
|
|
44
|
+
const limit = parsePositiveInt(options.limit, "--limit");
|
|
44
45
|
const data = allAttachments.slice(0, limit);
|
|
45
46
|
outputSuccess({ data, meta: { count: data.length } });
|
|
46
47
|
}));
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -147,12 +147,8 @@ export async function prepareDescriptionRewrite(description, selfIdentifier, lin
|
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
export async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
|
|
150
|
-
const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
|
|
151
|
-
|
|
152
|
-
input: { description },
|
|
153
|
-
});
|
|
154
|
-
const mutation = updateResult.issueUpdate;
|
|
155
|
-
if (!mutation?.success) {
|
|
150
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, { id: issueUuid, input: { description } });
|
|
151
|
+
if (!updateResult.issueUpdate.success) {
|
|
156
152
|
throw new Error("Failed to rewrite description");
|
|
157
153
|
}
|
|
158
154
|
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,171 @@
|
|
|
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 { resolveTeam } from "../../config/resolver.js";
|
|
21
|
+
import { LIST_COMMENTS_QUERY } from "../../queries/comments.js";
|
|
22
|
+
import { GET_ISSUE_RELATIONS_QUERY, SCAN_ISSUES_QUERY, } from "../../queries/issues.js";
|
|
23
|
+
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
24
|
+
import { createGraphQLService, } from "../../utils/graphql-service.js";
|
|
25
|
+
import { createLinearService, } from "../../utils/linear-service.js";
|
|
26
|
+
import { outputSuccess } from "../../utils/output.js";
|
|
27
|
+
import { getRootOpts } from "../../utils/root-opts.js";
|
|
28
|
+
import { parsePositiveInt } from "../../utils/validators.js";
|
|
29
|
+
import { prepareDescriptionRewrite, pushDescriptionUpdate, } from "./description.js";
|
|
30
|
+
async function fetchCommentBodies(issueUuid, graphQLService) {
|
|
31
|
+
const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
|
|
32
|
+
issueId: issueUuid,
|
|
33
|
+
first: 250,
|
|
34
|
+
});
|
|
35
|
+
const nodes = result.issue?.comments.nodes ?? [];
|
|
36
|
+
return nodes.map((c) => c.body).filter((b) => b.length > 0);
|
|
37
|
+
}
|
|
38
|
+
async function linkReferencesForIssue(args) {
|
|
39
|
+
const comments = args.includeComments
|
|
40
|
+
? await fetchCommentBodies(args.issueUuid, args.graphQLService)
|
|
41
|
+
: undefined;
|
|
42
|
+
return autoLinkReferences({
|
|
43
|
+
issueId: args.issueUuid,
|
|
44
|
+
identifier: args.identifier,
|
|
45
|
+
description: args.description,
|
|
46
|
+
comments,
|
|
47
|
+
dryRun: args.dryRun,
|
|
48
|
+
graphQLService: args.graphQLService,
|
|
49
|
+
linearService: args.linearService,
|
|
50
|
+
preResolved: args.preResolved,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async function handleLinkReferencesSingle(issueId, options, graphQLService, linearService) {
|
|
54
|
+
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
55
|
+
// GET_ISSUE_RELATIONS_QUERY also returns description, so this single call gives us everything
|
|
56
|
+
// we need for the description-only path. Comments are fetched separately on demand.
|
|
57
|
+
const issueResult = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, { id: resolvedId });
|
|
58
|
+
if (!issueResult.issue) {
|
|
59
|
+
throw new Error(`Issue "${issueId}" not found`);
|
|
60
|
+
}
|
|
61
|
+
const issue = issueResult.issue;
|
|
62
|
+
const identifier = issue.identifier;
|
|
63
|
+
const description = issue.description ?? "";
|
|
64
|
+
const dryRun = Boolean(options.dryRun);
|
|
65
|
+
const includeComments = Boolean(options.includeComments);
|
|
66
|
+
const rewriteDescription = Boolean(options.rewriteDescription);
|
|
67
|
+
// If rewriting, validate refs once up front so we can both wrap the description and
|
|
68
|
+
// pass the resolved map down to autoLink — avoids a second resolve round trip.
|
|
69
|
+
const rewrite = rewriteDescription
|
|
70
|
+
? await prepareDescriptionRewrite(description, identifier, linearService, graphQLService)
|
|
71
|
+
: { preResolved: undefined, wrapped: undefined };
|
|
72
|
+
const autoLinked = await linkReferencesForIssue({
|
|
73
|
+
issueUuid: resolvedId,
|
|
74
|
+
identifier,
|
|
75
|
+
description,
|
|
76
|
+
includeComments,
|
|
77
|
+
dryRun,
|
|
78
|
+
graphQLService,
|
|
79
|
+
linearService,
|
|
80
|
+
preResolved: rewrite.preResolved,
|
|
81
|
+
});
|
|
82
|
+
let descriptionRewritten = false;
|
|
83
|
+
if (rewriteDescription && rewrite.wrapped && !dryRun) {
|
|
84
|
+
await pushDescriptionUpdate(resolvedId, rewrite.wrapped, graphQLService);
|
|
85
|
+
descriptionRewritten = true;
|
|
86
|
+
}
|
|
87
|
+
outputSuccess({
|
|
88
|
+
id: resolvedId,
|
|
89
|
+
identifier,
|
|
90
|
+
title: issue.title,
|
|
91
|
+
autoLinked,
|
|
92
|
+
...(rewriteDescription
|
|
93
|
+
? {
|
|
94
|
+
descriptionRewritten,
|
|
95
|
+
...(dryRun && rewrite.wrapped
|
|
96
|
+
? { descriptionPreview: rewrite.wrapped }
|
|
97
|
+
: {}),
|
|
98
|
+
}
|
|
99
|
+
: {}),
|
|
100
|
+
meta: {
|
|
101
|
+
dryRun,
|
|
102
|
+
includeComments,
|
|
103
|
+
...(rewriteDescription ? { rewriteDescription: true } : {}),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async function handleLinkReferencesBatch(teamInput, options, graphQLService, linearService) {
|
|
108
|
+
const teamId = resolveTeam(teamInput);
|
|
109
|
+
const limit = options.limit
|
|
110
|
+
? parsePositiveInt(options.limit, "--limit")
|
|
111
|
+
: 100;
|
|
112
|
+
const dryRun = Boolean(options.dryRun);
|
|
113
|
+
const includeComments = Boolean(options.includeComments);
|
|
114
|
+
const scan = await graphQLService.rawRequest(SCAN_ISSUES_QUERY, {
|
|
115
|
+
filter: { team: { id: { eq: teamId } } },
|
|
116
|
+
first: limit,
|
|
117
|
+
});
|
|
118
|
+
const nodes = scan.issues.nodes;
|
|
119
|
+
const entries = [];
|
|
120
|
+
for (const node of nodes) {
|
|
121
|
+
const issueUuid = node.id;
|
|
122
|
+
const identifier = node.identifier;
|
|
123
|
+
const description = node.description ?? "";
|
|
124
|
+
const autoLinked = await linkReferencesForIssue({
|
|
125
|
+
issueUuid,
|
|
126
|
+
identifier,
|
|
127
|
+
description,
|
|
128
|
+
includeComments,
|
|
129
|
+
dryRun,
|
|
130
|
+
graphQLService,
|
|
131
|
+
linearService,
|
|
132
|
+
});
|
|
133
|
+
entries.push({ identifier, autoLinked });
|
|
134
|
+
}
|
|
135
|
+
// Summary counts across the batch
|
|
136
|
+
const totalLinked = entries.reduce((n, e) => n + e.autoLinked.linked.length, 0);
|
|
137
|
+
const totalSkipped = entries.reduce((n, e) => n + e.autoLinked.skipped.length, 0);
|
|
138
|
+
const totalFailed = entries.reduce((n, e) => n + e.autoLinked.failed.length, 0);
|
|
139
|
+
outputSuccess({
|
|
140
|
+
data: entries,
|
|
141
|
+
meta: {
|
|
142
|
+
count: entries.length,
|
|
143
|
+
team: teamInput,
|
|
144
|
+
dryRun,
|
|
145
|
+
includeComments,
|
|
146
|
+
totals: {
|
|
147
|
+
linked: totalLinked,
|
|
148
|
+
skipped: totalSkipped,
|
|
149
|
+
failed: totalFailed,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
export async function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
|
|
155
|
+
const rootOpts = getRootOpts(command);
|
|
156
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
157
|
+
const linearService = await createLinearService(rootOpts);
|
|
158
|
+
if (options.team) {
|
|
159
|
+
if (issueIdOrTeamFlag) {
|
|
160
|
+
throw new Error("Provide either an issueId positional argument OR --team, not both. With --team, omit the issueId.");
|
|
161
|
+
}
|
|
162
|
+
if (options.rewriteDescription) {
|
|
163
|
+
throw new Error("--rewrite-description is single-issue only. Use it case by case (one issueId per invocation) — bulk description rewrites bot-author the whole team's history.");
|
|
164
|
+
}
|
|
165
|
+
return handleLinkReferencesBatch(options.team, options, graphQLService, linearService);
|
|
166
|
+
}
|
|
167
|
+
if (!issueIdOrTeamFlag) {
|
|
168
|
+
throw new Error("issueId is required (or use --team <key> to scan a whole team)");
|
|
169
|
+
}
|
|
170
|
+
return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
|
|
171
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue-relation helpers — `transformIssueRelation` for the create
|
|
3
|
+
* path, plus the `RelatedIssueEntry` builders used by the
|
|
4
|
+
* `issues related` query path.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `commands/issues.ts` (ALL-938) so that file can
|
|
7
|
+
* focus on commander wiring + handlers.
|
|
8
|
+
*/
|
|
9
|
+
import type { IssueRelationCreateResponse, RelationIncomingNode, RelationOutgoingNode, RelationPeerNode } from "../../queries/issues-types.js";
|
|
10
|
+
import type { LinearIssueRelation } from "../../types/linear.js";
|
|
11
|
+
import type { GraphQLService } from "../../utils/graphql-service.js";
|
|
12
|
+
import type { LinearService } from "../../utils/linear-service.js";
|
|
13
|
+
type CreatedIssueRelation = NonNullable<IssueRelationCreateResponse["issueRelationCreate"]["issueRelation"]>;
|
|
14
|
+
export declare function transformIssueRelation(rel: CreatedIssueRelation): LinearIssueRelation;
|
|
15
|
+
/**
|
|
16
|
+
* Walk the `--related-to` / `--blocks` / `--blocked-by` /
|
|
17
|
+
* `--duplicate-of` flags and create the corresponding
|
|
18
|
+
* IssueRelation rows. Used by `issues create`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createRelations(sourceId: string, options: Record<string, unknown>, graphQLService: GraphQLService, linearService: LinearService): Promise<LinearIssueRelation[]>;
|
|
21
|
+
export interface RelatedIssueEntry {
|
|
22
|
+
direction: "outgoing" | "incoming";
|
|
23
|
+
id: string;
|
|
24
|
+
issue: {
|
|
25
|
+
id: string;
|
|
26
|
+
identifier: string;
|
|
27
|
+
title: string;
|
|
28
|
+
state?: {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
priority?: number;
|
|
33
|
+
assignee?: {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
};
|
|
37
|
+
team?: {
|
|
38
|
+
id: string;
|
|
39
|
+
key: string;
|
|
40
|
+
name: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
type: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Invert relation type for incoming (inverse) relations so the output
|
|
47
|
+
* reads naturally from the perspective of the queried issue. E.g. if
|
|
48
|
+
* DEV-100 "blocks" DEV-200, and we query DEV-200, the inverse relation
|
|
49
|
+
* type is "blocks" but direction is incoming → "blockedBy".
|
|
50
|
+
*/
|
|
51
|
+
export declare function normalizeInverseType(type: string): string;
|
|
52
|
+
export declare function buildRelatedIssueSummary(peer: RelationPeerNode): RelatedIssueEntry["issue"];
|
|
53
|
+
export declare function buildOutgoingRelationEntries(nodes: RelationOutgoingNode[] | undefined): RelatedIssueEntry[];
|
|
54
|
+
export declare function buildIncomingRelationEntries(nodes: RelationIncomingNode[] | undefined): RelatedIssueEntry[];
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue-relation helpers — `transformIssueRelation` for the create
|
|
3
|
+
* path, plus the `RelatedIssueEntry` builders used by the
|
|
4
|
+
* `issues related` query path.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `commands/issues.ts` (ALL-938) so that file can
|
|
7
|
+
* focus on commander wiring + handlers.
|
|
8
|
+
*/
|
|
9
|
+
import { ISSUE_RELATION_CREATE_MUTATION } from "../../queries/issues.js";
|
|
10
|
+
import { splitList } from "../../utils/validators.js";
|
|
11
|
+
export function transformIssueRelation(rel) {
|
|
12
|
+
return {
|
|
13
|
+
id: rel.id,
|
|
14
|
+
type: rel.type,
|
|
15
|
+
issue: {
|
|
16
|
+
id: rel.issue.id,
|
|
17
|
+
identifier: rel.issue.identifier,
|
|
18
|
+
title: rel.issue.title,
|
|
19
|
+
},
|
|
20
|
+
relatedIssue: {
|
|
21
|
+
id: rel.relatedIssue.id,
|
|
22
|
+
identifier: rel.relatedIssue.identifier,
|
|
23
|
+
title: rel.relatedIssue.title,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Walk the `--related-to` / `--blocks` / `--blocked-by` /
|
|
29
|
+
* `--duplicate-of` flags and create the corresponding
|
|
30
|
+
* IssueRelation rows. Used by `issues create`.
|
|
31
|
+
*/
|
|
32
|
+
export async function createRelations(sourceId, options, graphQLService, linearService) {
|
|
33
|
+
const relations = [];
|
|
34
|
+
const relationSpecs = [
|
|
35
|
+
{ option: "relatedTo", type: "related" },
|
|
36
|
+
{ option: "blocks", type: "blocks" },
|
|
37
|
+
{ option: "blockedBy", type: "blocks", reverse: true },
|
|
38
|
+
{ option: "duplicateOf", type: "duplicate" },
|
|
39
|
+
];
|
|
40
|
+
for (const spec of relationSpecs) {
|
|
41
|
+
const value = options[spec.option];
|
|
42
|
+
if (!value) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
for (const id of splitList(value)) {
|
|
46
|
+
const targetId = await linearService.resolveIssueId(id);
|
|
47
|
+
const result = await graphQLService.rawRequest(ISSUE_RELATION_CREATE_MUTATION, {
|
|
48
|
+
input: {
|
|
49
|
+
issueId: spec.reverse ? targetId : sourceId,
|
|
50
|
+
relatedIssueId: spec.reverse ? sourceId : targetId,
|
|
51
|
+
type: spec.type,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const issueRelation = result.issueRelationCreate.issueRelation;
|
|
55
|
+
if (!issueRelation) {
|
|
56
|
+
throw new Error(`Failed to create ${spec.type} relation from ${sourceId} to ${id}`);
|
|
57
|
+
}
|
|
58
|
+
relations.push(transformIssueRelation(issueRelation));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return relations;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Invert relation type for incoming (inverse) relations so the output
|
|
65
|
+
* reads naturally from the perspective of the queried issue. E.g. if
|
|
66
|
+
* DEV-100 "blocks" DEV-200, and we query DEV-200, the inverse relation
|
|
67
|
+
* type is "blocks" but direction is incoming → "blockedBy".
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeInverseType(type) {
|
|
70
|
+
if (type === "blocks") {
|
|
71
|
+
return "blockedBy";
|
|
72
|
+
}
|
|
73
|
+
return type;
|
|
74
|
+
}
|
|
75
|
+
export function buildRelatedIssueSummary(peer) {
|
|
76
|
+
return {
|
|
77
|
+
id: peer.id,
|
|
78
|
+
identifier: peer.identifier,
|
|
79
|
+
title: peer.title,
|
|
80
|
+
...(peer.state
|
|
81
|
+
? { state: { id: peer.state.id, name: peer.state.name } }
|
|
82
|
+
: {}),
|
|
83
|
+
...(peer.priority == null ? {} : { priority: peer.priority }),
|
|
84
|
+
...(peer.assignee
|
|
85
|
+
? {
|
|
86
|
+
assignee: {
|
|
87
|
+
id: peer.assignee.id,
|
|
88
|
+
name: peer.assignee.name,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
: {}),
|
|
92
|
+
...(peer.team
|
|
93
|
+
? {
|
|
94
|
+
team: {
|
|
95
|
+
id: peer.team.id,
|
|
96
|
+
key: peer.team.key,
|
|
97
|
+
name: peer.team.name,
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
: {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export function buildOutgoingRelationEntries(nodes) {
|
|
104
|
+
const entries = [];
|
|
105
|
+
for (const rel of nodes ?? []) {
|
|
106
|
+
if (!rel.relatedIssue) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
entries.push({
|
|
110
|
+
id: rel.id,
|
|
111
|
+
type: rel.type,
|
|
112
|
+
direction: "outgoing",
|
|
113
|
+
issue: buildRelatedIssueSummary(rel.relatedIssue),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
export function buildIncomingRelationEntries(nodes) {
|
|
119
|
+
const entries = [];
|
|
120
|
+
for (const rel of nodes ?? []) {
|
|
121
|
+
if (!rel.issue) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
entries.push({
|
|
125
|
+
id: rel.id,
|
|
126
|
+
type: normalizeInverseType(rel.type),
|
|
127
|
+
direction: "incoming",
|
|
128
|
+
issue: buildRelatedIssueSummary(rel.issue),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return entries;
|
|
132
|
+
}
|