linear-sdk-cli 0.1.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/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +807 -0
- package/COVERAGE.md +495 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +220 -0
- package/README.md +524 -0
- package/package.json +87 -0
- package/skills/linear-sdk-cli/SKILL.md +534 -0
- package/skills/linear-sdk-cli/references/api.md +27 -0
- package/skills/linear-sdk-cli/references/attachment.md +82 -0
- package/skills/linear-sdk-cli/references/auth.md +174 -0
- package/skills/linear-sdk-cli/references/commands.md +37 -0
- package/skills/linear-sdk-cli/references/comment.md +154 -0
- package/skills/linear-sdk-cli/references/completion.md +17 -0
- package/skills/linear-sdk-cli/references/config.md +99 -0
- package/skills/linear-sdk-cli/references/cycle.md +131 -0
- package/skills/linear-sdk-cli/references/document.md +180 -0
- package/skills/linear-sdk-cli/references/favorite.md +78 -0
- package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
- package/skills/linear-sdk-cli/references/initiative.md +256 -0
- package/skills/linear-sdk-cli/references/issue.md +928 -0
- package/skills/linear-sdk-cli/references/label.md +112 -0
- package/skills/linear-sdk-cli/references/milestone.md +139 -0
- package/skills/linear-sdk-cli/references/notification.md +123 -0
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/organization.md +99 -0
- package/skills/linear-sdk-cli/references/project-update.md +67 -0
- package/skills/linear-sdk-cli/references/project.md +234 -0
- package/skills/linear-sdk-cli/references/roadmap.md +150 -0
- package/skills/linear-sdk-cli/references/schema.md +21 -0
- package/skills/linear-sdk-cli/references/state.md +61 -0
- package/skills/linear-sdk-cli/references/team.md +227 -0
- package/skills/linear-sdk-cli/references/user.md +103 -0
- package/skills/linear-sdk-cli/references/webhook.md +151 -0
- package/skills/linear-sdk-cli/references/whoami.md +26 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
- package/src/bin/linear.ts +97 -0
- package/src/cli.ts +232 -0
- package/src/client.ts +172 -0
- package/src/commands/agent-session.ts +121 -0
- package/src/commands/api.ts +317 -0
- package/src/commands/attach.ts +90 -0
- package/src/commands/attachment.ts +74 -0
- package/src/commands/comment.ts +225 -0
- package/src/commands/completion.ts +69 -0
- package/src/commands/cycle.ts +125 -0
- package/src/commands/discover.ts +170 -0
- package/src/commands/document.ts +211 -0
- package/src/commands/favorite.ts +72 -0
- package/src/commands/initiative-update.ts +47 -0
- package/src/commands/initiative.ts +261 -0
- package/src/commands/issue.ts +1263 -0
- package/src/commands/label.ts +119 -0
- package/src/commands/meta.ts +534 -0
- package/src/commands/milestone.ts +163 -0
- package/src/commands/notification.ts +110 -0
- package/src/commands/open.ts +84 -0
- package/src/commands/organization.ts +79 -0
- package/src/commands/project-update.ts +47 -0
- package/src/commands/project.ts +339 -0
- package/src/commands/roadmap.ts +131 -0
- package/src/commands/state.ts +58 -0
- package/src/commands/team.ts +243 -0
- package/src/commands/user.ts +107 -0
- package/src/commands/webhook.ts +156 -0
- package/src/config.ts +1236 -0
- package/src/context.ts +117 -0
- package/src/git.ts +174 -0
- package/src/index.ts +10 -0
- package/src/lib/action.ts +26 -0
- package/src/lib/body.ts +172 -0
- package/src/lib/errors.ts +195 -0
- package/src/lib/introspect.ts +150 -0
- package/src/lib/keyring.ts +270 -0
- package/src/lib/mutation.ts +53 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +640 -0
- package/src/lib/output-shapes.ts +562 -0
- package/src/lib/pagination.ts +145 -0
- package/src/lib/prompt.ts +91 -0
- package/src/lib/resolve.ts +596 -0
- package/src/lib/shape.ts +273 -0
- package/src/lib/status-update.ts +91 -0
- package/src/lib/upload.ts +329 -0
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +238 -0
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +76 -0
- package/src/output/table.ts +198 -0
- package/src/services/agent-session.ts +289 -0
- package/src/services/attachment.ts +198 -0
- package/src/services/comment.ts +262 -0
- package/src/services/cycle.ts +224 -0
- package/src/services/document.ts +357 -0
- package/src/services/favorite.ts +147 -0
- package/src/services/initiative-update.ts +59 -0
- package/src/services/initiative.ts +449 -0
- package/src/services/issue.ts +1153 -0
- package/src/services/label.ts +193 -0
- package/src/services/milestone.ts +262 -0
- package/src/services/notification.ts +190 -0
- package/src/services/organization.ts +144 -0
- package/src/services/project-update.ts +36 -0
- package/src/services/project.ts +494 -0
- package/src/services/roadmap.ts +177 -0
- package/src/services/state.ts +127 -0
- package/src/services/team.ts +386 -0
- package/src/services/user.ts +137 -0
- package/src/services/webhook.ts +203 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Label service: all SDK access for issue labels lives here so commands stay thin.
|
|
3
|
+
*
|
|
4
|
+
* The list uses a tailored GraphQL query (one round-trip, no N+1 on the team
|
|
5
|
+
* name) and may be scoped to a single team; single mutations use the typed SDK
|
|
6
|
+
* client and unwrap the `{ success, issueLabel }` payload. Labels can be
|
|
7
|
+
* workspace-level (team-less) or scoped to one team.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { LinearClient } from "@linear/sdk";
|
|
11
|
+
import { withRetry } from "../client.js";
|
|
12
|
+
import { shape } from "../lib/shape.js";
|
|
13
|
+
import { collectRawQuery } from "../lib/pagination.js";
|
|
14
|
+
import { usageError, notFound, ambiguous } from "../lib/errors.js";
|
|
15
|
+
import { assertMutation, unwrapMutation } from "../lib/mutation.js";
|
|
16
|
+
import { resolveTeam, resolveLabelIds, isUuid } from "../lib/resolve.js";
|
|
17
|
+
|
|
18
|
+
export interface LabelRow {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
color: string;
|
|
22
|
+
isGroup: boolean;
|
|
23
|
+
team: { key: string; name: string } | null;
|
|
24
|
+
parent: { name: string } | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
|
|
28
|
+
export const LABEL_ROW_SHAPE = shape<LabelRow>({
|
|
29
|
+
id: "string",
|
|
30
|
+
name: "string",
|
|
31
|
+
color: "string",
|
|
32
|
+
isGroup: "boolean",
|
|
33
|
+
team: { nullable: { key: "string", name: "string" } },
|
|
34
|
+
parent: { nullable: { name: "string" } },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const LIST_QUERY = `
|
|
38
|
+
query CliIssueLabels($filter: IssueLabelFilter, $first: Int!, $after: String) {
|
|
39
|
+
issueLabels(filter: $filter, first: $first, after: $after) {
|
|
40
|
+
nodes {
|
|
41
|
+
id name color isGroup
|
|
42
|
+
team { key name }
|
|
43
|
+
parent { name }
|
|
44
|
+
}
|
|
45
|
+
pageInfo { hasNextPage endCursor }
|
|
46
|
+
}
|
|
47
|
+
}`;
|
|
48
|
+
|
|
49
|
+
export interface ListOptions {
|
|
50
|
+
/** Ignore the team argument and the configured default; list the whole workspace. */
|
|
51
|
+
allTeams?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* List labels, scoped to the labels an issue in one team can carry.
|
|
56
|
+
*
|
|
57
|
+
* When `teamInput` (or the default team) resolves to a team, the result is that
|
|
58
|
+
* team's labels **plus the workspace-level (team-less) ones** — the same set
|
|
59
|
+
* `resolveLabelIds` accepts for that team, and the set Linear offers in the
|
|
60
|
+
* label picker. It used to filter on `team.key` alone, which silently dropped
|
|
61
|
+
* every workspace label whenever a default team was configured: `label list`
|
|
62
|
+
* showed 5 of the 8 labels valid on the team's issues, and every "no label
|
|
63
|
+
* matching X — run 'linear label list'" error pointed at that incomplete list.
|
|
64
|
+
*
|
|
65
|
+
* `allTeams` (or no team in scope at all) lists every label in the workspace.
|
|
66
|
+
*/
|
|
67
|
+
export async function listLabels(
|
|
68
|
+
client: LinearClient,
|
|
69
|
+
teamInput: string | undefined,
|
|
70
|
+
limit: number,
|
|
71
|
+
defaultTeamKey: string | undefined,
|
|
72
|
+
opts: ListOptions = {},
|
|
73
|
+
): Promise<LabelRow[]> {
|
|
74
|
+
const teamKey = opts.allTeams ? undefined : (teamInput ?? defaultTeamKey);
|
|
75
|
+
let filter: Record<string, any> | undefined;
|
|
76
|
+
if (teamKey) {
|
|
77
|
+
const team = await resolveTeam(client, teamKey, undefined);
|
|
78
|
+
filter = { or: [{ team: { key: { eq: team.key } } }, { team: { null: true } }] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return collectRawQuery<LabelRow>(
|
|
82
|
+
client as any,
|
|
83
|
+
LIST_QUERY,
|
|
84
|
+
{ filter },
|
|
85
|
+
"issueLabels",
|
|
86
|
+
limit,
|
|
87
|
+
toLabelRow,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Map a tailored-query label node to a display row. */
|
|
92
|
+
function toLabelRow(n: any): LabelRow {
|
|
93
|
+
return {
|
|
94
|
+
id: n.id,
|
|
95
|
+
name: n.name,
|
|
96
|
+
color: n.color,
|
|
97
|
+
isGroup: !!n.isGroup,
|
|
98
|
+
team: n.team ?? null,
|
|
99
|
+
parent: n.parent ?? null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface CreateOptions {
|
|
104
|
+
name: string;
|
|
105
|
+
color?: string;
|
|
106
|
+
description?: string;
|
|
107
|
+
team?: string;
|
|
108
|
+
parent?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build an IssueLabelCreateInput, resolving every human reference to an id. A
|
|
113
|
+
* team is optional: without one the label is workspace-level. A parent label is
|
|
114
|
+
* resolved within the same team scope when a team is known.
|
|
115
|
+
*/
|
|
116
|
+
export async function createLabel(
|
|
117
|
+
client: LinearClient,
|
|
118
|
+
opts: CreateOptions,
|
|
119
|
+
defaultTeamKey: string | undefined,
|
|
120
|
+
) {
|
|
121
|
+
const input: Record<string, any> = { name: opts.name };
|
|
122
|
+
if (opts.color !== undefined) input.color = opts.color;
|
|
123
|
+
if (opts.description !== undefined) input.description = opts.description;
|
|
124
|
+
|
|
125
|
+
// A team is optional; only resolve it when the user asked for one (an explicit
|
|
126
|
+
// --team), so that omitting it yields a workspace-level label.
|
|
127
|
+
let teamId: string | undefined;
|
|
128
|
+
if (opts.team !== undefined) {
|
|
129
|
+
teamId = (await resolveTeam(client, opts.team, defaultTeamKey)).id;
|
|
130
|
+
input.teamId = teamId;
|
|
131
|
+
}
|
|
132
|
+
if (opts.parent) {
|
|
133
|
+
const [parentId] = await resolveLabelIds(client, [opts.parent], teamId);
|
|
134
|
+
input.parentId = parentId;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return unwrapMutation(
|
|
138
|
+
withRetry(() => client.createIssueLabel(input as any)),
|
|
139
|
+
"issueLabel",
|
|
140
|
+
"Label creation",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface UpdateOptions {
|
|
145
|
+
name?: string;
|
|
146
|
+
color?: string;
|
|
147
|
+
description?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function updateLabel(client: LinearClient, idArg: string, opts: UpdateOptions) {
|
|
151
|
+
const id = await resolveLabel(client, idArg);
|
|
152
|
+
const input: Record<string, any> = {};
|
|
153
|
+
if (opts.name !== undefined) input.name = opts.name;
|
|
154
|
+
if (opts.color !== undefined) input.color = opts.color;
|
|
155
|
+
if (opts.description !== undefined) input.description = opts.description;
|
|
156
|
+
|
|
157
|
+
if (Object.keys(input).length === 0)
|
|
158
|
+
throw usageError("Nothing to update; pass at least one of --name, --color, --description.");
|
|
159
|
+
|
|
160
|
+
return unwrapMutation(
|
|
161
|
+
withRetry(() => client.updateIssueLabel(id, input as any)),
|
|
162
|
+
"issueLabel",
|
|
163
|
+
"Label update",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function deleteLabel(client: LinearClient, idArg: string) {
|
|
168
|
+
const id = await resolveLabel(client, idArg);
|
|
169
|
+
// Fetch the label first so we can report its name after deletion.
|
|
170
|
+
const label = await withRetry(() => client.issueLabel(id));
|
|
171
|
+
await assertMutation(
|
|
172
|
+
withRetry(() => client.deleteIssueLabel(id)),
|
|
173
|
+
"Label deletion",
|
|
174
|
+
);
|
|
175
|
+
return label;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve a single label reference (UUID or name) to its id. A UUID passes
|
|
180
|
+
* through; a name is matched workspace-wide and must be unambiguous.
|
|
181
|
+
*/
|
|
182
|
+
async function resolveLabel(client: LinearClient, idArg: string): Promise<string> {
|
|
183
|
+
if (isUuid(idArg)) return idArg;
|
|
184
|
+
const labels = await withRetry(() =>
|
|
185
|
+
client.issueLabels({ filter: { name: { eqIgnoreCase: idArg } } as any, first: 50 }),
|
|
186
|
+
);
|
|
187
|
+
if (labels.nodes.length === 0) throw notFound(`No label matching '${idArg}'.`);
|
|
188
|
+
const exact = labels.nodes.filter((l) => l.name === idArg);
|
|
189
|
+
const finalists = exact.length ? exact : labels.nodes;
|
|
190
|
+
if (finalists.length > 1)
|
|
191
|
+
throw ambiguous(`Multiple labels named '${idArg}'; pass the label id instead.`);
|
|
192
|
+
return finalists[0]!.id;
|
|
193
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-milestone service: all SDK access for project milestones lives here so
|
|
3
|
+
* commands stay thin.
|
|
4
|
+
*
|
|
5
|
+
* Milestones are always scoped to a project. Lists go through the project's
|
|
6
|
+
* `projectMilestones` connection (paginated); single `view` and all mutations
|
|
7
|
+
* use the typed SDK models. Mutations return SDK payloads ({ success,
|
|
8
|
+
* projectMilestone }) — we await and unwrap the entity.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LinearClient } from "@linear/sdk";
|
|
12
|
+
import { withRetry } from "../client.js";
|
|
13
|
+
import { shape } from "../lib/shape.js";
|
|
14
|
+
import {
|
|
15
|
+
collect,
|
|
16
|
+
inheritPaginationMetadata,
|
|
17
|
+
pageSize,
|
|
18
|
+
pageSizeForMore,
|
|
19
|
+
} from "../lib/pagination.js";
|
|
20
|
+
import { usageError, notFound } from "../lib/errors.js";
|
|
21
|
+
import { assertMutation, unwrapMutation } from "../lib/mutation.js";
|
|
22
|
+
import { resolveProjectId, resolveMilestoneId, isUuid } from "../lib/resolve.js";
|
|
23
|
+
|
|
24
|
+
export interface MilestoneRow {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
targetDate: string | null;
|
|
28
|
+
progress: number;
|
|
29
|
+
status: string;
|
|
30
|
+
description: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
|
|
34
|
+
export const MILESTONE_ROW_SHAPE = shape<MilestoneRow>({
|
|
35
|
+
id: "string",
|
|
36
|
+
name: "string",
|
|
37
|
+
targetDate: "string|null",
|
|
38
|
+
progress: "number",
|
|
39
|
+
status: "string",
|
|
40
|
+
description: "string|null",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Project a ProjectMilestone (SDK model or raw node) to a table row. Exported for tests. */
|
|
44
|
+
export function toRow(m: any): MilestoneRow {
|
|
45
|
+
return {
|
|
46
|
+
id: m.id,
|
|
47
|
+
name: m.name,
|
|
48
|
+
targetDate: m.targetDate ?? null,
|
|
49
|
+
progress: m.progress ?? 0,
|
|
50
|
+
status: m.status ?? "",
|
|
51
|
+
description: m.description ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listMilestones(
|
|
56
|
+
client: LinearClient,
|
|
57
|
+
projectInput: string,
|
|
58
|
+
limit: number,
|
|
59
|
+
): Promise<MilestoneRow[]> {
|
|
60
|
+
const projectId = await resolveProjectId(client, projectInput);
|
|
61
|
+
const project = await withRetry(() => client.project(projectId));
|
|
62
|
+
const conn = await withRetry(() => project.projectMilestones({ first: pageSize(limit) }));
|
|
63
|
+
const nodes = await collect(conn as any, limit);
|
|
64
|
+
return inheritPaginationMetadata(nodes.map(toRow), nodes);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MilestoneDetail {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
description: string | null;
|
|
71
|
+
targetDate: string | null;
|
|
72
|
+
progress: number;
|
|
73
|
+
status: string;
|
|
74
|
+
project: { id: string; name: string } | null;
|
|
75
|
+
createdAt: string;
|
|
76
|
+
updatedAt: string;
|
|
77
|
+
/** Issues assigned to this milestone, capped at `limit`. */
|
|
78
|
+
issues: Array<{
|
|
79
|
+
id: string;
|
|
80
|
+
identifier: string;
|
|
81
|
+
title: string;
|
|
82
|
+
state: { id: string; name: string; type: string } | null;
|
|
83
|
+
}>;
|
|
84
|
+
/** True when the cap hid some issues, so the caller can say so out loud. */
|
|
85
|
+
issuesTruncated: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The detail's shape; checked against `MilestoneDetail`. */
|
|
89
|
+
export const MILESTONE_DETAIL_SHAPE = shape<MilestoneDetail>({
|
|
90
|
+
id: "string",
|
|
91
|
+
name: "string",
|
|
92
|
+
description: "string|null",
|
|
93
|
+
targetDate: "string|null",
|
|
94
|
+
progress: "number",
|
|
95
|
+
status: "string",
|
|
96
|
+
project: { nullable: { id: "string", name: "string" } },
|
|
97
|
+
createdAt: "string",
|
|
98
|
+
updatedAt: "string",
|
|
99
|
+
issues: [
|
|
100
|
+
{
|
|
101
|
+
id: "string",
|
|
102
|
+
identifier: "string",
|
|
103
|
+
title: "string",
|
|
104
|
+
state: { nullable: { id: "string", name: "string", type: "string" } },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
issuesTruncated: "boolean",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The milestone, its project and one page of its issues — with each issue's
|
|
112
|
+
* state selected in place. The SDK-model version awaited `issue.state` per
|
|
113
|
+
* issue, one request each: 16 requests for a 13-issue milestone, and `-n 50`
|
|
114
|
+
* on a full one cost ~53. The page size is `$first`; the milestone fields
|
|
115
|
+
* ride along on every page and are read off the first.
|
|
116
|
+
*/
|
|
117
|
+
const DETAIL_QUERY = `
|
|
118
|
+
query CliMilestoneDetail($id: String!, $first: Int!, $after: String) {
|
|
119
|
+
projectMilestone(id: $id) {
|
|
120
|
+
id name description targetDate progress status createdAt updatedAt
|
|
121
|
+
project { id name }
|
|
122
|
+
issues(first: $first, after: $after) {
|
|
123
|
+
nodes { id identifier title state { id name type } }
|
|
124
|
+
pageInfo { hasNextPage endCursor }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}`;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Milestone detail plus the issues it contains, capped at `limit` (the global
|
|
131
|
+
* `-n/--limit`, or all of them under `--all`). `issuesTruncated` lets the caller
|
|
132
|
+
* say so out loud instead of quietly showing a partial list.
|
|
133
|
+
*/
|
|
134
|
+
export async function getMilestoneDetail(
|
|
135
|
+
client: LinearClient,
|
|
136
|
+
id: string,
|
|
137
|
+
limit = 50,
|
|
138
|
+
): Promise<MilestoneDetail> {
|
|
139
|
+
const raw = (vars: Record<string, unknown>): Promise<{ data: any }> =>
|
|
140
|
+
(client as any).client.rawRequest(DETAIL_QUERY, vars);
|
|
141
|
+
// One extra issue is requested so truncation is a fact we hold, not a guess:
|
|
142
|
+
// the sentinel item is what tells "the last one fit" from "there is one more".
|
|
143
|
+
const want = limit === Infinity ? Infinity : limit + 1;
|
|
144
|
+
const first = pageSizeForMore(limit);
|
|
145
|
+
const page = await withRetry(() => raw({ id, first, after: undefined }));
|
|
146
|
+
const m = page.data?.projectMilestone;
|
|
147
|
+
if (!m) throw notFound(`No milestone ${id}.`);
|
|
148
|
+
const nodes: any[] = [...(m.issues?.nodes ?? [])];
|
|
149
|
+
let pageInfo = m.issues?.pageInfo ?? { hasNextPage: false };
|
|
150
|
+
while (nodes.length < want && pageInfo.hasNextPage) {
|
|
151
|
+
const after = pageInfo.endCursor;
|
|
152
|
+
const next = await withRetry(() => raw({ id, first, after }));
|
|
153
|
+
const conn = next.data?.projectMilestone?.issues;
|
|
154
|
+
if (!conn) break;
|
|
155
|
+
nodes.push(...conn.nodes);
|
|
156
|
+
pageInfo = conn.pageInfo;
|
|
157
|
+
}
|
|
158
|
+
const hasMore = nodes.length > limit;
|
|
159
|
+
return {
|
|
160
|
+
id: m.id,
|
|
161
|
+
name: m.name,
|
|
162
|
+
description: m.description ?? null,
|
|
163
|
+
targetDate: m.targetDate ?? null,
|
|
164
|
+
progress: m.progress ?? 0,
|
|
165
|
+
status: m.status ?? "",
|
|
166
|
+
project: m.project ?? null,
|
|
167
|
+
createdAt: m.createdAt,
|
|
168
|
+
updatedAt: m.updatedAt,
|
|
169
|
+
issues: nodes.slice(0, limit).map((i) => ({
|
|
170
|
+
id: i.id,
|
|
171
|
+
identifier: i.identifier,
|
|
172
|
+
title: i.title,
|
|
173
|
+
state: i.state ?? null,
|
|
174
|
+
})),
|
|
175
|
+
// We know only that more exist, never how many.
|
|
176
|
+
issuesTruncated: hasMore,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A milestone reference as `view`/`update`/`delete` take it: a UUID, or a name
|
|
182
|
+
* plus the project it lives in. Milestone names are unique only within a
|
|
183
|
+
* project, so a bare name has nothing to resolve against — the same rule
|
|
184
|
+
* `issue update --milestone` applies, where the scope is the issue's project
|
|
185
|
+
* or `--project`. Without a scope the error says what to pass, rather than the
|
|
186
|
+
* API's "Could not find referenced ProjectMilestone" for a name it never tried
|
|
187
|
+
* to match.
|
|
188
|
+
*/
|
|
189
|
+
export async function resolveMilestoneRef(
|
|
190
|
+
client: LinearClient,
|
|
191
|
+
input: string,
|
|
192
|
+
projectInput: string | undefined,
|
|
193
|
+
): Promise<string> {
|
|
194
|
+
if (isUuid(input)) return input;
|
|
195
|
+
if (!projectInput) {
|
|
196
|
+
throw usageError(
|
|
197
|
+
`'${input}' is not a milestone id; pass --project <name|id> to look a milestone up by name.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const projectId = await resolveProjectId(client, projectInput);
|
|
201
|
+
return resolveMilestoneId(client, projectId, input);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The SDK model for a milestone id (mutations need the model's id only, but the name for the receipt). */
|
|
205
|
+
async function resolveMilestone(client: LinearClient, id: string) {
|
|
206
|
+
return withRetry(() => client.projectMilestone(id));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface CreateOptions {
|
|
210
|
+
name: string;
|
|
211
|
+
description?: string;
|
|
212
|
+
targetDate?: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Build a ProjectMilestoneCreateInput; the milestone is scoped to a project. */
|
|
216
|
+
export async function createMilestone(
|
|
217
|
+
client: LinearClient,
|
|
218
|
+
projectInput: string,
|
|
219
|
+
opts: CreateOptions,
|
|
220
|
+
) {
|
|
221
|
+
const projectId = await resolveProjectId(client, projectInput);
|
|
222
|
+
const input: Record<string, any> = { projectId, name: opts.name };
|
|
223
|
+
if (opts.description !== undefined) input.description = opts.description;
|
|
224
|
+
if (opts.targetDate) input.targetDate = opts.targetDate;
|
|
225
|
+
|
|
226
|
+
return unwrapMutation(
|
|
227
|
+
withRetry(() => client.createProjectMilestone(input as any)),
|
|
228
|
+
"projectMilestone",
|
|
229
|
+
"Milestone creation",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface UpdateOptions {
|
|
234
|
+
name?: string;
|
|
235
|
+
description?: string;
|
|
236
|
+
targetDate?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function updateMilestone(client: LinearClient, id: string, opts: UpdateOptions) {
|
|
240
|
+
const milestone = await resolveMilestone(client, id);
|
|
241
|
+
const input: Record<string, any> = {};
|
|
242
|
+
if (opts.name !== undefined) input.name = opts.name;
|
|
243
|
+
if (opts.description !== undefined) input.description = opts.description;
|
|
244
|
+
if (opts.targetDate !== undefined) input.targetDate = opts.targetDate;
|
|
245
|
+
|
|
246
|
+
if (Object.keys(input).length === 0)
|
|
247
|
+
throw usageError("Nothing to update; pass at least one field.");
|
|
248
|
+
return unwrapMutation(
|
|
249
|
+
withRetry(() => client.updateProjectMilestone(milestone.id, input as any)),
|
|
250
|
+
"projectMilestone",
|
|
251
|
+
"Milestone update",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function deleteMilestone(client: LinearClient, id: string) {
|
|
256
|
+
const milestone = await resolveMilestone(client, id);
|
|
257
|
+
await assertMutation(
|
|
258
|
+
withRetry(() => client.deleteProjectMilestone(milestone.id)),
|
|
259
|
+
"Milestone deletion",
|
|
260
|
+
);
|
|
261
|
+
return milestone;
|
|
262
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification service: all SDK access for the viewer's notifications lives here
|
|
3
|
+
* so commands stay thin.
|
|
4
|
+
*
|
|
5
|
+
* The list uses a tailored GraphQL query with inline fragments on the
|
|
6
|
+
* notification subtypes, so the human-facing "subject" (issue identifier/title,
|
|
7
|
+
* project name, document title, …) is pulled in a single round-trip with no
|
|
8
|
+
* N+1. Mutations use the typed SDK (updateNotification / archiveNotification /
|
|
9
|
+
* notificationMarkReadAll).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LinearClient } from "@linear/sdk";
|
|
13
|
+
import { withRetry } from "../client.js";
|
|
14
|
+
import { shape } from "../lib/shape.js";
|
|
15
|
+
import { collectRawQuery } from "../lib/pagination.js";
|
|
16
|
+
import { assertMutation } from "../lib/mutation.js";
|
|
17
|
+
import { normalizeError, usageError } from "../lib/errors.js";
|
|
18
|
+
|
|
19
|
+
export interface NotificationRow {
|
|
20
|
+
id: string;
|
|
21
|
+
type: string;
|
|
22
|
+
subject: string | null;
|
|
23
|
+
read: boolean;
|
|
24
|
+
readAt: string | null;
|
|
25
|
+
snoozedUntilAt: string | null;
|
|
26
|
+
archivedAt: string | null;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
|
|
31
|
+
export const NOTIFICATION_ROW_SHAPE = shape<NotificationRow>({
|
|
32
|
+
id: "string",
|
|
33
|
+
type: "string",
|
|
34
|
+
subject: "string|null",
|
|
35
|
+
read: "boolean",
|
|
36
|
+
readAt: "string|null",
|
|
37
|
+
snoozedUntilAt: "string|null",
|
|
38
|
+
archivedAt: "string|null",
|
|
39
|
+
createdAt: "string",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const LIST_QUERY = `
|
|
43
|
+
query CliNotifications($first: Int!, $after: String, $includeArchived: Boolean) {
|
|
44
|
+
notifications(first: $first, after: $after, includeArchived: $includeArchived) {
|
|
45
|
+
nodes {
|
|
46
|
+
__typename
|
|
47
|
+
id type readAt snoozedUntilAt archivedAt createdAt
|
|
48
|
+
... on IssueNotification { issue { identifier title } comment { id } }
|
|
49
|
+
... on ProjectNotification { project { name } projectUpdate { id } }
|
|
50
|
+
... on DocumentNotification { documentId }
|
|
51
|
+
... on InitiativeNotification { initiative { name } }
|
|
52
|
+
... on CustomerNotification { customer { name } }
|
|
53
|
+
}
|
|
54
|
+
pageInfo { hasNextPage endCursor }
|
|
55
|
+
}
|
|
56
|
+
}`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Derive a short human "subject" line from the typed notification node. The base
|
|
60
|
+
* Notification type has no title scalar in @linear/sdk v87, so we synthesize one
|
|
61
|
+
* from the related entity. DocumentNotification exposes only `documentId` (no
|
|
62
|
+
* nested document relation), so document notifications fall back to that id.
|
|
63
|
+
*/
|
|
64
|
+
function subjectOf(n: any): string | null {
|
|
65
|
+
if (n.issue) {
|
|
66
|
+
const id = n.issue.identifier;
|
|
67
|
+
const title = n.issue.title;
|
|
68
|
+
return id && title ? `${id} ${title}` : (id ?? title ?? null);
|
|
69
|
+
}
|
|
70
|
+
if (n.project?.name) return n.project.name;
|
|
71
|
+
if (n.initiative?.name) return n.initiative.name;
|
|
72
|
+
if (n.customer?.name) return n.customer.name;
|
|
73
|
+
if (n.documentId) return `document ${n.documentId}`;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function listNotifications(
|
|
78
|
+
client: LinearClient,
|
|
79
|
+
limit: number,
|
|
80
|
+
includeArchived: boolean,
|
|
81
|
+
): Promise<NotificationRow[]> {
|
|
82
|
+
return collectRawQuery<NotificationRow>(
|
|
83
|
+
client as any,
|
|
84
|
+
LIST_QUERY,
|
|
85
|
+
{ includeArchived },
|
|
86
|
+
"notifications",
|
|
87
|
+
limit,
|
|
88
|
+
(n) => ({
|
|
89
|
+
id: n.id,
|
|
90
|
+
type: n.type ?? n.__typename ?? "",
|
|
91
|
+
subject: subjectOf(n),
|
|
92
|
+
read: !!n.readAt,
|
|
93
|
+
readAt: n.readAt ?? null,
|
|
94
|
+
snoozedUntilAt: n.snoozedUntilAt ?? null,
|
|
95
|
+
archivedAt: n.archivedAt ?? null,
|
|
96
|
+
createdAt: n.createdAt,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Mark a single notification read (now) or unread (null).
|
|
103
|
+
*
|
|
104
|
+
* NotificationPayload exposes only {success,lastSyncId} (no notification body),
|
|
105
|
+
* so there is no entity to unwrap — `assertMutation` is the whole check, and a
|
|
106
|
+
* refused write throws instead of being handed back as `{success:false}` for a
|
|
107
|
+
* caller to ignore.
|
|
108
|
+
*/
|
|
109
|
+
export async function setRead(client: LinearClient, id: string, read: boolean) {
|
|
110
|
+
const input = { readAt: read ? new Date().toISOString() : null };
|
|
111
|
+
await assertMutation(
|
|
112
|
+
withRetry(() => client.updateNotification(id, input as any)),
|
|
113
|
+
read ? "Marking notification read" : "Marking notification unread",
|
|
114
|
+
);
|
|
115
|
+
return { id, read };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One notification's outcome within `markAllRead`. */
|
|
119
|
+
export interface MarkAllItem {
|
|
120
|
+
id: string;
|
|
121
|
+
read: boolean;
|
|
122
|
+
error?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const MARK_ALL_ITEM_SHAPE = shape<MarkAllItem>({
|
|
126
|
+
id: "string",
|
|
127
|
+
read: "boolean",
|
|
128
|
+
"error?": "string",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Mark all of the viewer's unread notifications as read. The SDK's
|
|
133
|
+
* `notificationMarkReadAll` requires a specific entity type (an empty input is
|
|
134
|
+
* rejected with "entity type is not supported"), so we instead enumerate unread
|
|
135
|
+
* notifications and mark each read.
|
|
136
|
+
*
|
|
137
|
+
* This is a batch of independent mutations, so it reports what actually
|
|
138
|
+
* happened per item rather than a hardcoded aggregate: `count` is the number
|
|
139
|
+
* that really went through, and `failed` carries the ones that did not, with the
|
|
140
|
+
* API's reason. One bad notification does not abort the rest — but it can no
|
|
141
|
+
* longer be reported as a success.
|
|
142
|
+
*/
|
|
143
|
+
export async function markAllRead(
|
|
144
|
+
client: LinearClient,
|
|
145
|
+
): Promise<{ success: boolean; count: number; attempted: number; failed: MarkAllItem[] }> {
|
|
146
|
+
const rows = await listNotifications(client, Infinity, false);
|
|
147
|
+
const unread = rows.filter((r) => !r.readAt);
|
|
148
|
+
const readAt = new Date().toISOString();
|
|
149
|
+
const failed: MarkAllItem[] = [];
|
|
150
|
+
let count = 0;
|
|
151
|
+
for (const r of unread) {
|
|
152
|
+
try {
|
|
153
|
+
await assertMutation(
|
|
154
|
+
withRetry(() => client.updateNotification(r.id, { readAt } as any)),
|
|
155
|
+
"Marking notification read",
|
|
156
|
+
);
|
|
157
|
+
count += 1;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
failed.push({ id: r.id, read: false, error: normalizeError(err).message });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { success: failed.length === 0, count, attempted: unread.length, failed };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function archiveNotification(client: LinearClient, id: string): Promise<boolean> {
|
|
166
|
+
await assertMutation(
|
|
167
|
+
withRetry(() => client.archiveNotification(id)),
|
|
168
|
+
"Notification archive",
|
|
169
|
+
);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Snooze a notification until the given ISO timestamp. */
|
|
174
|
+
export async function snoozeNotification(client: LinearClient, id: string, untilISO: string) {
|
|
175
|
+
if (
|
|
176
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
|
177
|
+
untilISO,
|
|
178
|
+
) ||
|
|
179
|
+
!Number.isFinite(Date.parse(untilISO))
|
|
180
|
+
) {
|
|
181
|
+
throw usageError(
|
|
182
|
+
`Invalid snooze timestamp '${untilISO}'. Use ISO 8601 with a timezone, e.g. 2026-07-01T09:00:00Z.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
await assertMutation(
|
|
186
|
+
withRetry(() => client.updateNotification(id, { snoozedUntilAt: untilISO } as any)),
|
|
187
|
+
"Notification snooze",
|
|
188
|
+
);
|
|
189
|
+
return { id, snoozedUntilAt: untilISO };
|
|
190
|
+
}
|