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.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/CHANGELOG.md +807 -0
  4. package/COVERAGE.md +495 -0
  5. package/LICENSE +21 -0
  6. package/MIGRATING.md +220 -0
  7. package/README.md +524 -0
  8. package/package.json +87 -0
  9. package/skills/linear-sdk-cli/SKILL.md +534 -0
  10. package/skills/linear-sdk-cli/references/api.md +27 -0
  11. package/skills/linear-sdk-cli/references/attachment.md +82 -0
  12. package/skills/linear-sdk-cli/references/auth.md +174 -0
  13. package/skills/linear-sdk-cli/references/commands.md +37 -0
  14. package/skills/linear-sdk-cli/references/comment.md +154 -0
  15. package/skills/linear-sdk-cli/references/completion.md +17 -0
  16. package/skills/linear-sdk-cli/references/config.md +99 -0
  17. package/skills/linear-sdk-cli/references/cycle.md +131 -0
  18. package/skills/linear-sdk-cli/references/document.md +180 -0
  19. package/skills/linear-sdk-cli/references/favorite.md +78 -0
  20. package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
  21. package/skills/linear-sdk-cli/references/initiative.md +256 -0
  22. package/skills/linear-sdk-cli/references/issue.md +928 -0
  23. package/skills/linear-sdk-cli/references/label.md +112 -0
  24. package/skills/linear-sdk-cli/references/milestone.md +139 -0
  25. package/skills/linear-sdk-cli/references/notification.md +123 -0
  26. package/skills/linear-sdk-cli/references/open.md +29 -0
  27. package/skills/linear-sdk-cli/references/organization.md +99 -0
  28. package/skills/linear-sdk-cli/references/project-update.md +67 -0
  29. package/skills/linear-sdk-cli/references/project.md +234 -0
  30. package/skills/linear-sdk-cli/references/roadmap.md +150 -0
  31. package/skills/linear-sdk-cli/references/schema.md +21 -0
  32. package/skills/linear-sdk-cli/references/state.md +61 -0
  33. package/skills/linear-sdk-cli/references/team.md +227 -0
  34. package/skills/linear-sdk-cli/references/user.md +103 -0
  35. package/skills/linear-sdk-cli/references/webhook.md +151 -0
  36. package/skills/linear-sdk-cli/references/whoami.md +26 -0
  37. package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
  38. package/src/bin/linear.ts +97 -0
  39. package/src/cli.ts +232 -0
  40. package/src/client.ts +172 -0
  41. package/src/commands/agent-session.ts +121 -0
  42. package/src/commands/api.ts +317 -0
  43. package/src/commands/attach.ts +90 -0
  44. package/src/commands/attachment.ts +74 -0
  45. package/src/commands/comment.ts +225 -0
  46. package/src/commands/completion.ts +69 -0
  47. package/src/commands/cycle.ts +125 -0
  48. package/src/commands/discover.ts +170 -0
  49. package/src/commands/document.ts +211 -0
  50. package/src/commands/favorite.ts +72 -0
  51. package/src/commands/initiative-update.ts +47 -0
  52. package/src/commands/initiative.ts +261 -0
  53. package/src/commands/issue.ts +1263 -0
  54. package/src/commands/label.ts +119 -0
  55. package/src/commands/meta.ts +534 -0
  56. package/src/commands/milestone.ts +163 -0
  57. package/src/commands/notification.ts +110 -0
  58. package/src/commands/open.ts +84 -0
  59. package/src/commands/organization.ts +79 -0
  60. package/src/commands/project-update.ts +47 -0
  61. package/src/commands/project.ts +339 -0
  62. package/src/commands/roadmap.ts +131 -0
  63. package/src/commands/state.ts +58 -0
  64. package/src/commands/team.ts +243 -0
  65. package/src/commands/user.ts +107 -0
  66. package/src/commands/webhook.ts +156 -0
  67. package/src/config.ts +1236 -0
  68. package/src/context.ts +117 -0
  69. package/src/git.ts +174 -0
  70. package/src/index.ts +10 -0
  71. package/src/lib/action.ts +26 -0
  72. package/src/lib/body.ts +172 -0
  73. package/src/lib/errors.ts +195 -0
  74. package/src/lib/introspect.ts +150 -0
  75. package/src/lib/keyring.ts +270 -0
  76. package/src/lib/mutation.ts +53 -0
  77. package/src/lib/open.ts +32 -0
  78. package/src/lib/options.ts +640 -0
  79. package/src/lib/output-shapes.ts +562 -0
  80. package/src/lib/pagination.ts +145 -0
  81. package/src/lib/prompt.ts +91 -0
  82. package/src/lib/resolve.ts +596 -0
  83. package/src/lib/shape.ts +273 -0
  84. package/src/lib/status-update.ts +91 -0
  85. package/src/lib/upload.ts +329 -0
  86. package/src/output/color.ts +52 -0
  87. package/src/output/format.ts +238 -0
  88. package/src/output/markdown.ts +133 -0
  89. package/src/output/pager.ts +131 -0
  90. package/src/output/sanitize.ts +76 -0
  91. package/src/output/table.ts +198 -0
  92. package/src/services/agent-session.ts +289 -0
  93. package/src/services/attachment.ts +198 -0
  94. package/src/services/comment.ts +262 -0
  95. package/src/services/cycle.ts +224 -0
  96. package/src/services/document.ts +357 -0
  97. package/src/services/favorite.ts +147 -0
  98. package/src/services/initiative-update.ts +59 -0
  99. package/src/services/initiative.ts +449 -0
  100. package/src/services/issue.ts +1153 -0
  101. package/src/services/label.ts +193 -0
  102. package/src/services/milestone.ts +262 -0
  103. package/src/services/notification.ts +190 -0
  104. package/src/services/organization.ts +144 -0
  105. package/src/services/project-update.ts +36 -0
  106. package/src/services/project.ts +494 -0
  107. package/src/services/roadmap.ts +177 -0
  108. package/src/services/state.ts +127 -0
  109. package/src/services/team.ts +386 -0
  110. package/src/services/user.ts +137 -0
  111. package/src/services/webhook.ts +203 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Roadmap service: all SDK access for roadmaps lives here so commands stay thin.
3
+ *
4
+ * Roadmaps are workspace-level and relatively few, so the list uses the typed
5
+ * SDK connection (paginated via collect()); single `view`, its associated
6
+ * projects, and all mutations use the typed SDK models. Mutations return SDK
7
+ * payloads ({ success, roadmap }) — we await and unwrap the entity.
8
+ *
9
+ * There is no RoadmapFilter in the SDK, so name lookups match client-side
10
+ * against the (small) set of workspace roadmaps.
11
+ */
12
+
13
+ import type { LinearClient } from "@linear/sdk";
14
+ import { withRetry } from "../client.js";
15
+ import { shape } from "../lib/shape.js";
16
+ import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
17
+ import { usageError, notFound, ambiguous } from "../lib/errors.js";
18
+ import { assertMutation, unwrapMutation } from "../lib/mutation.js";
19
+ import { resolveUserId, isUuid } from "../lib/resolve.js";
20
+
21
+ export interface RoadmapRow {
22
+ id: string;
23
+ name: string;
24
+ description: string | null;
25
+ url: string;
26
+ }
27
+
28
+ /** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
29
+ export const ROADMAP_ROW_SHAPE = shape<RoadmapRow>({
30
+ id: "string",
31
+ name: "string",
32
+ description: "string|null",
33
+ url: "string",
34
+ });
35
+
36
+ /** Project a Roadmap SDK model to a table row. Exported for tests. */
37
+ export function toRow(r: any): RoadmapRow {
38
+ return {
39
+ id: r.id,
40
+ name: r.name,
41
+ description: r.description ?? null,
42
+ url: r.url,
43
+ };
44
+ }
45
+
46
+ export async function listRoadmaps(client: LinearClient, limit: number): Promise<RoadmapRow[]> {
47
+ const conn = await withRetry(() => client.roadmaps({ first: pageSize(limit) }));
48
+ const nodes = await collect(conn as any, limit);
49
+ return inheritPaginationMetadata(nodes.map(toRow), nodes);
50
+ }
51
+
52
+ export interface RoadmapDetail {
53
+ id: string;
54
+ name: string;
55
+ description: string | null;
56
+ color: string | null;
57
+ url: string;
58
+ slugId: string;
59
+ owner: string | null;
60
+ creator: string | null;
61
+ createdAt: string;
62
+ updatedAt: string;
63
+ projects: string[];
64
+ }
65
+
66
+ /** The detail's shape; checked against `RoadmapDetail`. */
67
+ export const ROADMAP_DETAIL_SHAPE = shape<RoadmapDetail>({
68
+ id: "string",
69
+ name: "string",
70
+ description: "string|null",
71
+ color: "string|null",
72
+ url: "string",
73
+ slugId: "string",
74
+ owner: "string|null",
75
+ creator: "string|null",
76
+ createdAt: "string",
77
+ updatedAt: "string",
78
+ projects: ["string"],
79
+ });
80
+
81
+ export async function getRoadmapDetail(
82
+ client: LinearClient,
83
+ idArg: string,
84
+ ): Promise<RoadmapDetail> {
85
+ const roadmap = await resolveRoadmap(client, idArg);
86
+ const [owner, creator, projects] = await Promise.all([
87
+ roadmap.owner,
88
+ roadmap.creator,
89
+ withRetry(() => roadmap.projects({ first: 100 })),
90
+ ]);
91
+ const projectConn = projects as { nodes: Array<{ name: string }> };
92
+ return {
93
+ id: roadmap.id,
94
+ name: roadmap.name,
95
+ description: roadmap.description ?? null,
96
+ color: roadmap.color ?? null,
97
+ url: roadmap.url,
98
+ slugId: roadmap.slugId,
99
+ owner: owner?.displayName ?? null,
100
+ creator: creator?.displayName ?? null,
101
+ createdAt: roadmap.createdAt.toISOString(),
102
+ updatedAt: roadmap.updatedAt.toISOString(),
103
+ projects: projectConn.nodes.map((p) => p.name),
104
+ };
105
+ }
106
+
107
+ export interface CreateOptions {
108
+ name: string;
109
+ description?: string;
110
+ owner?: string;
111
+ color?: string;
112
+ }
113
+
114
+ /** Build a RoadmapCreateInput, resolving the owner reference to a user id. */
115
+ export async function createRoadmap(client: LinearClient, opts: CreateOptions) {
116
+ const input: Record<string, any> = { name: opts.name };
117
+ if (opts.description !== undefined) input.description = opts.description;
118
+ if (opts.owner) input.ownerId = await resolveUserId(client, opts.owner);
119
+ if (opts.color) input.color = opts.color;
120
+
121
+ return unwrapMutation(
122
+ withRetry(() => client.createRoadmap(input as any)),
123
+ "roadmap",
124
+ "Roadmap creation",
125
+ );
126
+ }
127
+
128
+ export interface UpdateOptions {
129
+ name?: string;
130
+ description?: string;
131
+ owner?: string;
132
+ color?: string;
133
+ }
134
+
135
+ export async function updateRoadmap(client: LinearClient, idArg: string, opts: UpdateOptions) {
136
+ const roadmap = await resolveRoadmap(client, idArg);
137
+ const input: Record<string, any> = {};
138
+ if (opts.name !== undefined) input.name = opts.name;
139
+ if (opts.description !== undefined) input.description = opts.description;
140
+ if (opts.owner) input.ownerId = await resolveUserId(client, opts.owner);
141
+ if (opts.color) input.color = opts.color;
142
+
143
+ if (Object.keys(input).length === 0)
144
+ throw usageError("Nothing to update; pass at least one field.");
145
+ return unwrapMutation(
146
+ withRetry(() => client.updateRoadmap(roadmap.id, input as any)),
147
+ "roadmap",
148
+ "Roadmap update",
149
+ );
150
+ }
151
+
152
+ export async function deleteRoadmap(client: LinearClient, idArg: string) {
153
+ const roadmap = await resolveRoadmap(client, idArg);
154
+ await assertMutation(
155
+ withRetry(() => client.deleteRoadmap(roadmap.id)),
156
+ "Roadmap deletion",
157
+ );
158
+ return roadmap;
159
+ }
160
+
161
+ /**
162
+ * Resolve a roadmap by id (UUID, fetched directly) or by name. The `roadmaps`
163
+ * query takes no filter argument in @linear/sdk v87, so matching is client-side
164
+ * — but we exhaust pagination (via collect) so a match past the first page is
165
+ * not missed.
166
+ */
167
+ async function resolveRoadmap(client: LinearClient, idArg: string) {
168
+ if (isUuid(idArg)) return withRetry(() => client.roadmap(idArg));
169
+ const conn = await withRetry(() => client.roadmaps({ first: 100 }));
170
+ const all = await collect<any>(conn as any, Infinity);
171
+ const lower = idArg.toLowerCase();
172
+ const matches = all.filter((r: any) => r.name.toLowerCase() === lower);
173
+ if (matches.length === 0) throw notFound(`No roadmap matching '${idArg}'.`);
174
+ if (matches.length > 1)
175
+ throw ambiguous(`Multiple roadmaps match '${idArg}'; pass the roadmap id instead.`);
176
+ return matches[0]!;
177
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Workflow-state service: all SDK access for workflow states lives here so
3
+ * commands stay thin.
4
+ *
5
+ * A team is resolved from a friendly key/name/id via `resolveTeam`, then its
6
+ * states come from the typed `team.states()` connection (sorted client-side by
7
+ * position). The single `view` takes an id, or a name/type resolved within a 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 { collect, pageSize, setPaginationMetadata } from "../lib/pagination.js";
14
+ import { usageError } from "../lib/errors.js";
15
+ import { resolveTeam, resolveStateId, isUuid } from "../lib/resolve.js";
16
+
17
+ export interface StateRow {
18
+ id: string;
19
+ name: string;
20
+ type: string;
21
+ position: number;
22
+ color: string;
23
+ }
24
+
25
+ /** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
26
+ export const STATE_ROW_SHAPE = shape<StateRow>({
27
+ id: "string",
28
+ name: "string",
29
+ type: "string",
30
+ position: "number",
31
+ color: "string",
32
+ });
33
+
34
+ /** List a team's workflow states, sorted by position (ascending). */
35
+ export async function listStates(
36
+ client: LinearClient,
37
+ teamInput: string | undefined,
38
+ defaultTeamKey: string | undefined,
39
+ limit: number,
40
+ ): Promise<StateRow[]> {
41
+ const team = await resolveTeam(client, teamInput, defaultTeamKey);
42
+ const teamModel = await withRetry(() => client.team(team.id));
43
+ const conn = await withRetry(() => teamModel.states({ first: pageSize(limit) }));
44
+ // The API's connection order is unrelated to workflow position. Sort the
45
+ // complete small state set, then apply the caller's limit.
46
+ const nodes = await collect(conn as any, Infinity);
47
+ const rows = nodes.map(toRow).sort((a, b) => a.position - b.position);
48
+ return setPaginationMetadata(
49
+ limit === Infinity ? rows : rows.slice(0, limit),
50
+ rows.length > limit,
51
+ );
52
+ }
53
+
54
+ export interface StateDetail {
55
+ id: string;
56
+ name: string;
57
+ type: string;
58
+ position: number;
59
+ color: string;
60
+ description: string | null;
61
+ team: string | null;
62
+ createdAt: string;
63
+ updatedAt: string;
64
+ }
65
+
66
+ /** The detail's shape; checked against `StateDetail`. */
67
+ export const STATE_DETAIL_SHAPE = shape<StateDetail>({
68
+ id: "string",
69
+ name: "string",
70
+ type: "string",
71
+ position: "number",
72
+ color: "string",
73
+ description: "string|null",
74
+ team: "string|null",
75
+ createdAt: "string",
76
+ updatedAt: "string",
77
+ });
78
+
79
+ /**
80
+ * A single workflow state, by id or by name/type within a team.
81
+ *
82
+ * A name resolves through `resolveStateId` against `--team` or the configured
83
+ * default — the same lookup `issue create --state` uses — so `state view
84
+ * Backlog` works wherever `issue create --state Backlog` would. Without a team
85
+ * to scope the name, the error says what to pass; the API's own answer for a
86
+ * name was "Could not find referenced WorkflowState", which hid the fact that
87
+ * only ids were ever tried.
88
+ */
89
+ export async function getStateDetail(
90
+ client: LinearClient,
91
+ input: string,
92
+ teamKey?: string,
93
+ ): Promise<StateDetail> {
94
+ let id = input;
95
+ if (!isUuid(input)) {
96
+ if (!teamKey) {
97
+ throw usageError(
98
+ `'${input}' is not a workflow state id; pass --team <KEY> (or set a default team) to look a state up by name.`,
99
+ );
100
+ }
101
+ const team = await resolveTeam(client, teamKey, undefined);
102
+ id = await resolveStateId(client, team.id, input);
103
+ }
104
+ const state = await withRetry(() => client.workflowState(id));
105
+ const team = await state.team;
106
+ return {
107
+ id: state.id,
108
+ name: state.name,
109
+ type: state.type,
110
+ position: state.position,
111
+ color: state.color,
112
+ description: state.description ?? null,
113
+ team: team?.key ?? null,
114
+ createdAt: state.createdAt.toISOString(),
115
+ updatedAt: state.updatedAt.toISOString(),
116
+ };
117
+ }
118
+
119
+ function toRow(s: any): StateRow {
120
+ return {
121
+ id: s.id,
122
+ name: s.name,
123
+ type: s.type,
124
+ position: s.position,
125
+ color: s.color,
126
+ };
127
+ }
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Team service: all SDK access for teams lives here so commands stay thin.
3
+ *
4
+ * Teams are resolved from a friendly key/name/id via `resolveTeam`; the single
5
+ * `view` and the sub-resource listings (members, states, labels, cycles) use the
6
+ * typed SDK connections, and mutations unwrap the `{ success, team }` payload.
7
+ */
8
+
9
+ import type { LinearClient } from "@linear/sdk";
10
+ import { withRetry } from "../client.js";
11
+ import { shape } from "../lib/shape.js";
12
+ import {
13
+ collect,
14
+ inheritPaginationMetadata,
15
+ pageSize,
16
+ setPaginationMetadata,
17
+ } from "../lib/pagination.js";
18
+ import { usageError } from "../lib/errors.js";
19
+ import { assertMutation, unwrapMutation } from "../lib/mutation.js";
20
+ import { resolveTeam, type ResolvedTeam } from "../lib/resolve.js";
21
+
22
+ export interface TeamRow {
23
+ id: string;
24
+ key: string;
25
+ name: string;
26
+ }
27
+
28
+ /** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
29
+ export const TEAM_ROW_SHAPE = shape<TeamRow>({ id: "string", key: "string", name: "string" });
30
+
31
+ /** Resolve the selected team to its canonical key, UUID, and name. */
32
+ export async function getTeamIdentity(
33
+ client: LinearClient,
34
+ keyArg: string | undefined,
35
+ defaultTeamKey: string | undefined,
36
+ ): Promise<ResolvedTeam> {
37
+ return resolveTeam(client, keyArg, defaultTeamKey);
38
+ }
39
+
40
+ /** All teams (key, name, id). */
41
+ export async function listTeams(client: LinearClient, limit: number): Promise<TeamRow[]> {
42
+ const conn = await withRetry(() => client.teams({ first: pageSize(limit) }));
43
+ const nodes = await collect(conn as any, limit);
44
+ return inheritPaginationMetadata(
45
+ nodes.map((t: any) => ({ id: t.id, key: t.key, name: t.name })),
46
+ nodes,
47
+ );
48
+ }
49
+
50
+ export interface TeamDetail {
51
+ id: string;
52
+ key: string;
53
+ name: string;
54
+ description: string | null;
55
+ private: boolean;
56
+ cyclesEnabled: boolean;
57
+ timezone: string | null;
58
+ color: string | null;
59
+ icon: string | null;
60
+ issueCount: number;
61
+ memberCount: number;
62
+ createdAt: string;
63
+ updatedAt: string;
64
+ }
65
+
66
+ /** The detail's shape; checked against `TeamDetail`. */
67
+ export const TEAM_DETAIL_SHAPE = shape<TeamDetail>({
68
+ id: "string",
69
+ key: "string",
70
+ name: "string",
71
+ description: "string|null",
72
+ private: "boolean",
73
+ cyclesEnabled: "boolean",
74
+ timezone: "string|null",
75
+ color: "string|null",
76
+ icon: "string|null",
77
+ issueCount: "number",
78
+ memberCount: "number",
79
+ createdAt: "string",
80
+ updatedAt: "string",
81
+ });
82
+
83
+ export async function getTeamDetail(
84
+ client: LinearClient,
85
+ keyArg: string | undefined,
86
+ defaultTeamKey: string | undefined,
87
+ ): Promise<TeamDetail> {
88
+ const resolved = await resolveTeam(client, keyArg, defaultTeamKey);
89
+ const team = await withRetry(() => client.team(resolved.id));
90
+ // Count members via the connection (no direct count field on Team). Deactivated
91
+ // users are excluded — `includeDisabled` is passed explicitly rather than left
92
+ // to the server default so the intent is visible here.
93
+ const members = await collect(
94
+ (await withRetry(() => team.members({ first: 100, includeDisabled: false }))) as any,
95
+ Infinity,
96
+ );
97
+ return {
98
+ id: team.id,
99
+ key: team.key,
100
+ name: team.name,
101
+ description: team.description ?? null,
102
+ private: !!team.private,
103
+ cyclesEnabled: !!team.cyclesEnabled,
104
+ timezone: team.timezone ?? null,
105
+ color: team.color ?? null,
106
+ icon: team.icon ?? null,
107
+ issueCount: team.issueCount ?? 0,
108
+ memberCount: members.length,
109
+ createdAt: team.createdAt.toISOString(),
110
+ updatedAt: team.updatedAt.toISOString(),
111
+ };
112
+ }
113
+
114
+ export interface MemberRow {
115
+ id: string;
116
+ displayName: string;
117
+ name: string;
118
+ email: string;
119
+ active: boolean;
120
+ }
121
+
122
+ export const TEAM_MEMBER_ROW_SHAPE = shape<MemberRow>({
123
+ id: "string",
124
+ displayName: "string",
125
+ name: "string",
126
+ email: "string",
127
+ active: "boolean",
128
+ });
129
+
130
+ /**
131
+ * List a team's members. Linear defaults `includeDisabled` to false, so
132
+ * deactivated users are invisible (and the `active` column constantly true)
133
+ * unless the caller opts in.
134
+ */
135
+ export async function listMembers(
136
+ client: LinearClient,
137
+ keyArg: string | undefined,
138
+ defaultTeamKey: string | undefined,
139
+ limit: number,
140
+ includeDisabled = false,
141
+ ): Promise<MemberRow[]> {
142
+ const resolved = await resolveTeam(client, keyArg, defaultTeamKey);
143
+ const team = await withRetry(() => client.team(resolved.id));
144
+ const nodes = await collect(
145
+ (await withRetry(() => team.members({ first: pageSize(limit), includeDisabled }))) as any,
146
+ limit,
147
+ );
148
+ return inheritPaginationMetadata(
149
+ nodes.map((u: any) => ({
150
+ id: u.id,
151
+ displayName: u.displayName,
152
+ name: u.name,
153
+ email: u.email,
154
+ active: !!u.active,
155
+ })),
156
+ nodes,
157
+ );
158
+ }
159
+
160
+ export interface StateRow {
161
+ id: string;
162
+ name: string;
163
+ type: string;
164
+ color: string;
165
+ position: number;
166
+ }
167
+
168
+ export const TEAM_STATE_ROW_SHAPE = shape<StateRow>({
169
+ id: "string",
170
+ name: "string",
171
+ type: "string",
172
+ color: "string",
173
+ position: "number",
174
+ });
175
+
176
+ export async function listStates(
177
+ client: LinearClient,
178
+ keyArg: string | undefined,
179
+ defaultTeamKey: string | undefined,
180
+ limit: number,
181
+ ): Promise<StateRow[]> {
182
+ const resolved = await resolveTeam(client, keyArg, defaultTeamKey);
183
+ const team = await withRetry(() => client.team(resolved.id));
184
+ const nodes = await collect((await withRetry(() => team.states())) as any, Infinity);
185
+ const rows = nodes
186
+ .map((s: any) => ({
187
+ id: s.id,
188
+ name: s.name,
189
+ type: s.type,
190
+ color: s.color,
191
+ position: s.position,
192
+ }))
193
+ .sort((a: StateRow, b: StateRow) => a.position - b.position);
194
+ return setPaginationMetadata(
195
+ limit === Infinity ? rows : rows.slice(0, limit),
196
+ rows.length > limit,
197
+ );
198
+ }
199
+
200
+ export interface LabelRow {
201
+ id: string;
202
+ name: string;
203
+ color: string;
204
+ }
205
+
206
+ export const TEAM_LABEL_ROW_SHAPE = shape<LabelRow>({
207
+ id: "string",
208
+ name: "string",
209
+ color: "string",
210
+ });
211
+
212
+ export async function listLabels(
213
+ client: LinearClient,
214
+ keyArg: string | undefined,
215
+ defaultTeamKey: string | undefined,
216
+ limit: number,
217
+ ): Promise<LabelRow[]> {
218
+ const resolved = await resolveTeam(client, keyArg, defaultTeamKey);
219
+ const team = await withRetry(() => client.team(resolved.id));
220
+ const nodes = await collect((await withRetry(() => team.labels())) as any, limit);
221
+ return inheritPaginationMetadata(
222
+ nodes.map((l: any) => ({ id: l.id, name: l.name, color: l.color })),
223
+ nodes,
224
+ );
225
+ }
226
+
227
+ export interface CycleRow {
228
+ id: string;
229
+ number: number;
230
+ name: string | null;
231
+ startsAt: string | null;
232
+ endsAt: string | null;
233
+ }
234
+
235
+ export const TEAM_CYCLE_ROW_SHAPE = shape<CycleRow>({
236
+ id: "string",
237
+ number: "number",
238
+ name: "string|null",
239
+ startsAt: "string|null",
240
+ endsAt: "string|null",
241
+ });
242
+
243
+ export async function listCycles(
244
+ client: LinearClient,
245
+ keyArg: string | undefined,
246
+ defaultTeamKey: string | undefined,
247
+ limit: number,
248
+ ): Promise<CycleRow[]> {
249
+ const resolved = await resolveTeam(client, keyArg, defaultTeamKey);
250
+ const team = await withRetry(() => client.team(resolved.id));
251
+ const nodes = await collect((await withRetry(() => team.cycles())) as any, Infinity);
252
+ const rows = nodes
253
+ .map((c: any) => ({
254
+ id: c.id,
255
+ number: c.number,
256
+ name: c.name ?? null,
257
+ startsAt: c.startsAt?.toISOString?.() ?? (c.startsAt ? String(c.startsAt) : null),
258
+ endsAt: c.endsAt?.toISOString?.() ?? (c.endsAt ? String(c.endsAt) : null),
259
+ }))
260
+ .sort((a, b) => b.number - a.number);
261
+ return setPaginationMetadata(
262
+ limit === Infinity ? rows : rows.slice(0, limit),
263
+ rows.length > limit,
264
+ );
265
+ }
266
+
267
+ export interface CreateTeamOptions {
268
+ name: string;
269
+ key?: string;
270
+ description?: string;
271
+ /** A private team is visible to its members only. Sent only when true (Linear's default is public). */
272
+ private?: boolean;
273
+ }
274
+
275
+ /** Create a team. `name` is required; `key` is generated by Linear when omitted. */
276
+ export async function createTeam(client: LinearClient, opts: CreateTeamOptions) {
277
+ const input: Record<string, any> = { name: opts.name };
278
+ if (opts.key !== undefined) input.key = opts.key;
279
+ if (opts.description !== undefined) input.description = opts.description;
280
+ if (opts.private) input.private = true;
281
+ return unwrapMutation(
282
+ withRetry(() => client.createTeam(input as any)),
283
+ "team",
284
+ "Team creation",
285
+ );
286
+ }
287
+
288
+ export interface UpdateTeamOptions {
289
+ name?: string;
290
+ key?: string;
291
+ description?: string;
292
+ }
293
+
294
+ export async function updateTeam(
295
+ client: LinearClient,
296
+ keyArg: string | undefined,
297
+ defaultTeamKey: string | undefined,
298
+ opts: UpdateTeamOptions,
299
+ ) {
300
+ const resolved = await resolveTeam(client, keyArg, defaultTeamKey);
301
+ const input: Record<string, any> = {};
302
+ if (opts.name !== undefined) input.name = opts.name;
303
+ if (opts.key !== undefined) input.key = opts.key;
304
+ if (opts.description !== undefined) input.description = opts.description;
305
+ if (Object.keys(input).length === 0)
306
+ throw usageError("Nothing to update; pass at least one field (--name/--key/--description).");
307
+ return unwrapMutation(
308
+ withRetry(() => client.updateTeam(resolved.id, input as any)),
309
+ "team",
310
+ "Team update",
311
+ );
312
+ }
313
+
314
+ /** What `team delete` needs to know before it asks: the team, and how much goes with it. */
315
+ export interface DeleteTeamPlan {
316
+ team: ResolvedTeam;
317
+ /** The team's live issues — the ones a delete takes with it, or `--move-issues` rescues. */
318
+ issueCount: number;
319
+ /** Set when `--move-issues` named a (different, existing) team. */
320
+ moveTo?: ResolvedTeam;
321
+ }
322
+
323
+ /**
324
+ * Resolve everything `team delete` will act on, so the confirmation can name
325
+ * the team, the issue count and the destination before anything is written.
326
+ * The key is required — a delete must never fall through to the configured
327
+ * default team the way `team view` does.
328
+ */
329
+ export async function planDeleteTeam(
330
+ client: LinearClient,
331
+ keyArg: string,
332
+ moveIssuesTo: string | undefined,
333
+ ): Promise<DeleteTeamPlan> {
334
+ const team = await resolveTeam(client, keyArg, undefined);
335
+ const model = await withRetry(() => client.team(team.id));
336
+ const plan: DeleteTeamPlan = { team, issueCount: model.issueCount ?? 0 };
337
+ if (moveIssuesTo !== undefined) {
338
+ const moveTo = await resolveTeam(client, moveIssuesTo, undefined);
339
+ if (moveTo.id === team.id) {
340
+ throw usageError(`--move-issues names ${team.key} itself; pick a different team.`);
341
+ }
342
+ plan.moveTo = moveTo;
343
+ }
344
+ return plan;
345
+ }
346
+
347
+ /** Linear's cap on `issueBatchUpdate` ids per call. */
348
+ const MOVE_BATCH = 50;
349
+
350
+ /**
351
+ * Move every live issue on `from` to `to`, in batches. Returns how many moved.
352
+ * Archived and trashed issues stay where they are — the API's team listing
353
+ * excludes them and Linear takes them along with the team.
354
+ */
355
+ export async function moveTeamIssues(
356
+ client: LinearClient,
357
+ from: ResolvedTeam,
358
+ to: ResolvedTeam,
359
+ ): Promise<number> {
360
+ const team = await withRetry(() => client.team(from.id));
361
+ const issues = await collect(
362
+ (await withRetry(() => team.issues({ first: 100 }))) as any,
363
+ Infinity,
364
+ );
365
+ const ids = issues.map((i: any) => i.id as string);
366
+ for (let i = 0; i < ids.length; i += MOVE_BATCH) {
367
+ const batch = ids.slice(i, i + MOVE_BATCH);
368
+ await assertMutation(
369
+ withRetry(() => client.updateIssueBatch(batch, { teamId: to.id })),
370
+ `Moving ${batch.length} issue(s) to ${to.key}`,
371
+ );
372
+ }
373
+ return ids.length;
374
+ }
375
+
376
+ /**
377
+ * Delete a team (`teamDelete`). Linear archives the team and everything on it;
378
+ * a plan that does not allow it answers with an API error, which reaches the
379
+ * user as `feature_not_accessible` like every other plan gate.
380
+ */
381
+ export async function deleteTeam(client: LinearClient, team: ResolvedTeam): Promise<void> {
382
+ await assertMutation(
383
+ withRetry(() => client.deleteTeam(team.id)),
384
+ "Team deletion",
385
+ );
386
+ }