linear-sdk-cli 0.8.2 → 0.9.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/CHANGELOG.md +29 -15
- package/COVERAGE.md +26 -25
- package/MIGRATING.md +1 -1
- package/README.md +170 -9
- package/package.json +6 -6
- package/skills/linear-sdk-cli/SKILL.md +98 -0
- package/skills/linear-sdk-cli/references/auth.md +31 -0
- package/skills/linear-sdk-cli/references/config.md +13 -9
- package/skills/linear-sdk-cli/references/issue.md +3 -2
- package/skills/linear-sdk-cli/references/release-note.md +148 -0
- package/skills/linear-sdk-cli/references/release-pipeline.md +137 -0
- package/skills/linear-sdk-cli/references/release-stage.md +121 -0
- package/skills/linear-sdk-cli/references/release.md +348 -0
- package/src/cli.ts +8 -0
- package/src/commands/issue.ts +9 -3
- package/src/commands/meta.ts +82 -2
- package/src/commands/release-note.ts +90 -0
- package/src/commands/release-pipeline.ts +122 -0
- package/src/commands/release-stage.ts +100 -0
- package/src/commands/release.ts +254 -0
- package/src/config.ts +254 -6
- package/src/lib/keyring.ts +28 -0
- package/src/lib/options.ts +18 -0
- package/src/lib/output-shapes.ts +68 -1
- package/src/lib/release-input.ts +138 -0
- package/src/services/issue.ts +12 -3
- package/src/services/release.ts +691 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Releases use the authenticated SDK transport, with explicit GraphQL selections.
|
|
3
|
+
* Tailored projections select related fields and mutation receipts in one request,
|
|
4
|
+
* avoiding per-row SDK relation fetches. Schema contract tests validate documents
|
|
5
|
+
* and variables against a read-only capture of Linear's current schema.
|
|
6
|
+
* No access-key transport or Git attribution is implemented here: linear-release
|
|
7
|
+
* owns those CI workflows. All values travel as variables, never query strings.
|
|
8
|
+
*/
|
|
9
|
+
import type { LinearClient } from "@linear/sdk";
|
|
10
|
+
import { withRetry } from "../client.js";
|
|
11
|
+
import { CliError, normalizeError, notFound, usageError } from "../lib/errors.js";
|
|
12
|
+
import { assertMutation, unwrapMutation } from "../lib/mutation.js";
|
|
13
|
+
import { collectRawQuery } from "../lib/pagination.js";
|
|
14
|
+
import { shape } from "../lib/shape.js";
|
|
15
|
+
|
|
16
|
+
interface NamedResource {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
interface PipelineRef extends NamedResource {
|
|
21
|
+
type: string;
|
|
22
|
+
}
|
|
23
|
+
interface StageRef extends NamedResource {
|
|
24
|
+
type: string;
|
|
25
|
+
frozen: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ReleaseRow {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
version: string | null;
|
|
32
|
+
commitSha: string | null;
|
|
33
|
+
url: string;
|
|
34
|
+
pipeline: PipelineRef;
|
|
35
|
+
stage: StageRef;
|
|
36
|
+
startDate: string | null;
|
|
37
|
+
targetDate: string | null;
|
|
38
|
+
startedAt: string | null;
|
|
39
|
+
completedAt: string | null;
|
|
40
|
+
canceledAt: string | null;
|
|
41
|
+
archivedAt: string | null;
|
|
42
|
+
}
|
|
43
|
+
export interface ReleaseDetail extends ReleaseRow {
|
|
44
|
+
description: string | null;
|
|
45
|
+
issueCount: number;
|
|
46
|
+
}
|
|
47
|
+
export const RELEASE_ROW_SHAPE = shape<ReleaseRow>({
|
|
48
|
+
id: "string",
|
|
49
|
+
name: "string",
|
|
50
|
+
version: "string|null",
|
|
51
|
+
commitSha: "string|null",
|
|
52
|
+
url: "string",
|
|
53
|
+
pipeline: { id: "string", name: "string", type: "string" },
|
|
54
|
+
stage: { id: "string", name: "string", type: "string", frozen: "boolean" },
|
|
55
|
+
startDate: "string|null",
|
|
56
|
+
targetDate: "string|null",
|
|
57
|
+
startedAt: "string|null",
|
|
58
|
+
completedAt: "string|null",
|
|
59
|
+
canceledAt: "string|null",
|
|
60
|
+
archivedAt: "string|null",
|
|
61
|
+
});
|
|
62
|
+
export const RELEASE_DETAIL_SHAPE = shape<ReleaseDetail>({
|
|
63
|
+
...RELEASE_ROW_SHAPE,
|
|
64
|
+
description: "string|null",
|
|
65
|
+
issueCount: "number",
|
|
66
|
+
});
|
|
67
|
+
export interface PipelineRow {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
slugId: string;
|
|
71
|
+
type: string;
|
|
72
|
+
url: string;
|
|
73
|
+
isProduction: boolean;
|
|
74
|
+
autoGenerateReleaseNotesOnCompletion: boolean;
|
|
75
|
+
rolloverIssuesOnCompletion: boolean;
|
|
76
|
+
includePathPatterns: string[];
|
|
77
|
+
archivedAt: string | null;
|
|
78
|
+
}
|
|
79
|
+
export const PIPELINE_ROW_SHAPE = shape<PipelineRow>({
|
|
80
|
+
id: "string",
|
|
81
|
+
name: "string",
|
|
82
|
+
slugId: "string",
|
|
83
|
+
type: "string",
|
|
84
|
+
url: "string",
|
|
85
|
+
isProduction: "boolean",
|
|
86
|
+
autoGenerateReleaseNotesOnCompletion: "boolean",
|
|
87
|
+
rolloverIssuesOnCompletion: "boolean",
|
|
88
|
+
includePathPatterns: ["string"],
|
|
89
|
+
archivedAt: "string|null",
|
|
90
|
+
});
|
|
91
|
+
export interface StageRow {
|
|
92
|
+
id: string;
|
|
93
|
+
name: string;
|
|
94
|
+
type: string;
|
|
95
|
+
color: string;
|
|
96
|
+
position: number;
|
|
97
|
+
frozen: boolean;
|
|
98
|
+
pipeline: NamedResource;
|
|
99
|
+
archivedAt: string | null;
|
|
100
|
+
}
|
|
101
|
+
export const STAGE_ROW_SHAPE = shape<StageRow>({
|
|
102
|
+
id: "string",
|
|
103
|
+
name: "string",
|
|
104
|
+
type: "string",
|
|
105
|
+
color: "string",
|
|
106
|
+
position: "number",
|
|
107
|
+
frozen: "boolean",
|
|
108
|
+
pipeline: { id: "string", name: "string" },
|
|
109
|
+
archivedAt: "string|null",
|
|
110
|
+
});
|
|
111
|
+
export interface NoteRow {
|
|
112
|
+
id: string;
|
|
113
|
+
title: string | null;
|
|
114
|
+
url: string;
|
|
115
|
+
pipeline: NamedResource;
|
|
116
|
+
releaseCount: number;
|
|
117
|
+
firstRelease: NamedResource | null;
|
|
118
|
+
lastRelease: NamedResource | null;
|
|
119
|
+
generationStatus: string | null;
|
|
120
|
+
archivedAt: string | null;
|
|
121
|
+
}
|
|
122
|
+
export interface NoteDetail extends NoteRow {
|
|
123
|
+
content: string | null;
|
|
124
|
+
}
|
|
125
|
+
export const NOTE_ROW_SHAPE = shape<NoteRow>({
|
|
126
|
+
id: "string",
|
|
127
|
+
title: "string|null",
|
|
128
|
+
url: "string",
|
|
129
|
+
pipeline: { id: "string", name: "string" },
|
|
130
|
+
releaseCount: "number",
|
|
131
|
+
firstRelease: { nullable: { id: "string", name: "string" } },
|
|
132
|
+
lastRelease: { nullable: { id: "string", name: "string" } },
|
|
133
|
+
generationStatus: "string|null",
|
|
134
|
+
archivedAt: "string|null",
|
|
135
|
+
});
|
|
136
|
+
export const NOTE_DETAIL_SHAPE = shape<NoteDetail>({ ...NOTE_ROW_SHAPE, content: "string|null" });
|
|
137
|
+
export interface ReleaseIssue {
|
|
138
|
+
id: string;
|
|
139
|
+
identifier: string;
|
|
140
|
+
title: string;
|
|
141
|
+
url: string;
|
|
142
|
+
}
|
|
143
|
+
export const RELEASE_ISSUE_SHAPE = shape<ReleaseIssue>({
|
|
144
|
+
id: "string",
|
|
145
|
+
identifier: "string",
|
|
146
|
+
title: "string",
|
|
147
|
+
url: "string",
|
|
148
|
+
});
|
|
149
|
+
export interface ReleaseLink {
|
|
150
|
+
id: string;
|
|
151
|
+
label: string | null;
|
|
152
|
+
url: string;
|
|
153
|
+
}
|
|
154
|
+
export const RELEASE_LINK_SHAPE = shape<ReleaseLink>({
|
|
155
|
+
id: "string",
|
|
156
|
+
label: "string|null",
|
|
157
|
+
url: "string",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const RELEASE_FIELDS = `id name version commitSha url pipeline { id name type }
|
|
161
|
+
stage { id name type frozen } startDate targetDate startedAt completedAt canceledAt archivedAt`;
|
|
162
|
+
const DETAIL_FIELDS = `${RELEASE_FIELDS} description issueCount`;
|
|
163
|
+
const PIPELINE_FIELDS = `id name slugId type url isProduction autoGenerateReleaseNotesOnCompletion
|
|
164
|
+
rolloverIssuesOnCompletion includePathPatterns archivedAt`;
|
|
165
|
+
const STAGE_FIELDS = `id name type color position frozen pipeline { id name } archivedAt`;
|
|
166
|
+
const NOTE_FIELDS = `id title url pipeline { id name } releaseCount firstRelease { id name }
|
|
167
|
+
lastRelease { id name } generationStatus archivedAt`;
|
|
168
|
+
const PAGE = `pageInfo { hasNextPage endCursor }`;
|
|
169
|
+
|
|
170
|
+
/** Preserve the central error/exit contract and add actionable feature context. */
|
|
171
|
+
async function rawRequest(
|
|
172
|
+
client: LinearClient,
|
|
173
|
+
query: string,
|
|
174
|
+
variables: Record<string, unknown>,
|
|
175
|
+
): Promise<{ data: Record<string, any> }> {
|
|
176
|
+
try {
|
|
177
|
+
const result = await client.client.rawRequest<Record<string, any>, Record<string, unknown>>(
|
|
178
|
+
query,
|
|
179
|
+
variables,
|
|
180
|
+
);
|
|
181
|
+
if (!result.data) throw new CliError("The API returned no release data.", "api");
|
|
182
|
+
return { data: result.data };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const e = normalizeError(error);
|
|
185
|
+
if (e.code !== "feature_not_accessible") throw e;
|
|
186
|
+
throw new CliError(
|
|
187
|
+
e.message,
|
|
188
|
+
e.code,
|
|
189
|
+
e.detail,
|
|
190
|
+
"Releases require a Business or Enterprise workspace and permission to access the pipeline.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function request(client: LinearClient, query: string, variables: Record<string, unknown>) {
|
|
195
|
+
return withRetry(() => rawRequest(client, query, variables));
|
|
196
|
+
}
|
|
197
|
+
function transport(client: LinearClient) {
|
|
198
|
+
return {
|
|
199
|
+
client: {
|
|
200
|
+
rawRequest: (query: string, variables: Record<string, unknown>) =>
|
|
201
|
+
rawRequest(client, query, variables),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function listReleases(
|
|
207
|
+
client: LinearClient,
|
|
208
|
+
limit: number,
|
|
209
|
+
opts: {
|
|
210
|
+
pipeline?: string;
|
|
211
|
+
version?: string;
|
|
212
|
+
stageType?: string;
|
|
213
|
+
includeArchived?: boolean;
|
|
214
|
+
} = {},
|
|
215
|
+
): Promise<ReleaseRow[]> {
|
|
216
|
+
const filter: Record<string, unknown> = {};
|
|
217
|
+
if (opts.pipeline)
|
|
218
|
+
filter.pipeline = { id: { eq: (await getPipeline(client, opts.pipeline)).id } };
|
|
219
|
+
if (opts.version !== undefined) filter.version = { eq: opts.version };
|
|
220
|
+
if (opts.stageType) filter.stage = { type: { eq: opts.stageType } };
|
|
221
|
+
return collectRawQuery(
|
|
222
|
+
transport(client),
|
|
223
|
+
`query CliReleases($first: Int!, $after: String,
|
|
224
|
+
$filter: ReleaseFilter, $archived: Boolean) {
|
|
225
|
+
releases(first: $first, after: $after, filter: $filter, includeArchived: $archived) {
|
|
226
|
+
nodes { ${RELEASE_FIELDS} } ${PAGE} } }`,
|
|
227
|
+
{ filter, archived: opts.includeArchived ?? false },
|
|
228
|
+
"releases",
|
|
229
|
+
limit,
|
|
230
|
+
(n) => n as ReleaseRow,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function getRelease(client: LinearClient, id: string): Promise<ReleaseDetail> {
|
|
235
|
+
const r = await request(
|
|
236
|
+
client,
|
|
237
|
+
`query CliRelease($id: String!) { release(id: $id) { ${DETAIL_FIELDS} } }`,
|
|
238
|
+
{ id },
|
|
239
|
+
);
|
|
240
|
+
if (!r.data?.release) throw notFound(`No release matching '${id}'.`);
|
|
241
|
+
return r.data.release as ReleaseDetail;
|
|
242
|
+
}
|
|
243
|
+
export async function listPipelines(
|
|
244
|
+
client: LinearClient,
|
|
245
|
+
limit: number,
|
|
246
|
+
includeArchived = false,
|
|
247
|
+
): Promise<PipelineRow[]> {
|
|
248
|
+
return collectRawQuery(
|
|
249
|
+
transport(client),
|
|
250
|
+
`query CliReleasePipelines($first: Int!, $after: String, $archived: Boolean) {
|
|
251
|
+
releasePipelines(first: $first, after: $after, includeArchived: $archived) {
|
|
252
|
+
nodes { ${PIPELINE_FIELDS} } ${PAGE} } }`,
|
|
253
|
+
{ archived: includeArchived },
|
|
254
|
+
"releasePipelines",
|
|
255
|
+
limit,
|
|
256
|
+
(n) => n as PipelineRow,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
/** The API accepts UUIDs and slugs; do not guess from potentially duplicate names. */
|
|
260
|
+
export async function getPipeline(client: LinearClient, id: string): Promise<PipelineRow> {
|
|
261
|
+
const r = await request(
|
|
262
|
+
client,
|
|
263
|
+
`query CliReleasePipeline($id: String!) {
|
|
264
|
+
releasePipeline(id: $id) { ${PIPELINE_FIELDS} } }`,
|
|
265
|
+
{ id },
|
|
266
|
+
);
|
|
267
|
+
if (!r.data?.releasePipeline) throw notFound(`No release pipeline matching '${id}'.`);
|
|
268
|
+
return r.data.releasePipeline as PipelineRow;
|
|
269
|
+
}
|
|
270
|
+
export async function listStages(
|
|
271
|
+
client: LinearClient,
|
|
272
|
+
pipeline: string,
|
|
273
|
+
limit: number,
|
|
274
|
+
includeArchived = false,
|
|
275
|
+
): Promise<StageRow[]> {
|
|
276
|
+
return collectRawQuery(
|
|
277
|
+
transport(client),
|
|
278
|
+
`query CliReleaseStages($id: String!, $first: Int!, $after: String, $archived: Boolean) {
|
|
279
|
+
releasePipeline(id: $id) { stages(first: $first, after: $after, includeArchived: $archived) {
|
|
280
|
+
nodes { ${STAGE_FIELDS} } ${PAGE} } } }`,
|
|
281
|
+
{ id: pipeline, archived: includeArchived },
|
|
282
|
+
"releasePipeline.stages",
|
|
283
|
+
limit,
|
|
284
|
+
(n) => n as StageRow,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
export async function getStage(client: LinearClient, id: string): Promise<StageRow> {
|
|
288
|
+
const r = await request(
|
|
289
|
+
client,
|
|
290
|
+
`query CliReleaseStage($id: String!) { releaseStage(id: $id) { ${STAGE_FIELDS} } }`,
|
|
291
|
+
{ id },
|
|
292
|
+
);
|
|
293
|
+
if (!r.data?.releaseStage) throw notFound(`No release stage matching '${id}'.`);
|
|
294
|
+
return r.data.releaseStage as StageRow;
|
|
295
|
+
}
|
|
296
|
+
export async function listNotes(
|
|
297
|
+
client: LinearClient,
|
|
298
|
+
limit: number,
|
|
299
|
+
opts: {
|
|
300
|
+
pipeline?: string;
|
|
301
|
+
release?: string;
|
|
302
|
+
includeArchived?: boolean;
|
|
303
|
+
} = {},
|
|
304
|
+
): Promise<NoteRow[]> {
|
|
305
|
+
const filter: Record<string, unknown> = {};
|
|
306
|
+
if (opts.pipeline)
|
|
307
|
+
filter.pipeline = { id: { eq: (await getPipeline(client, opts.pipeline)).id } };
|
|
308
|
+
if (opts.release) filter.release = { id: { eq: (await getRelease(client, opts.release)).id } };
|
|
309
|
+
return collectRawQuery(
|
|
310
|
+
transport(client),
|
|
311
|
+
`query CliReleaseNotes($first: Int!, $after: String,
|
|
312
|
+
$filter: ReleaseNoteFilter, $archived: Boolean) {
|
|
313
|
+
releaseNotes(first: $first, after: $after, filter: $filter, includeArchived: $archived) {
|
|
314
|
+
nodes { ${NOTE_FIELDS} } ${PAGE} } }`,
|
|
315
|
+
{ filter, archived: opts.includeArchived ?? false },
|
|
316
|
+
"releaseNotes",
|
|
317
|
+
limit,
|
|
318
|
+
(n) => n as NoteRow,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
export async function getNote(client: LinearClient, id: string): Promise<NoteDetail> {
|
|
322
|
+
const r = await request(
|
|
323
|
+
client,
|
|
324
|
+
`query CliReleaseNote($id: String!) { releaseNote(id: $id) {
|
|
325
|
+
${NOTE_FIELDS} documentContent { content } } }`,
|
|
326
|
+
{ id },
|
|
327
|
+
);
|
|
328
|
+
const n = r.data?.releaseNote;
|
|
329
|
+
if (!n) throw notFound(`No release note matching '${id}'.`);
|
|
330
|
+
const { documentContent, ...row } = n;
|
|
331
|
+
return { ...row, content: documentContent?.content ?? null } as NoteDetail;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
type Resource = "release" | "releasePipeline" | "releaseStage" | "releaseNote";
|
|
335
|
+
const RESOURCE_FIELDS = {
|
|
336
|
+
release: DETAIL_FIELDS,
|
|
337
|
+
releasePipeline: PIPELINE_FIELDS,
|
|
338
|
+
releaseStage: STAGE_FIELDS,
|
|
339
|
+
releaseNote: `${NOTE_FIELDS} documentContent { content }`,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
async function mutate(
|
|
343
|
+
client: LinearClient,
|
|
344
|
+
resource: Resource,
|
|
345
|
+
verb: "Create" | "Update" | "Sync" | "Complete" | "UpdateByPipeline",
|
|
346
|
+
input: object,
|
|
347
|
+
id?: string,
|
|
348
|
+
): Promise<any> {
|
|
349
|
+
const name = `${resource}${verb}`;
|
|
350
|
+
const inputType = name[0]!.toUpperCase() + name.slice(1) + "Input!";
|
|
351
|
+
const r = await request(
|
|
352
|
+
client,
|
|
353
|
+
`mutation Cli${name}($input: ${inputType}${id === undefined ? "" : ", $id: String!"}) {
|
|
354
|
+
${name}(input: $input${id === undefined ? "" : ", id: $id"}) { success ${resource} { ${RESOURCE_FIELDS[resource]} } } }`,
|
|
355
|
+
{ input, ...(id === undefined ? {} : { id }) },
|
|
356
|
+
);
|
|
357
|
+
const entity = await unwrapMutation(r.data?.[name], resource, name);
|
|
358
|
+
if (resource !== "releaseNote") return entity;
|
|
359
|
+
const { documentContent, ...row } = entity;
|
|
360
|
+
return { ...row, content: documentContent?.content ?? null };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface ReleaseInput {
|
|
364
|
+
name?: string;
|
|
365
|
+
description?: string | null;
|
|
366
|
+
version?: string | null;
|
|
367
|
+
commitSha?: string | null;
|
|
368
|
+
stageId?: string;
|
|
369
|
+
startDate?: string | null;
|
|
370
|
+
targetDate?: string | null;
|
|
371
|
+
}
|
|
372
|
+
function nonempty(value: string, label: string): string {
|
|
373
|
+
if (!value.trim()) throw usageError(`${label} must not be empty.`);
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
function changed(input: object): void {
|
|
377
|
+
if (!Object.values(input).some((v) => v !== undefined))
|
|
378
|
+
throw usageError("Nothing to update; pass at least one field.");
|
|
379
|
+
}
|
|
380
|
+
function validateReleaseInput(input: ReleaseInput): void {
|
|
381
|
+
if (input.name !== undefined) nonempty(input.name, "--name");
|
|
382
|
+
if (typeof input.version === "string") nonempty(input.version, "--release-version");
|
|
383
|
+
if (
|
|
384
|
+
typeof input.commitSha === "string" &&
|
|
385
|
+
!/^[a-f\d]{40}$|^[a-f\d]{64}$/i.test(input.commitSha)
|
|
386
|
+
) {
|
|
387
|
+
throw usageError("--commit-sha must be a full 40- or 64-character Git commit SHA.");
|
|
388
|
+
}
|
|
389
|
+
for (const key of ["startDate", "targetDate"] as const) {
|
|
390
|
+
const date = input[key];
|
|
391
|
+
if (
|
|
392
|
+
date != null &&
|
|
393
|
+
(!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
|
394
|
+
!Number.isFinite(Date.parse(date)) ||
|
|
395
|
+
new Date(date).toISOString().slice(0, 10) !== date)
|
|
396
|
+
) {
|
|
397
|
+
throw usageError(`${key} must be a real date in YYYY-MM-DD format.`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function checkStage(client: LinearClient, stageId: string | undefined, pipelineId: string) {
|
|
402
|
+
if (stageId && (await getStage(client, stageId)).pipeline.id !== pipelineId)
|
|
403
|
+
throw usageError("The stage belongs to a different release pipeline.");
|
|
404
|
+
}
|
|
405
|
+
export async function createRelease(
|
|
406
|
+
client: LinearClient,
|
|
407
|
+
pipeline: string,
|
|
408
|
+
input: ReleaseInput & { name: string },
|
|
409
|
+
): Promise<ReleaseDetail> {
|
|
410
|
+
validateReleaseInput(input);
|
|
411
|
+
const p = await getPipeline(client, pipeline);
|
|
412
|
+
await checkStage(client, input.stageId, p.id);
|
|
413
|
+
return mutate(client, "release", "Create", { ...input, pipelineId: p.id });
|
|
414
|
+
}
|
|
415
|
+
export async function updateRelease(
|
|
416
|
+
client: LinearClient,
|
|
417
|
+
id: string,
|
|
418
|
+
input: ReleaseInput,
|
|
419
|
+
): Promise<ReleaseDetail> {
|
|
420
|
+
changed(input);
|
|
421
|
+
validateReleaseInput(input);
|
|
422
|
+
const release = await getRelease(client, id);
|
|
423
|
+
await checkStage(client, input.stageId, release.pipeline.id);
|
|
424
|
+
return mutate(client, "release", "Update", input, release.id);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export interface LinkInput {
|
|
428
|
+
url: string;
|
|
429
|
+
label?: string;
|
|
430
|
+
}
|
|
431
|
+
export interface LifecycleInput {
|
|
432
|
+
name?: string;
|
|
433
|
+
description?: string | null;
|
|
434
|
+
links?: LinkInput[];
|
|
435
|
+
}
|
|
436
|
+
export interface SyncInput extends LifecycleInput {
|
|
437
|
+
commitSha: string;
|
|
438
|
+
issueReferences?: { identifier: string; commitSha: string }[];
|
|
439
|
+
revertedIssueReferences?: { identifier: string; commitSha: string }[];
|
|
440
|
+
}
|
|
441
|
+
/** Never silently select the latest release in an overlapping scheduled pipeline. */
|
|
442
|
+
export async function syncRelease(
|
|
443
|
+
client: LinearClient,
|
|
444
|
+
pipeline: string,
|
|
445
|
+
version: string,
|
|
446
|
+
input: SyncInput,
|
|
447
|
+
): Promise<ReleaseDetail> {
|
|
448
|
+
nonempty(version, "--release-version");
|
|
449
|
+
validateReleaseInput({ ...input, version });
|
|
450
|
+
const p = await getPipeline(client, pipeline);
|
|
451
|
+
return mutate(client, "release", "Sync", { ...input, pipelineId: p.id, version });
|
|
452
|
+
}
|
|
453
|
+
export async function completeRelease(
|
|
454
|
+
client: LinearClient,
|
|
455
|
+
pipeline: string,
|
|
456
|
+
version: string,
|
|
457
|
+
input: LifecycleInput & { commitSha?: string } = {},
|
|
458
|
+
): Promise<ReleaseDetail> {
|
|
459
|
+
nonempty(version, "--release-version");
|
|
460
|
+
validateReleaseInput({ ...input, version });
|
|
461
|
+
const p = await getPipeline(client, pipeline);
|
|
462
|
+
if (p.type !== "scheduled")
|
|
463
|
+
throw usageError("Continuous pipelines complete releases during sync; use release sync.");
|
|
464
|
+
return mutate(client, "release", "Complete", { ...input, pipelineId: p.id, version });
|
|
465
|
+
}
|
|
466
|
+
export async function updateReleaseVersion(
|
|
467
|
+
client: LinearClient,
|
|
468
|
+
pipeline: string,
|
|
469
|
+
version: string,
|
|
470
|
+
input: LifecycleInput & { stage?: string },
|
|
471
|
+
): Promise<ReleaseDetail> {
|
|
472
|
+
nonempty(version, "--release-version");
|
|
473
|
+
changed(input);
|
|
474
|
+
validateReleaseInput(input);
|
|
475
|
+
const p = await getPipeline(client, pipeline);
|
|
476
|
+
return mutate(client, "release", "UpdateByPipeline", { ...input, pipelineId: p.id, version });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export interface PipelineInput {
|
|
480
|
+
name?: string;
|
|
481
|
+
type?: string;
|
|
482
|
+
isProduction?: boolean;
|
|
483
|
+
autoGenerateReleaseNotesOnCompletion?: boolean;
|
|
484
|
+
rolloverIssuesOnCompletion?: boolean;
|
|
485
|
+
includePathPatterns?: string[];
|
|
486
|
+
teamIds?: string[];
|
|
487
|
+
}
|
|
488
|
+
function validatePipelineInput(input: PipelineInput): void {
|
|
489
|
+
if (input.name !== undefined) nonempty(input.name, "--name");
|
|
490
|
+
if (input.type !== undefined && !["scheduled", "continuous"].includes(input.type))
|
|
491
|
+
throw usageError("Pipeline type must be scheduled or continuous.");
|
|
492
|
+
}
|
|
493
|
+
export async function createPipeline(
|
|
494
|
+
client: LinearClient,
|
|
495
|
+
input: PipelineInput & { name: string; type: string },
|
|
496
|
+
): Promise<PipelineRow> {
|
|
497
|
+
validatePipelineInput(input);
|
|
498
|
+
return mutate(client, "releasePipeline", "Create", input);
|
|
499
|
+
}
|
|
500
|
+
export async function updatePipeline(
|
|
501
|
+
client: LinearClient,
|
|
502
|
+
id: string,
|
|
503
|
+
input: PipelineInput,
|
|
504
|
+
): Promise<PipelineRow> {
|
|
505
|
+
changed(input);
|
|
506
|
+
validatePipelineInput(input);
|
|
507
|
+
return mutate(client, "releasePipeline", "Update", input, (await getPipeline(client, id)).id);
|
|
508
|
+
}
|
|
509
|
+
export interface StageInput {
|
|
510
|
+
name?: string;
|
|
511
|
+
color?: string;
|
|
512
|
+
position?: number;
|
|
513
|
+
frozen?: boolean;
|
|
514
|
+
}
|
|
515
|
+
function validateStageInput(input: StageInput): void {
|
|
516
|
+
if (input.name !== undefined) nonempty(input.name, "--name");
|
|
517
|
+
if (input.color !== undefined && !/^#[\da-f]{6}$/i.test(input.color))
|
|
518
|
+
throw usageError("--color must be a six-digit HEX color (e.g. '#5e6ad2').");
|
|
519
|
+
if (input.position !== undefined && !Number.isFinite(input.position))
|
|
520
|
+
throw usageError("--position must be a finite number.");
|
|
521
|
+
}
|
|
522
|
+
export async function createStage(
|
|
523
|
+
client: LinearClient,
|
|
524
|
+
pipeline: string,
|
|
525
|
+
input: StageInput & { name: string; color: string; position: number; type: string },
|
|
526
|
+
): Promise<StageRow> {
|
|
527
|
+
validateStageInput(input);
|
|
528
|
+
if (!["planned", "started", "completed", "canceled"].includes(input.type))
|
|
529
|
+
throw usageError("Invalid stage type.");
|
|
530
|
+
if (input.frozen && input.type !== "started")
|
|
531
|
+
throw usageError("Only started stages can be frozen.");
|
|
532
|
+
return mutate(client, "releaseStage", "Create", {
|
|
533
|
+
...input,
|
|
534
|
+
pipelineId: (await getPipeline(client, pipeline)).id,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
export async function updateStage(
|
|
538
|
+
client: LinearClient,
|
|
539
|
+
id: string,
|
|
540
|
+
input: StageInput,
|
|
541
|
+
): Promise<StageRow> {
|
|
542
|
+
changed(input);
|
|
543
|
+
validateStageInput(input);
|
|
544
|
+
const stage = await getStage(client, id);
|
|
545
|
+
if (input.frozen && stage.type !== "started")
|
|
546
|
+
throw usageError("Only started stages can be frozen.");
|
|
547
|
+
return mutate(client, "releaseStage", "Update", input, stage.id);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export interface NoteInput {
|
|
551
|
+
title?: string;
|
|
552
|
+
content?: string;
|
|
553
|
+
releaseIds?: string[];
|
|
554
|
+
rangeFromReleaseId?: string;
|
|
555
|
+
rangeToReleaseId?: string;
|
|
556
|
+
}
|
|
557
|
+
/** Validate scope before command handlers obtain an authenticated client. */
|
|
558
|
+
export function validateNoteScope(input: NoteInput, required: boolean): void {
|
|
559
|
+
const explicit = input.releaseIds !== undefined;
|
|
560
|
+
const range = input.rangeFromReleaseId !== undefined || input.rangeToReleaseId !== undefined;
|
|
561
|
+
if (explicit && range)
|
|
562
|
+
throw usageError("Use --release or --from-release/--to-release, not both.");
|
|
563
|
+
if (explicit && !input.releaseIds!.length)
|
|
564
|
+
throw usageError("At least one --release is required.");
|
|
565
|
+
if (range && (!input.rangeFromReleaseId || !input.rangeToReleaseId))
|
|
566
|
+
throw usageError("--from-release and --to-release must be provided together.");
|
|
567
|
+
if (required && !explicit && !range)
|
|
568
|
+
throw usageError("Provide --release or both --from-release and --to-release.");
|
|
569
|
+
}
|
|
570
|
+
async function resolveNoteScope(
|
|
571
|
+
client: LinearClient,
|
|
572
|
+
pipelineId: string,
|
|
573
|
+
input: NoteInput,
|
|
574
|
+
): Promise<NoteInput> {
|
|
575
|
+
const ids =
|
|
576
|
+
input.releaseIds ??
|
|
577
|
+
[input.rangeFromReleaseId, input.rangeToReleaseId].filter(
|
|
578
|
+
(id): id is string => id !== undefined,
|
|
579
|
+
);
|
|
580
|
+
const releases = await Promise.all(ids.map((id) => getRelease(client, id)));
|
|
581
|
+
if (releases.some((r) => r.pipeline.id !== pipelineId))
|
|
582
|
+
throw usageError("Every covered release must belong to the note's pipeline.");
|
|
583
|
+
if (input.releaseIds) return { ...input, releaseIds: [...new Set(releases.map((r) => r.id))] };
|
|
584
|
+
return releases.length
|
|
585
|
+
? { ...input, rangeFromReleaseId: releases[0]!.id, rangeToReleaseId: releases[1]!.id }
|
|
586
|
+
: input;
|
|
587
|
+
}
|
|
588
|
+
export async function createNote(
|
|
589
|
+
client: LinearClient,
|
|
590
|
+
pipeline: string,
|
|
591
|
+
input: NoteInput,
|
|
592
|
+
): Promise<NoteDetail> {
|
|
593
|
+
validateNoteScope(input, true);
|
|
594
|
+
const p = await getPipeline(client, pipeline);
|
|
595
|
+
return mutate(client, "releaseNote", "Create", {
|
|
596
|
+
...(await resolveNoteScope(client, p.id, input)),
|
|
597
|
+
pipelineId: p.id,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
export async function updateNote(
|
|
601
|
+
client: LinearClient,
|
|
602
|
+
id: string,
|
|
603
|
+
input: NoteInput,
|
|
604
|
+
): Promise<NoteDetail> {
|
|
605
|
+
changed(input);
|
|
606
|
+
validateNoteScope(input, false);
|
|
607
|
+
const note = await getNote(client, id);
|
|
608
|
+
return mutate(
|
|
609
|
+
client,
|
|
610
|
+
"releaseNote",
|
|
611
|
+
"Update",
|
|
612
|
+
await resolveNoteScope(client, note.pipeline.id, input),
|
|
613
|
+
note.id,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function listReleaseIssues(
|
|
618
|
+
client: LinearClient,
|
|
619
|
+
id: string,
|
|
620
|
+
limit: number,
|
|
621
|
+
): Promise<ReleaseIssue[]> {
|
|
622
|
+
return collectRawQuery(
|
|
623
|
+
transport(client),
|
|
624
|
+
`query CliReleaseIssues($id: String!, $first: Int!, $after: String) {
|
|
625
|
+
release(id: $id) { issues(first: $first, after: $after) { nodes { id identifier title url } ${PAGE} } } }`,
|
|
626
|
+
{ id },
|
|
627
|
+
"release.issues",
|
|
628
|
+
limit,
|
|
629
|
+
(n) => n as ReleaseIssue,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
export async function listReleaseLinks(
|
|
633
|
+
client: LinearClient,
|
|
634
|
+
id: string,
|
|
635
|
+
limit: number,
|
|
636
|
+
): Promise<ReleaseLink[]> {
|
|
637
|
+
return collectRawQuery(
|
|
638
|
+
transport(client),
|
|
639
|
+
`query CliReleaseLinks($id: String!, $first: Int!, $after: String) {
|
|
640
|
+
release(id: $id) { links(first: $first, after: $after) { nodes { id label url } ${PAGE} } } }`,
|
|
641
|
+
{ id },
|
|
642
|
+
"release.links",
|
|
643
|
+
limit,
|
|
644
|
+
(n) => n as ReleaseLink,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
export async function addIssue(client: LinearClient, id: string, issue: string) {
|
|
648
|
+
const release = await getRelease(client, id);
|
|
649
|
+
const r = await request(
|
|
650
|
+
client,
|
|
651
|
+
`mutation CliIssueToReleaseCreate($input: IssueToReleaseCreateInput!) {
|
|
652
|
+
issueToReleaseCreate(input: $input) { success issueToRelease { id issue { id identifier } release { id } } } }`,
|
|
653
|
+
{ input: { releaseId: release.id, issueId: issue } },
|
|
654
|
+
);
|
|
655
|
+
const link = await unwrapMutation(
|
|
656
|
+
r.data?.issueToReleaseCreate,
|
|
657
|
+
"issueToRelease",
|
|
658
|
+
"Add issue to release",
|
|
659
|
+
);
|
|
660
|
+
return {
|
|
661
|
+
id: link.id as string,
|
|
662
|
+
releaseId: link.release.id as string,
|
|
663
|
+
issueId: link.issue.id as string,
|
|
664
|
+
identifier: link.issue.identifier as string,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
export async function removeIssue(client: LinearClient, id: string, issue: string) {
|
|
668
|
+
const release = await getRelease(client, id);
|
|
669
|
+
const r = await request(
|
|
670
|
+
client,
|
|
671
|
+
`mutation CliIssueToReleaseDelete($releaseId: String!, $issueId: String!) {
|
|
672
|
+
issueToReleaseDeleteByIssueAndRelease(releaseId: $releaseId, issueId: $issueId) { success } }`,
|
|
673
|
+
{ releaseId: release.id, issueId: issue },
|
|
674
|
+
);
|
|
675
|
+
await assertMutation(r.data?.issueToReleaseDeleteByIssueAndRelease, "Remove issue from release");
|
|
676
|
+
return { releaseId: release.id, issue, removed: true };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export async function deleteNote(
|
|
680
|
+
client: LinearClient,
|
|
681
|
+
id: string,
|
|
682
|
+
): Promise<{ id: string; deleted: boolean }> {
|
|
683
|
+
const note = await getNote(client, id);
|
|
684
|
+
const r = await request(
|
|
685
|
+
client,
|
|
686
|
+
`mutation CliReleaseNoteDelete($id: String!) { releaseNoteDelete(id: $id) { success } }`,
|
|
687
|
+
{ id: note.id },
|
|
688
|
+
);
|
|
689
|
+
await assertMutation(r.data?.releaseNoteDelete, "Release note deletion");
|
|
690
|
+
return { id: note.id, deleted: true };
|
|
691
|
+
}
|