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,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organization service: all SDK access for the workspace lives here so commands
|
|
3
|
+
* stay thin. This is a read-only group — workspace `view`, member listing, and
|
|
4
|
+
* pending-invite listing. Admin/destructive operations are intentionally out of
|
|
5
|
+
* scope.
|
|
6
|
+
*
|
|
7
|
+
* The single `view` uses the typed `client.organization` getter; members and
|
|
8
|
+
* invites use the typed SDK connections and paginate via `collect()`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LinearClient } from "@linear/sdk";
|
|
12
|
+
import { withRetry } from "../client.js";
|
|
13
|
+
import { shape } from "../lib/shape.js";
|
|
14
|
+
import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
|
|
15
|
+
|
|
16
|
+
export interface OrganizationDetail {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
urlKey: string;
|
|
20
|
+
userCount: number;
|
|
21
|
+
createdIssueCount: number;
|
|
22
|
+
samlEnabled: boolean;
|
|
23
|
+
scimEnabled: boolean;
|
|
24
|
+
roadmapEnabled: boolean;
|
|
25
|
+
logoUrl: string | null;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The detail's shape as `linear commands` advertises it (TES-610); checked against the interface. */
|
|
31
|
+
export const ORGANIZATION_DETAIL_SHAPE = shape<OrganizationDetail>({
|
|
32
|
+
id: "string",
|
|
33
|
+
name: "string",
|
|
34
|
+
urlKey: "string",
|
|
35
|
+
userCount: "number",
|
|
36
|
+
createdIssueCount: "number",
|
|
37
|
+
samlEnabled: "boolean",
|
|
38
|
+
scimEnabled: "boolean",
|
|
39
|
+
roadmapEnabled: "boolean",
|
|
40
|
+
logoUrl: "string|null",
|
|
41
|
+
createdAt: "string",
|
|
42
|
+
updatedAt: "string",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export async function getOrganizationDetail(client: LinearClient): Promise<OrganizationDetail> {
|
|
46
|
+
// `client.organization` is a getter that returns a LinearFetch<Organization>.
|
|
47
|
+
const org = await withRetry(() => client.organization);
|
|
48
|
+
return {
|
|
49
|
+
id: org.id,
|
|
50
|
+
name: org.name,
|
|
51
|
+
urlKey: org.urlKey,
|
|
52
|
+
userCount: org.userCount,
|
|
53
|
+
createdIssueCount: org.createdIssueCount,
|
|
54
|
+
samlEnabled: !!org.samlEnabled,
|
|
55
|
+
scimEnabled: !!org.scimEnabled,
|
|
56
|
+
roadmapEnabled: !!org.roadmapEnabled,
|
|
57
|
+
logoUrl: org.logoUrl ?? null,
|
|
58
|
+
createdAt: org.createdAt.toISOString(),
|
|
59
|
+
updatedAt: org.updatedAt.toISOString(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MemberRow {
|
|
64
|
+
id: string;
|
|
65
|
+
displayName: string;
|
|
66
|
+
name: string;
|
|
67
|
+
email: string;
|
|
68
|
+
admin: boolean;
|
|
69
|
+
active: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const ORGANIZATION_MEMBER_ROW_SHAPE = shape<MemberRow>({
|
|
73
|
+
id: "string",
|
|
74
|
+
displayName: "string",
|
|
75
|
+
name: "string",
|
|
76
|
+
email: "string",
|
|
77
|
+
admin: "boolean",
|
|
78
|
+
active: "boolean",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** List workspace users (members). Paginates to the requested limit. */
|
|
82
|
+
export async function listMembers(client: LinearClient, limit: number): Promise<MemberRow[]> {
|
|
83
|
+
const conn = await withRetry(() => client.users({ first: pageSize(limit) }));
|
|
84
|
+
const nodes = await collect(conn as any, limit);
|
|
85
|
+
return inheritPaginationMetadata(
|
|
86
|
+
nodes.map((u: any) => ({
|
|
87
|
+
id: u.id,
|
|
88
|
+
displayName: u.displayName,
|
|
89
|
+
name: u.name,
|
|
90
|
+
email: u.email,
|
|
91
|
+
admin: !!u.admin,
|
|
92
|
+
active: !!u.active,
|
|
93
|
+
})),
|
|
94
|
+
nodes,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface InviteRow {
|
|
99
|
+
id: string;
|
|
100
|
+
email: string;
|
|
101
|
+
status: string;
|
|
102
|
+
role: string;
|
|
103
|
+
external: boolean;
|
|
104
|
+
createdAt: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const INVITE_ROW_SHAPE = shape<InviteRow>({
|
|
108
|
+
id: "string",
|
|
109
|
+
email: "string",
|
|
110
|
+
status: "string",
|
|
111
|
+
role: "string",
|
|
112
|
+
external: "boolean",
|
|
113
|
+
createdAt: "string",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Derive a human status for an invite. The `OrganizationInvite` model exposes
|
|
118
|
+
* `acceptedAt`/`expiresAt` rather than a status enum, so we classify from those:
|
|
119
|
+
* accepted > expired > pending.
|
|
120
|
+
*/
|
|
121
|
+
export function inviteStatus(invite: { acceptedAt?: unknown; expiresAt?: unknown }): string {
|
|
122
|
+
if (invite.acceptedAt) return "accepted";
|
|
123
|
+
if (invite.expiresAt && new Date(invite.expiresAt as any).getTime() < Date.now()) {
|
|
124
|
+
return "expired";
|
|
125
|
+
}
|
|
126
|
+
return "pending";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** List organization invites. Tolerates an empty list (returns `[]`). */
|
|
130
|
+
export async function listInvites(client: LinearClient, limit: number): Promise<InviteRow[]> {
|
|
131
|
+
const conn = await withRetry(() => client.organizationInvites({ first: pageSize(limit) }));
|
|
132
|
+
const nodes = await collect(conn as any, limit);
|
|
133
|
+
return inheritPaginationMetadata(
|
|
134
|
+
nodes.map((i: any) => ({
|
|
135
|
+
id: i.id,
|
|
136
|
+
email: i.email,
|
|
137
|
+
status: inviteStatus(i),
|
|
138
|
+
role: i.role ?? "member",
|
|
139
|
+
external: !!i.external,
|
|
140
|
+
createdAt: i.createdAt?.toISOString?.() ?? String(i.createdAt),
|
|
141
|
+
})),
|
|
142
|
+
nodes,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-update service: create a project status update, and list a project's
|
|
3
|
+
* updates. The list reuses the existing `listUpdates` in `services/project.ts`
|
|
4
|
+
* (re-exported here as `listProjectUpdates` so the new command group owns a
|
|
5
|
+
* coherent surface). All SDK calls are wrapped in `withRetry`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LinearClient } from "@linear/sdk";
|
|
9
|
+
import { withRetry } from "../client.js";
|
|
10
|
+
import { resolveProjectId } from "../lib/resolve.js";
|
|
11
|
+
import { normalizeUpdatePayload, type Health, type UpdateRow } from "../lib/status-update.js";
|
|
12
|
+
import { listUpdates } from "./project.js";
|
|
13
|
+
|
|
14
|
+
export type { UpdateRow };
|
|
15
|
+
|
|
16
|
+
/** List a project's status updates (by project name or id). */
|
|
17
|
+
export const listProjectUpdates = listUpdates;
|
|
18
|
+
|
|
19
|
+
export interface CreateProjectUpdateOptions {
|
|
20
|
+
body: string;
|
|
21
|
+
health?: Health;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Create a status update on a project (resolved by name or id). Returns a normalized row. */
|
|
25
|
+
export async function createProjectUpdate(
|
|
26
|
+
client: LinearClient,
|
|
27
|
+
idArg: string,
|
|
28
|
+
opts: CreateProjectUpdateOptions,
|
|
29
|
+
): Promise<UpdateRow & { url: string }> {
|
|
30
|
+
const projectId = await resolveProjectId(client, idArg);
|
|
31
|
+
const input: Record<string, any> = { projectId, body: opts.body };
|
|
32
|
+
if (opts.health) input.health = opts.health;
|
|
33
|
+
|
|
34
|
+
const payload = await withRetry(() => client.createProjectUpdate(input as any));
|
|
35
|
+
return normalizeUpdatePayload(payload, "projectUpdate");
|
|
36
|
+
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project service: all SDK access for projects lives here so commands stay thin.
|
|
3
|
+
*
|
|
4
|
+
* Lists use a tailored GraphQL query (one round-trip, no N+1 on status/lead);
|
|
5
|
+
* single `view`, milestones, updates and all mutations use the typed SDK models.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LinearClient } from "@linear/sdk";
|
|
9
|
+
import { withRetry } from "../client.js";
|
|
10
|
+
import { shape } from "../lib/shape.js";
|
|
11
|
+
import {
|
|
12
|
+
collect,
|
|
13
|
+
collectRawQuery,
|
|
14
|
+
inheritPaginationMetadata,
|
|
15
|
+
pageSize,
|
|
16
|
+
} from "../lib/pagination.js";
|
|
17
|
+
import { usageError, notFound, ambiguous } from "../lib/errors.js";
|
|
18
|
+
import { assertMutation, unwrapMutation } from "../lib/mutation.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveTeam,
|
|
21
|
+
resolveUserId,
|
|
22
|
+
resolveProjectId,
|
|
23
|
+
resolveProjectLabelIds,
|
|
24
|
+
isUuid,
|
|
25
|
+
} from "../lib/resolve.js";
|
|
26
|
+
|
|
27
|
+
export interface ProjectRow {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
state: string | null;
|
|
31
|
+
progress: number | null;
|
|
32
|
+
url: string;
|
|
33
|
+
startDate: string | null;
|
|
34
|
+
targetDate: string | null;
|
|
35
|
+
status: { name: string } | null;
|
|
36
|
+
lead: { displayName: string } | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
|
|
40
|
+
export const PROJECT_ROW_SHAPE = shape<ProjectRow>({
|
|
41
|
+
id: "string",
|
|
42
|
+
name: "string",
|
|
43
|
+
state: "string|null",
|
|
44
|
+
progress: "number|null",
|
|
45
|
+
url: "string",
|
|
46
|
+
startDate: "string|null",
|
|
47
|
+
targetDate: "string|null",
|
|
48
|
+
status: { nullable: { name: "string" } },
|
|
49
|
+
lead: { nullable: { displayName: "string" } },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export interface ListFilters {
|
|
53
|
+
team?: string;
|
|
54
|
+
/** Every team's projects: no team clause at all, and the default team is not applied. */
|
|
55
|
+
allTeams?: boolean;
|
|
56
|
+
state?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const LIST_QUERY = `
|
|
60
|
+
query CliProjects($filter: ProjectFilter, $first: Int!, $after: String, $includeArchived: Boolean) {
|
|
61
|
+
projects(filter: $filter, first: $first, after: $after, includeArchived: $includeArchived) {
|
|
62
|
+
nodes {
|
|
63
|
+
id name state progress url startDate targetDate
|
|
64
|
+
status { name }
|
|
65
|
+
lead { displayName }
|
|
66
|
+
}
|
|
67
|
+
pageInfo { hasNextPage endCursor }
|
|
68
|
+
}
|
|
69
|
+
}`;
|
|
70
|
+
|
|
71
|
+
/** Build a ProjectFilter from human options, resolving names to ids. Exported for tests. */
|
|
72
|
+
export async function buildFilter(
|
|
73
|
+
client: LinearClient,
|
|
74
|
+
f: ListFilters,
|
|
75
|
+
defaultTeamKey: string | undefined,
|
|
76
|
+
): Promise<Record<string, unknown>> {
|
|
77
|
+
const filter: Record<string, any> = {};
|
|
78
|
+
// Without a team the list is scoped to the configured default, so a project
|
|
79
|
+
// in another team is invisible unless `--all-teams` says the whole workspace
|
|
80
|
+
// (the only other way out used to be the accidental `--team ''`).
|
|
81
|
+
const teamKey = f.allTeams ? undefined : (f.team ?? defaultTeamKey);
|
|
82
|
+
if (teamKey) {
|
|
83
|
+
filter.accessibleTeams = { some: { key: { eq: teamKey.toUpperCase() } } };
|
|
84
|
+
}
|
|
85
|
+
if (f.state) {
|
|
86
|
+
// Match either the custom status NAME shown in the UI ("In QA") or the
|
|
87
|
+
// underlying status TYPE ("started"), case-insensitively, so both
|
|
88
|
+
// vocabularies work.
|
|
89
|
+
//
|
|
90
|
+
// NOT `filter.state`: that targets the deprecated legacy `Project.state`
|
|
91
|
+
// field, which the API silently ignores — every value, valid or not,
|
|
92
|
+
// returned the unfiltered list.
|
|
93
|
+
filter.status = {
|
|
94
|
+
or: [{ name: { eqIgnoreCase: f.state } }, { type: { eqIgnoreCase: f.state } }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return filter;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function listProjects(
|
|
101
|
+
client: LinearClient,
|
|
102
|
+
filters: ListFilters,
|
|
103
|
+
limit: number,
|
|
104
|
+
defaultTeamKey: string | undefined,
|
|
105
|
+
): Promise<ProjectRow[]> {
|
|
106
|
+
const filter = await buildFilter(client, filters, defaultTeamKey);
|
|
107
|
+
return collectRawQuery<ProjectRow>(
|
|
108
|
+
client as any,
|
|
109
|
+
LIST_QUERY,
|
|
110
|
+
{ filter, includeArchived: false },
|
|
111
|
+
"projects",
|
|
112
|
+
limit,
|
|
113
|
+
(n) => ({
|
|
114
|
+
id: n.id,
|
|
115
|
+
name: n.name,
|
|
116
|
+
state: n.state ?? null,
|
|
117
|
+
progress: n.progress ?? null,
|
|
118
|
+
url: n.url,
|
|
119
|
+
startDate: n.startDate ?? null,
|
|
120
|
+
targetDate: n.targetDate ?? null,
|
|
121
|
+
status: n.status ?? null,
|
|
122
|
+
lead: n.lead ?? null,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A single project with every relation `view` shows. Relations are objects
|
|
129
|
+
* with ids — `status` matches the list row's `status: { name }` and adds
|
|
130
|
+
* `id`/`type`; `lead` matches the row's `lead: { displayName }` and adds
|
|
131
|
+
* `id`/`email`. `teams` used to be `"KEY name"` strings, which a script could
|
|
132
|
+
* not split back apart (team names contain spaces).
|
|
133
|
+
*/
|
|
134
|
+
export interface ProjectDetail {
|
|
135
|
+
id: string;
|
|
136
|
+
name: string;
|
|
137
|
+
description: string | null;
|
|
138
|
+
/** The project's markdown body (Project.content), not the one-line description. */
|
|
139
|
+
content: string | null;
|
|
140
|
+
labels: Array<{ id: string; name: string }>;
|
|
141
|
+
state: string | null;
|
|
142
|
+
status: { id: string; name: string; type: string } | null;
|
|
143
|
+
health: string | null;
|
|
144
|
+
progress: number | null;
|
|
145
|
+
priority: number;
|
|
146
|
+
priorityLabel: string;
|
|
147
|
+
url: string;
|
|
148
|
+
startDate: string | null;
|
|
149
|
+
targetDate: string | null;
|
|
150
|
+
createdAt: string;
|
|
151
|
+
updatedAt: string;
|
|
152
|
+
completedAt: string | null;
|
|
153
|
+
archivedAt: string | null;
|
|
154
|
+
lead: { id: string; displayName: string; email: string } | null;
|
|
155
|
+
teams: Array<{ id: string; key: string; name: string }>;
|
|
156
|
+
members: Array<{ id: string; displayName: string; email: string }>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The detail's shape; checked against `ProjectDetail`. */
|
|
160
|
+
export const PROJECT_DETAIL_SHAPE = shape<ProjectDetail>({
|
|
161
|
+
id: "string",
|
|
162
|
+
name: "string",
|
|
163
|
+
description: "string|null",
|
|
164
|
+
content: "string|null",
|
|
165
|
+
labels: [{ id: "string", name: "string" }],
|
|
166
|
+
state: "string|null",
|
|
167
|
+
status: { nullable: { id: "string", name: "string", type: "string" } },
|
|
168
|
+
health: "string|null",
|
|
169
|
+
progress: "number|null",
|
|
170
|
+
priority: "number",
|
|
171
|
+
priorityLabel: "string",
|
|
172
|
+
url: "string",
|
|
173
|
+
startDate: "string|null",
|
|
174
|
+
targetDate: "string|null",
|
|
175
|
+
createdAt: "string",
|
|
176
|
+
updatedAt: "string",
|
|
177
|
+
completedAt: "string|null",
|
|
178
|
+
archivedAt: "string|null",
|
|
179
|
+
lead: { nullable: { id: "string", displayName: "string", email: "string" } },
|
|
180
|
+
teams: [{ id: "string", key: "string", name: "string" }],
|
|
181
|
+
members: [{ id: "string", displayName: "string", email: "string" }],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Everything `project view` shows, in one round-trip: the name lookup, the
|
|
186
|
+
* project, and its relations, which the SDK-model path fetched one request
|
|
187
|
+
* each (7 requests, measured). A UUID filters by id; anything else is a
|
|
188
|
+
* case-insensitive name match — the same rule as `resolveProjectId`, with the
|
|
189
|
+
* same not-found/ambiguous outcomes. An id finds an archived project too (as
|
|
190
|
+
* `client.project(id)` did); a name matches live projects only (as
|
|
191
|
+
* `resolveProjectId` does), so an archived namesake cannot make a live project
|
|
192
|
+
* ambiguous.
|
|
193
|
+
*
|
|
194
|
+
* `first: 2`, not a full page: a second match already means "ambiguous", and
|
|
195
|
+
* Linear prices a query by its worst case — 250 projects × three nested
|
|
196
|
+
* 50-item connections was refused as too complex (49 975 against a cap of
|
|
197
|
+
* 10 000, verified live).
|
|
198
|
+
*/
|
|
199
|
+
const DETAIL_QUERY = `
|
|
200
|
+
query CliProjectDetail($filter: ProjectFilter!, $includeArchived: Boolean!) {
|
|
201
|
+
projects(filter: $filter, first: 2, includeArchived: $includeArchived) {
|
|
202
|
+
nodes {
|
|
203
|
+
id name description content state health progress priority priorityLabel url
|
|
204
|
+
startDate targetDate createdAt updatedAt completedAt archivedAt
|
|
205
|
+
status { id name type }
|
|
206
|
+
lead { id displayName email }
|
|
207
|
+
labels(first: 50) { nodes { id name } }
|
|
208
|
+
teams(first: 50) { nodes { id key name } }
|
|
209
|
+
members(first: 50) { nodes { id displayName email } }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}`;
|
|
213
|
+
|
|
214
|
+
export async function getProjectDetail(
|
|
215
|
+
client: LinearClient,
|
|
216
|
+
idArg: string,
|
|
217
|
+
): Promise<ProjectDetail> {
|
|
218
|
+
const byId = isUuid(idArg);
|
|
219
|
+
const filter = byId ? { id: { eq: idArg } } : { name: { eqIgnoreCase: idArg } };
|
|
220
|
+
const data: any = await withRetry(() =>
|
|
221
|
+
(client as any).client.rawRequest(DETAIL_QUERY, { filter, includeArchived: byId }),
|
|
222
|
+
);
|
|
223
|
+
const nodes: any[] = data.data?.projects?.nodes ?? [];
|
|
224
|
+
if (nodes.length === 0)
|
|
225
|
+
throw notFound(`No project matching '${idArg}'. Run 'linear project list' to see the options.`);
|
|
226
|
+
if (nodes.length > 1)
|
|
227
|
+
throw ambiguous(
|
|
228
|
+
`Multiple projects match '${idArg}': ${nodes.map((p) => p.name).join(", ")}. Pass the project id instead.`,
|
|
229
|
+
);
|
|
230
|
+
const p = nodes[0];
|
|
231
|
+
return {
|
|
232
|
+
id: p.id,
|
|
233
|
+
name: p.name,
|
|
234
|
+
description: p.description || null,
|
|
235
|
+
content: p.content || null,
|
|
236
|
+
labels: p.labels?.nodes ?? [],
|
|
237
|
+
state: p.state ?? null,
|
|
238
|
+
status: p.status ?? null,
|
|
239
|
+
health: p.health ?? null,
|
|
240
|
+
progress: p.progress ?? null,
|
|
241
|
+
priority: p.priority,
|
|
242
|
+
priorityLabel: p.priorityLabel,
|
|
243
|
+
url: p.url,
|
|
244
|
+
startDate: p.startDate ?? null,
|
|
245
|
+
targetDate: p.targetDate ?? null,
|
|
246
|
+
createdAt: p.createdAt,
|
|
247
|
+
updatedAt: p.updatedAt,
|
|
248
|
+
completedAt: p.completedAt ?? null,
|
|
249
|
+
archivedAt: p.archivedAt ?? null,
|
|
250
|
+
lead: p.lead ?? null,
|
|
251
|
+
teams: p.teams?.nodes ?? [],
|
|
252
|
+
members: p.members?.nodes ?? [],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface CreateOptions {
|
|
257
|
+
name: string;
|
|
258
|
+
description?: string;
|
|
259
|
+
/** The project's markdown body — distinct from the one-line `description`. */
|
|
260
|
+
content?: string;
|
|
261
|
+
team?: string[];
|
|
262
|
+
lead?: string;
|
|
263
|
+
state?: string;
|
|
264
|
+
startDate?: string;
|
|
265
|
+
targetDate?: string;
|
|
266
|
+
priority?: number;
|
|
267
|
+
label?: string[];
|
|
268
|
+
member?: string[];
|
|
269
|
+
icon?: string;
|
|
270
|
+
color?: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Build a ProjectCreateInput, resolving every human reference to an id. */
|
|
274
|
+
export async function createProject(
|
|
275
|
+
client: LinearClient,
|
|
276
|
+
opts: CreateOptions,
|
|
277
|
+
defaultTeamKey: string | undefined,
|
|
278
|
+
) {
|
|
279
|
+
const teamIds = await resolveTeamIds(client, opts.team, defaultTeamKey);
|
|
280
|
+
const input: Record<string, any> = { name: opts.name, teamIds };
|
|
281
|
+
if (opts.description !== undefined) input.description = opts.description;
|
|
282
|
+
if (opts.content !== undefined) input.content = opts.content;
|
|
283
|
+
if (opts.state) input.statusId = await resolveStatusId(client, opts.state);
|
|
284
|
+
if (opts.lead) input.leadId = await resolveUserId(client, opts.lead);
|
|
285
|
+
if (opts.startDate) input.startDate = opts.startDate;
|
|
286
|
+
if (opts.targetDate) input.targetDate = opts.targetDate;
|
|
287
|
+
if (opts.priority !== undefined) input.priority = resolvePriority(opts.priority);
|
|
288
|
+
if (opts.label?.length) input.labelIds = await resolveProjectLabelIds(client, opts.label);
|
|
289
|
+
if (opts.member?.length) input.memberIds = await resolveMemberIds(client, opts.member);
|
|
290
|
+
if (opts.icon) input.icon = opts.icon;
|
|
291
|
+
if (opts.color) input.color = opts.color;
|
|
292
|
+
|
|
293
|
+
return unwrapMutation(
|
|
294
|
+
withRetry(() => client.createProject(input as any)),
|
|
295
|
+
"project",
|
|
296
|
+
"Project creation",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface UpdateOptions {
|
|
301
|
+
name?: string;
|
|
302
|
+
description?: string;
|
|
303
|
+
/** The project's markdown body — distinct from the one-line `description`. */
|
|
304
|
+
content?: string;
|
|
305
|
+
team?: string[];
|
|
306
|
+
lead?: string;
|
|
307
|
+
state?: string;
|
|
308
|
+
startDate?: string;
|
|
309
|
+
targetDate?: string;
|
|
310
|
+
priority?: number;
|
|
311
|
+
/** Replaces the whole label set. */
|
|
312
|
+
label?: string[];
|
|
313
|
+
/** Replaces the whole member set. */
|
|
314
|
+
member?: string[];
|
|
315
|
+
icon?: string;
|
|
316
|
+
color?: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function updateProject(client: LinearClient, idArg: string, opts: UpdateOptions) {
|
|
320
|
+
const projectId = await resolveProjectId(client, idArg);
|
|
321
|
+
const input: Record<string, any> = {};
|
|
322
|
+
if (opts.name !== undefined) input.name = opts.name;
|
|
323
|
+
if (opts.description !== undefined) input.description = opts.description;
|
|
324
|
+
if (opts.content !== undefined) input.content = opts.content;
|
|
325
|
+
if (opts.team?.length) input.teamIds = await resolveTeamIds(client, opts.team, undefined);
|
|
326
|
+
if (opts.lead) input.leadId = await resolveUserId(client, opts.lead);
|
|
327
|
+
if (opts.state) input.statusId = await resolveStatusId(client, opts.state);
|
|
328
|
+
if (opts.startDate) input.startDate = opts.startDate;
|
|
329
|
+
if (opts.targetDate) input.targetDate = opts.targetDate;
|
|
330
|
+
if (opts.priority !== undefined) input.priority = resolvePriority(opts.priority);
|
|
331
|
+
if (opts.label) input.labelIds = await resolveProjectLabelIds(client, opts.label);
|
|
332
|
+
if (opts.member) input.memberIds = await resolveMemberIds(client, opts.member);
|
|
333
|
+
if (opts.icon) input.icon = opts.icon;
|
|
334
|
+
if (opts.color) input.color = opts.color;
|
|
335
|
+
|
|
336
|
+
if (Object.keys(input).length === 0)
|
|
337
|
+
throw usageError("Nothing to update; pass at least one field.");
|
|
338
|
+
return unwrapMutation(
|
|
339
|
+
withRetry(() => client.updateProject(projectId, input as any)),
|
|
340
|
+
"project",
|
|
341
|
+
"Project update",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function archiveProject(client: LinearClient, idArg: string) {
|
|
346
|
+
const projectId = await resolveProjectId(client, idArg);
|
|
347
|
+
const project = await withRetry(() => client.project(projectId));
|
|
348
|
+
await assertMutation(
|
|
349
|
+
withRetry(() => client.archiveProject(projectId)),
|
|
350
|
+
"Project archive",
|
|
351
|
+
);
|
|
352
|
+
return project;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Trash a project (`projectDelete`). Distinct from `archiveProject`: an
|
|
357
|
+
* archived project stays in the workspace, read-only; a trashed one is gone
|
|
358
|
+
* from every list and is purged after Linear's grace period. Returns the
|
|
359
|
+
* project as it was, for the receipt.
|
|
360
|
+
*/
|
|
361
|
+
export async function deleteProject(client: LinearClient, idArg: string) {
|
|
362
|
+
const projectId = await resolveProjectId(client, idArg);
|
|
363
|
+
const project = await withRetry(() => client.project(projectId));
|
|
364
|
+
await assertMutation(
|
|
365
|
+
withRetry(() => client.deleteProject(projectId)),
|
|
366
|
+
"Project deletion",
|
|
367
|
+
);
|
|
368
|
+
return project;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface MilestoneRow {
|
|
372
|
+
id: string;
|
|
373
|
+
name: string;
|
|
374
|
+
targetDate: string | null;
|
|
375
|
+
progress: number | null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** `project milestones` rows; checked against this file's `MilestoneRow`. */
|
|
379
|
+
export const PROJECT_MILESTONE_ROW_SHAPE = shape<MilestoneRow>({
|
|
380
|
+
id: "string",
|
|
381
|
+
name: "string",
|
|
382
|
+
targetDate: "string|null",
|
|
383
|
+
progress: "number|null",
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
export async function listMilestones(
|
|
387
|
+
client: LinearClient,
|
|
388
|
+
idArg: string,
|
|
389
|
+
limit: number,
|
|
390
|
+
): Promise<MilestoneRow[]> {
|
|
391
|
+
const projectId = await resolveProjectId(client, idArg);
|
|
392
|
+
const project = await withRetry(() => client.project(projectId));
|
|
393
|
+
const conn = await withRetry(() => project.projectMilestones({ first: pageSize(limit) }));
|
|
394
|
+
const nodes = await collect(conn as any, limit);
|
|
395
|
+
return inheritPaginationMetadata(
|
|
396
|
+
nodes.map((m: any) => ({
|
|
397
|
+
id: m.id,
|
|
398
|
+
name: m.name,
|
|
399
|
+
targetDate: m.targetDate ?? null,
|
|
400
|
+
progress: m.progress ?? null,
|
|
401
|
+
})),
|
|
402
|
+
nodes,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export interface UpdateRow {
|
|
407
|
+
id: string;
|
|
408
|
+
createdAt: string;
|
|
409
|
+
user: string;
|
|
410
|
+
body: string;
|
|
411
|
+
health: string | null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function listUpdates(
|
|
415
|
+
client: LinearClient,
|
|
416
|
+
idArg: string,
|
|
417
|
+
limit: number,
|
|
418
|
+
): Promise<UpdateRow[]> {
|
|
419
|
+
const projectId = await resolveProjectId(client, idArg);
|
|
420
|
+
const project = await withRetry(() => client.project(projectId));
|
|
421
|
+
const conn = await withRetry(() => project.projectUpdates({ first: pageSize(limit) }));
|
|
422
|
+
const nodes = await collect(conn as any, limit);
|
|
423
|
+
return inheritPaginationMetadata(
|
|
424
|
+
await Promise.all(
|
|
425
|
+
nodes.map(async (u: any) => {
|
|
426
|
+
const user = await u.user;
|
|
427
|
+
return {
|
|
428
|
+
id: u.id,
|
|
429
|
+
createdAt: u.createdAt?.toISOString?.() ?? String(u.createdAt),
|
|
430
|
+
user: user?.displayName ?? "unknown",
|
|
431
|
+
body: u.body ?? "",
|
|
432
|
+
health: u.health ?? null,
|
|
433
|
+
};
|
|
434
|
+
}),
|
|
435
|
+
),
|
|
436
|
+
nodes,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Resolve one or more team references (key/name/id) to team ids; default when none. */
|
|
441
|
+
/** Validate a project priority (same 0-4 scale as issues). */
|
|
442
|
+
export function resolvePriority(input: number): number {
|
|
443
|
+
if (!Number.isInteger(input) || input < 0 || input > 4)
|
|
444
|
+
throw usageError(
|
|
445
|
+
`Invalid priority '${input}'. Valid: 0 (none), 1 (urgent), 2 (high), 3 (medium), 4 (low).`,
|
|
446
|
+
);
|
|
447
|
+
return input;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Resolve project members (me|email|name|id), deduplicated. */
|
|
451
|
+
async function resolveMemberIds(client: LinearClient, members: string[]): Promise<string[]> {
|
|
452
|
+
const ids: string[] = [];
|
|
453
|
+
for (const m of members) ids.push(await resolveUserId(client, m));
|
|
454
|
+
return [...new Set(ids)];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function resolveTeamIds(
|
|
458
|
+
client: LinearClient,
|
|
459
|
+
teams: string[] | undefined,
|
|
460
|
+
defaultTeamKey: string | undefined,
|
|
461
|
+
): Promise<string[]> {
|
|
462
|
+
const inputs = teams?.length ? teams : defaultTeamKey ? [defaultTeamKey] : [];
|
|
463
|
+
if (inputs.length === 0) {
|
|
464
|
+
throw usageError("No team specified. Pass --team <KEY> or set a default team in config.");
|
|
465
|
+
}
|
|
466
|
+
const ids: string[] = [];
|
|
467
|
+
for (const t of inputs) {
|
|
468
|
+
ids.push((await resolveTeam(client, t, undefined)).id);
|
|
469
|
+
}
|
|
470
|
+
return ids;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Resolve a project status (by name or type) to a status id. */
|
|
474
|
+
async function resolveStatusId(client: LinearClient, input: string): Promise<string> {
|
|
475
|
+
if (isUuid(input)) return input;
|
|
476
|
+
const conn = await withRetry(() => client.projectStatuses({ first: 250 }));
|
|
477
|
+
const statuses = (await collect(conn as any, Infinity)) as Array<{
|
|
478
|
+
id: string;
|
|
479
|
+
name: string;
|
|
480
|
+
type: string;
|
|
481
|
+
}>;
|
|
482
|
+
const lower = input.toLowerCase();
|
|
483
|
+
const byName = statuses.filter((s) => s.name.toLowerCase() === lower);
|
|
484
|
+
const matches = byName.length ? byName : statuses.filter((s) => s.type.toLowerCase() === lower);
|
|
485
|
+
if (matches.length === 0)
|
|
486
|
+
throw notFound(
|
|
487
|
+
`No project status '${input}'. Available: ${statuses.map((s) => s.name).join(", ")}`,
|
|
488
|
+
);
|
|
489
|
+
if (matches.length > 1)
|
|
490
|
+
throw ambiguous(
|
|
491
|
+
`Multiple project statuses match '${input}': ${matches.map((s) => s.name).join(", ")}`,
|
|
492
|
+
);
|
|
493
|
+
return matches[0]!.id;
|
|
494
|
+
}
|