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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal dependency-free table renderer with truncation + optional color.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
import { usageError } from "../lib/errors.js";
|
|
7
|
+
import { sanitizeForTerminal, displayWidth } from "./sanitize.js";
|
|
8
|
+
|
|
9
|
+
export interface Column<T> {
|
|
10
|
+
/** Stable key used by --fields selection. */
|
|
11
|
+
key: string;
|
|
12
|
+
/** Header label (defaults to key). */
|
|
13
|
+
header?: string;
|
|
14
|
+
/** Cell accessor. */
|
|
15
|
+
value: (row: T) => unknown;
|
|
16
|
+
/** Max width before truncation (default 60). */
|
|
17
|
+
max?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Cut `s` to at most `max` terminal columns, by grapheme, with an ellipsis. */
|
|
21
|
+
function truncate(s: string, max: number): string {
|
|
22
|
+
if (displayWidth(s) <= max) return s;
|
|
23
|
+
if (max <= 1) return sliceToWidth(s, max);
|
|
24
|
+
return sliceToWidth(s, max - 1) + "…";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The longest prefix of `s` that fits in `width` columns — whole graphemes only. */
|
|
28
|
+
function sliceToWidth(s: string, width: number): string {
|
|
29
|
+
let out = "";
|
|
30
|
+
let used = 0;
|
|
31
|
+
for (const { segment } of new Intl.Segmenter().segment(s)) {
|
|
32
|
+
const w = displayWidth(segment);
|
|
33
|
+
if (used + w > width) break;
|
|
34
|
+
out += segment;
|
|
35
|
+
used += w;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A value as a human cell. Every string that reaches a table or a detail block
|
|
42
|
+
* passes through here, and this is where terminal escapes in API data die.
|
|
43
|
+
*
|
|
44
|
+
* A relation object (`{id, name}`, `{displayName}`, `{identifier}`) shows its
|
|
45
|
+
* human name, so `--fields project,assignee` on a row that carries objects
|
|
46
|
+
* reads as a table and not as `[object Object]`.
|
|
47
|
+
*/
|
|
48
|
+
export function cell(v: unknown): string {
|
|
49
|
+
if (v === null || v === undefined) return "";
|
|
50
|
+
if (Array.isArray(v)) return v.map(cell).join(", ");
|
|
51
|
+
if (v instanceof Date) return v.toISOString();
|
|
52
|
+
if (typeof v === "object") {
|
|
53
|
+
const o = v as Record<string, unknown>;
|
|
54
|
+
const name = o.name ?? o.displayName ?? o.identifier ?? o.key ?? o.id;
|
|
55
|
+
return name === undefined ? sanitizeForTerminal(JSON.stringify(v)) : cell(name);
|
|
56
|
+
}
|
|
57
|
+
return sanitizeForTerminal(String(v));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Filter columns to a requested subset (by key or header, case-insensitive),
|
|
62
|
+
* preserving requested order. A field that names no column but IS a key on the
|
|
63
|
+
* rows becomes a column of that key's value — `issue list --fields id,title,
|
|
64
|
+
* labels` used to be `Unknown field 'labels'` while every row carried labels,
|
|
65
|
+
* project, url, updatedAt and estimate. An unknown field is a usage error
|
|
66
|
+
* rather than a silent fall-through to all columns, and it lists both kinds.
|
|
67
|
+
*/
|
|
68
|
+
export function selectColumns<T>(columns: Column<T>[], fields?: string[], sample?: T): Column<T>[] {
|
|
69
|
+
if (!fields || fields.length === 0) return columns;
|
|
70
|
+
const byName = new Map<string, Column<T>>();
|
|
71
|
+
for (const c of columns) {
|
|
72
|
+
byName.set(c.key.toLowerCase(), c);
|
|
73
|
+
if (c.header) byName.set(c.header.toLowerCase(), c);
|
|
74
|
+
}
|
|
75
|
+
const rowKeys = sample && typeof sample === "object" ? Object.keys(sample as object) : [];
|
|
76
|
+
const picked: Column<T>[] = [];
|
|
77
|
+
for (const f of fields) {
|
|
78
|
+
const col = byName.get(f.toLowerCase());
|
|
79
|
+
if (col) {
|
|
80
|
+
picked.push(col);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const key = rowKeys.find((k) => k.toLowerCase() === f.toLowerCase());
|
|
84
|
+
if (key === undefined) {
|
|
85
|
+
const extra = rowKeys.filter((k) => !byName.has(k.toLowerCase()));
|
|
86
|
+
throw usageError(
|
|
87
|
+
`Unknown field '${f}'. Available: ${columns.map((c) => c.key).join(", ")}${
|
|
88
|
+
extra.length ? `; also any row key: ${extra.join(", ")}` : ""
|
|
89
|
+
}.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
picked.push({ key, header: key, value: (row) => (row as Record<string, unknown>)[key] });
|
|
93
|
+
}
|
|
94
|
+
return picked;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `--fields` under `--json`: keep only the named top-level keys of each object,
|
|
99
|
+
* in the order asked. Keys are matched exactly (JSON is case-sensitive), and an
|
|
100
|
+
* unknown key is a usage error naming the real ones — it used to be silently
|
|
101
|
+
* ignored, so `--fields nope --json` exited 0 with every key while
|
|
102
|
+
* `--fields nope` exited 2. An empty list has nothing to check and stays `[]`.
|
|
103
|
+
*/
|
|
104
|
+
export function projectFields<T>(value: T, fields?: string[]): T {
|
|
105
|
+
if (!fields || fields.length === 0) return value;
|
|
106
|
+
const pick = (obj: Record<string, unknown>): Record<string, unknown> => {
|
|
107
|
+
const out: Record<string, unknown> = {};
|
|
108
|
+
for (const f of fields) {
|
|
109
|
+
if (!(f in obj)) {
|
|
110
|
+
throw usageError(`Unknown field '${f}'. Available: ${Object.keys(obj).join(", ")}.`);
|
|
111
|
+
}
|
|
112
|
+
out[f] = obj[f];
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
return value.map((row) =>
|
|
118
|
+
row && typeof row === "object" ? pick(row as Record<string, unknown>) : row,
|
|
119
|
+
) as T;
|
|
120
|
+
}
|
|
121
|
+
if (value && typeof value === "object") return pick(value as Record<string, unknown>) as T;
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `--fields` on a human detail block: keep the pairs whose label matches
|
|
127
|
+
* (case-insensitively), in the order asked. Unknown → usage error naming the
|
|
128
|
+
* labels there are.
|
|
129
|
+
*/
|
|
130
|
+
export function selectPairs(
|
|
131
|
+
pairs: Array<[string, unknown]>,
|
|
132
|
+
fields?: string[],
|
|
133
|
+
): Array<[string, unknown]> {
|
|
134
|
+
if (!fields || fields.length === 0) return pairs;
|
|
135
|
+
const picked: Array<[string, unknown]> = [];
|
|
136
|
+
for (const f of fields) {
|
|
137
|
+
const pair = pairs.find(([label]) => label.toLowerCase() === f.toLowerCase());
|
|
138
|
+
if (!pair) {
|
|
139
|
+
throw usageError(
|
|
140
|
+
`Unknown field '${f}'. Available: ${pairs.map(([label]) => label).join(", ")}.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
picked.push(pair);
|
|
144
|
+
}
|
|
145
|
+
return picked;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function renderTable<T>(
|
|
149
|
+
rows: T[],
|
|
150
|
+
columns: Column<T>[],
|
|
151
|
+
opts: { color?: boolean; empty?: string } = {},
|
|
152
|
+
): string {
|
|
153
|
+
const color = opts.color ?? false;
|
|
154
|
+
const colors = pc.createColors(color);
|
|
155
|
+
if (rows.length === 0) return colors.dim(sanitizeForTerminal(opts.empty ?? "(no results)"));
|
|
156
|
+
|
|
157
|
+
const headers = columns.map((c) => c.header ?? c.key);
|
|
158
|
+
const body = rows.map((row) => columns.map((c) => truncate(cell(c.value(row)), c.max ?? 60)));
|
|
159
|
+
|
|
160
|
+
const widths = columns.map((_, i) => {
|
|
161
|
+
const header = headers[i] ?? "";
|
|
162
|
+
const colMax = body.reduce((m, r) => Math.max(m, displayWidth(r[i] ?? "")), 0);
|
|
163
|
+
return Math.max(displayWidth(header), colMax);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - displayWidth(s)));
|
|
167
|
+
|
|
168
|
+
const headerLine = headers
|
|
169
|
+
.map((h, i) => pad(colors.bold(h), widths[i] ?? 0))
|
|
170
|
+
.join(" ")
|
|
171
|
+
.trimEnd();
|
|
172
|
+
|
|
173
|
+
const lines = body.map((r) =>
|
|
174
|
+
r
|
|
175
|
+
.map((c, i) => pad(c, widths[i] ?? 0))
|
|
176
|
+
.join(" ")
|
|
177
|
+
.trimEnd(),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return [headerLine, ...lines].join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Render a single record as aligned key: value lines. */
|
|
184
|
+
export function renderDetail(
|
|
185
|
+
pairs: Array<[string, unknown]>,
|
|
186
|
+
opts: { color?: boolean } = {},
|
|
187
|
+
): string {
|
|
188
|
+
const color = opts.color ?? false;
|
|
189
|
+
const colors = pc.createColors(color);
|
|
190
|
+
const visible = pairs.filter(([, v]) => v !== undefined && v !== null);
|
|
191
|
+
const keyWidth = visible.reduce((m, [k]) => Math.max(m, k.length), 0);
|
|
192
|
+
return visible
|
|
193
|
+
.map(([k, v]) => {
|
|
194
|
+
const label = (k + ":").padEnd(keyWidth + 1);
|
|
195
|
+
return `${colors.dim(label)} ${cell(v)}`;
|
|
196
|
+
})
|
|
197
|
+
.join("\n");
|
|
198
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-session service: the sessions Linear's agent integrations (Codex,
|
|
3
|
+
* Claude, Cursor, …) open on an issue when they are mentioned or delegated to.
|
|
4
|
+
*
|
|
5
|
+
* Both listings and the single `view` use tailored GraphQL queries: one
|
|
6
|
+
* selection set for the row, so a session looks the same whether it was found
|
|
7
|
+
* through its issue or through the workspace-wide feed, and one round-trip for
|
|
8
|
+
* the detail (the SDK-model path fetches the agent, the creator, the issue and
|
|
9
|
+
* the activities as four more requests). Every field selected is public schema
|
|
10
|
+
* — the SDK's own `AgentSession` model carries the same ones — so this does not
|
|
11
|
+
* depend on the `[Internal]` `Issue.agentSessions` connection.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { LinearClient } from "@linear/sdk";
|
|
15
|
+
import { withRetry } from "../client.js";
|
|
16
|
+
import { shape } from "../lib/shape.js";
|
|
17
|
+
import { collectRawQuery, hasMoreResults, setPaginationMetadata } from "../lib/pagination.js";
|
|
18
|
+
import { notFound } from "../lib/errors.js";
|
|
19
|
+
|
|
20
|
+
/** The statuses Linear reports for a session (AgentSessionStatus). */
|
|
21
|
+
export const AGENT_SESSION_STATUSES = [
|
|
22
|
+
"pending",
|
|
23
|
+
"active",
|
|
24
|
+
"awaitingInput",
|
|
25
|
+
"complete",
|
|
26
|
+
"error",
|
|
27
|
+
"stale",
|
|
28
|
+
] as const;
|
|
29
|
+
export type AgentSessionStatus = (typeof AGENT_SESSION_STATUSES)[number];
|
|
30
|
+
|
|
31
|
+
/** A user as the session references it: the agent (`appUser`) or a human. */
|
|
32
|
+
export interface SessionUser {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
displayName: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SESSION_USER_SHAPE = shape<SessionUser>({
|
|
39
|
+
id: "string",
|
|
40
|
+
name: "string",
|
|
41
|
+
displayName: "string",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export interface AgentSessionRow {
|
|
45
|
+
id: string;
|
|
46
|
+
status: string;
|
|
47
|
+
/** Deprecated upstream (always `commentThread` today); kept because the API still returns it. */
|
|
48
|
+
type: string | null;
|
|
49
|
+
summary: string | null;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
startedAt: string | null;
|
|
52
|
+
endedAt: string | null;
|
|
53
|
+
url: string | null;
|
|
54
|
+
issue: { id: string; identifier: string; title: string } | null;
|
|
55
|
+
/** The agent user the session belongs to (the API's `appUser`). */
|
|
56
|
+
agent: SessionUser | null;
|
|
57
|
+
/** The human who started the session; null when an automation or another agent did. */
|
|
58
|
+
creator: SessionUser | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
|
|
62
|
+
export const AGENT_SESSION_ROW_SHAPE = shape<AgentSessionRow>({
|
|
63
|
+
id: "string",
|
|
64
|
+
status: "string",
|
|
65
|
+
type: "string|null",
|
|
66
|
+
summary: "string|null",
|
|
67
|
+
createdAt: "string",
|
|
68
|
+
startedAt: "string|null",
|
|
69
|
+
endedAt: "string|null",
|
|
70
|
+
url: "string|null",
|
|
71
|
+
issue: { nullable: { id: "string", identifier: "string", title: "string" } },
|
|
72
|
+
agent: { nullable: SESSION_USER_SHAPE },
|
|
73
|
+
creator: { nullable: SESSION_USER_SHAPE },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const ROW_FIELDS = `
|
|
77
|
+
id status type summary createdAt startedAt endedAt url
|
|
78
|
+
issue { id identifier title }
|
|
79
|
+
appUser { id name displayName }
|
|
80
|
+
creator { id name displayName }
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* An issue's sessions, read off its comments: every session is anchored to a
|
|
85
|
+
* comment thread on the issue (`Comment.agentSession`), and that edge is public
|
|
86
|
+
* where `Issue.agentSessions` is not. Comments are paged to exhaustion —
|
|
87
|
+
* sessions are sparse among them, so a limit on comments would not be a limit
|
|
88
|
+
* on sessions — and the caller's limit is applied to the sessions found.
|
|
89
|
+
*/
|
|
90
|
+
const ISSUE_QUERY = `
|
|
91
|
+
query CliIssueAgentSessions($id: String!, $first: Int!, $after: String) {
|
|
92
|
+
issue(id: $id) {
|
|
93
|
+
comments(first: $first, after: $after) {
|
|
94
|
+
nodes { agentSession { ${ROW_FIELDS} } }
|
|
95
|
+
pageInfo { hasNextPage endCursor }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}`;
|
|
99
|
+
|
|
100
|
+
/** The workspace-wide feed, newest first — the SDK's `agentSessions`. */
|
|
101
|
+
const ALL_QUERY = `
|
|
102
|
+
query CliAgentSessions($first: Int!, $after: String) {
|
|
103
|
+
agentSessions(first: $first, after: $after) {
|
|
104
|
+
nodes { ${ROW_FIELDS} }
|
|
105
|
+
pageInfo { hasNextPage endCursor }
|
|
106
|
+
}
|
|
107
|
+
}`;
|
|
108
|
+
|
|
109
|
+
export interface ListOptions {
|
|
110
|
+
/** Keep only sessions in this status (validated by the command's `.choices`). */
|
|
111
|
+
status?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Sessions on one issue (identifier or id), newest first. */
|
|
115
|
+
export async function listIssueAgentSessions(
|
|
116
|
+
client: LinearClient,
|
|
117
|
+
issueId: string,
|
|
118
|
+
limit: number,
|
|
119
|
+
opts: ListOptions = {},
|
|
120
|
+
): Promise<AgentSessionRow[]> {
|
|
121
|
+
const sessions = await collectRawQuery<AgentSessionRow | null>(
|
|
122
|
+
client as any,
|
|
123
|
+
ISSUE_QUERY,
|
|
124
|
+
{ id: issueId },
|
|
125
|
+
"issue.comments",
|
|
126
|
+
Infinity,
|
|
127
|
+
(n) => (n.agentSession ? toRow(n.agentSession) : null),
|
|
128
|
+
);
|
|
129
|
+
return finish(sessions, limit, opts);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Every session in the workspace, newest first. */
|
|
133
|
+
export async function listAllAgentSessions(
|
|
134
|
+
client: LinearClient,
|
|
135
|
+
limit: number,
|
|
136
|
+
opts: ListOptions = {},
|
|
137
|
+
): Promise<AgentSessionRow[]> {
|
|
138
|
+
// The status filter is applied after the fetch (the API has no filter
|
|
139
|
+
// argument on this connection), so the page has to be exhaustive when one is
|
|
140
|
+
// set — a limit on the unfiltered feed would hide matches.
|
|
141
|
+
const rows = await collectRawQuery<AgentSessionRow>(
|
|
142
|
+
client as any,
|
|
143
|
+
ALL_QUERY,
|
|
144
|
+
{},
|
|
145
|
+
"agentSessions",
|
|
146
|
+
opts.status ? Infinity : limit,
|
|
147
|
+
toRow,
|
|
148
|
+
);
|
|
149
|
+
return finish(rows, limit, opts);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function finish(
|
|
153
|
+
rows: Array<AgentSessionRow | null>,
|
|
154
|
+
limit: number,
|
|
155
|
+
opts: ListOptions,
|
|
156
|
+
): AgentSessionRow[] {
|
|
157
|
+
const present = rows.filter((r): r is AgentSessionRow => r !== null);
|
|
158
|
+
const filtered = opts.status ? present.filter((r) => r.status === opts.status) : present;
|
|
159
|
+
// Newest first, whichever query found them (an issue's comments come oldest first).
|
|
160
|
+
filtered.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
161
|
+
if (limit === Infinity) return setPaginationMetadata(filtered, false);
|
|
162
|
+
return setPaginationMetadata(
|
|
163
|
+
filtered.slice(0, limit),
|
|
164
|
+
filtered.length > limit || (!opts.status && filtered.length >= limit && hasMoreResults(rows)),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toRow(s: any): AgentSessionRow {
|
|
169
|
+
return {
|
|
170
|
+
id: s.id,
|
|
171
|
+
status: s.status,
|
|
172
|
+
type: s.type ?? null,
|
|
173
|
+
summary: s.summary ?? null,
|
|
174
|
+
createdAt: s.createdAt,
|
|
175
|
+
startedAt: s.startedAt ?? null,
|
|
176
|
+
endedAt: s.endedAt ?? null,
|
|
177
|
+
url: s.url ?? null,
|
|
178
|
+
issue: s.issue ?? null,
|
|
179
|
+
agent: s.appUser ?? null,
|
|
180
|
+
creator: s.creator ?? null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One step of the agent's work, flattened from the activity-content union. */
|
|
185
|
+
export interface AgentActivityRow {
|
|
186
|
+
id: string;
|
|
187
|
+
createdAt: string;
|
|
188
|
+
/** thought | action | response | prompt | error | elicitation */
|
|
189
|
+
type: string;
|
|
190
|
+
/** The text of a thought/response/prompt/error/elicitation; null for an action. */
|
|
191
|
+
body: string | null;
|
|
192
|
+
/** An action's name (`Running command`) and its parameter/result; null otherwise. */
|
|
193
|
+
action: string | null;
|
|
194
|
+
parameter: string | null;
|
|
195
|
+
result: string | null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const AGENT_ACTIVITY_ROW_SHAPE = shape<AgentActivityRow>({
|
|
199
|
+
id: "string",
|
|
200
|
+
createdAt: "string",
|
|
201
|
+
type: "string",
|
|
202
|
+
body: "string|null",
|
|
203
|
+
action: "string|null",
|
|
204
|
+
parameter: "string|null",
|
|
205
|
+
result: "string|null",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
export interface AgentSessionDetail extends AgentSessionRow {
|
|
209
|
+
updatedAt: string;
|
|
210
|
+
dismissedAt: string | null;
|
|
211
|
+
dismissedBy: SessionUser | null;
|
|
212
|
+
externalLink: string | null;
|
|
213
|
+
/** Oldest first, up to `ACTIVITY_LIMIT`; `activitiesTruncated` says whether more exist. */
|
|
214
|
+
activities: AgentActivityRow[];
|
|
215
|
+
activitiesTruncated: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The detail's shape; checked against `AgentSessionDetail` (the row, plus the transcript). */
|
|
219
|
+
export const AGENT_SESSION_DETAIL_SHAPE = shape<AgentSessionDetail>({
|
|
220
|
+
...AGENT_SESSION_ROW_SHAPE,
|
|
221
|
+
updatedAt: "string",
|
|
222
|
+
dismissedAt: "string|null",
|
|
223
|
+
dismissedBy: { nullable: SESSION_USER_SHAPE },
|
|
224
|
+
externalLink: "string|null",
|
|
225
|
+
activities: [AGENT_ACTIVITY_ROW_SHAPE],
|
|
226
|
+
activitiesTruncated: "boolean",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/** How many activities `view` fetches — a session is short, and the API prices nested lists by their worst case. */
|
|
230
|
+
const ACTIVITY_LIMIT = 100;
|
|
231
|
+
|
|
232
|
+
const DETAIL_QUERY = `
|
|
233
|
+
query CliAgentSessionDetail($id: String!, $activities: Int!) {
|
|
234
|
+
agentSession(id: $id) {
|
|
235
|
+
${ROW_FIELDS}
|
|
236
|
+
updatedAt dismissedAt externalLink
|
|
237
|
+
dismissedBy { id name displayName }
|
|
238
|
+
activities(first: $activities) {
|
|
239
|
+
nodes {
|
|
240
|
+
id createdAt
|
|
241
|
+
content {
|
|
242
|
+
__typename
|
|
243
|
+
... on AgentActivityThoughtContent { type body }
|
|
244
|
+
... on AgentActivityActionContent { type action parameter result }
|
|
245
|
+
... on AgentActivityResponseContent { type body }
|
|
246
|
+
... on AgentActivityPromptContent { type body }
|
|
247
|
+
... on AgentActivityErrorContent { type body }
|
|
248
|
+
... on AgentActivityElicitationContent { type body }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
pageInfo { hasNextPage }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}`;
|
|
255
|
+
|
|
256
|
+
export async function getAgentSessionDetail(
|
|
257
|
+
client: LinearClient,
|
|
258
|
+
id: string,
|
|
259
|
+
): Promise<AgentSessionDetail> {
|
|
260
|
+
const data: any = await withRetry(() =>
|
|
261
|
+
(client as any).client.rawRequest(DETAIL_QUERY, { id, activities: ACTIVITY_LIMIT }),
|
|
262
|
+
);
|
|
263
|
+
const s = data.data?.agentSession;
|
|
264
|
+
if (!s) throw notFound(`No agent session '${id}'.`);
|
|
265
|
+
const activities: any[] = s.activities?.nodes ?? [];
|
|
266
|
+
return {
|
|
267
|
+
...toRow(s),
|
|
268
|
+
updatedAt: s.updatedAt,
|
|
269
|
+
dismissedAt: s.dismissedAt ?? null,
|
|
270
|
+
dismissedBy: s.dismissedBy ?? null,
|
|
271
|
+
externalLink: s.externalLink ?? null,
|
|
272
|
+
// The API serves activities newest first; a transcript reads the other way.
|
|
273
|
+
activities: activities.map(toActivity).reverse(),
|
|
274
|
+
activitiesTruncated: s.activities?.pageInfo?.hasNextPage === true,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function toActivity(a: any): AgentActivityRow {
|
|
279
|
+
const c = a.content ?? {};
|
|
280
|
+
return {
|
|
281
|
+
id: a.id,
|
|
282
|
+
createdAt: a.createdAt,
|
|
283
|
+
type: c.type ?? "unknown",
|
|
284
|
+
body: c.body ?? null,
|
|
285
|
+
action: c.action ?? null,
|
|
286
|
+
parameter: c.parameter ?? null,
|
|
287
|
+
result: c.result ?? null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment service: all SDK access for attachments lives here so commands
|
|
3
|
+
* stay thin.
|
|
4
|
+
*
|
|
5
|
+
* Listing goes through the typed `issue.attachments()` connection on a resolved
|
|
6
|
+
* issue; `create` unwraps the `{ success, attachment }` payload and `delete`
|
|
7
|
+
* removes by id. `attachFiles` is `issue attach`: upload each file (see
|
|
8
|
+
* `lib/upload.ts`), then attach it by its asset URL.
|
|
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
|
+
import { notFound, usageError } from "../lib/errors.js";
|
|
16
|
+
import { assertMutation, unwrapMutation } from "../lib/mutation.js";
|
|
17
|
+
import { resolveIssue } from "../lib/resolve.js";
|
|
18
|
+
import { appendEmbeds, uploadFile, validateUploads, type UploadResult } from "../lib/upload.js";
|
|
19
|
+
|
|
20
|
+
export interface AttachmentRow {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
subtitle: string | null;
|
|
24
|
+
url: string;
|
|
25
|
+
source: string | null;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
|
|
30
|
+
export const ATTACHMENT_ROW_SHAPE = shape<AttachmentRow>({
|
|
31
|
+
id: "string",
|
|
32
|
+
title: "string",
|
|
33
|
+
subtitle: "string|null",
|
|
34
|
+
url: "string",
|
|
35
|
+
source: "string|null",
|
|
36
|
+
createdAt: "string",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Derive a human-friendly source label from the Attachment's source/sourceType. */
|
|
40
|
+
export function sourceLabel(a: any): string | null {
|
|
41
|
+
if (a.sourceType) return String(a.sourceType);
|
|
42
|
+
const src = a.source;
|
|
43
|
+
if (src && typeof src === "object" && typeof src.type === "string") return src.type;
|
|
44
|
+
if (typeof src === "string") return src;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toRow(a: any): AttachmentRow {
|
|
49
|
+
return {
|
|
50
|
+
id: a.id,
|
|
51
|
+
title: a.title,
|
|
52
|
+
subtitle: a.subtitle ?? null,
|
|
53
|
+
url: a.url,
|
|
54
|
+
source: sourceLabel(a),
|
|
55
|
+
createdAt: dateStr(a.createdAt),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** List the attachments on an issue (resolved from identifier or UUID). */
|
|
60
|
+
export async function listAttachments(
|
|
61
|
+
client: LinearClient,
|
|
62
|
+
issueArg: string,
|
|
63
|
+
limit: number,
|
|
64
|
+
): Promise<AttachmentRow[]> {
|
|
65
|
+
const issue = await resolveIssue(client, issueArg);
|
|
66
|
+
const conn = await withRetry(() => issue.attachments({ first: pageSize(limit) }));
|
|
67
|
+
const nodes = await collect(conn as any, limit);
|
|
68
|
+
return inheritPaginationMetadata(nodes.map(toRow), nodes);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CreateOptions {
|
|
72
|
+
url: string;
|
|
73
|
+
title: string;
|
|
74
|
+
subtitle?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Create an attachment on an issue, unwrapping the `{ success, attachment }` payload. */
|
|
78
|
+
export async function createAttachment(
|
|
79
|
+
client: LinearClient,
|
|
80
|
+
issueArg: string,
|
|
81
|
+
opts: CreateOptions,
|
|
82
|
+
) {
|
|
83
|
+
const issue = await resolveIssue(client, issueArg);
|
|
84
|
+
const input: Record<string, any> = {
|
|
85
|
+
issueId: issue.id,
|
|
86
|
+
url: opts.url,
|
|
87
|
+
title: opts.title,
|
|
88
|
+
};
|
|
89
|
+
if (opts.subtitle !== undefined) input.subtitle = opts.subtitle;
|
|
90
|
+
|
|
91
|
+
return unwrapMutation(
|
|
92
|
+
withRetry(() => client.createAttachment(input as any)),
|
|
93
|
+
"attachment",
|
|
94
|
+
"Attachment creation",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AttachFilesOptions {
|
|
99
|
+
/** Attachment title; only meaningful for a single file (default: the filename). */
|
|
100
|
+
title?: string;
|
|
101
|
+
/** Also post one comment with this body, embedding every file as markdown. */
|
|
102
|
+
comment?: string;
|
|
103
|
+
/** Upload to public, world-readable URLs (raster images only). Default: private. */
|
|
104
|
+
public?: boolean;
|
|
105
|
+
/** Called as each file is attached (with the resolved issue), for progress output. */
|
|
106
|
+
onAttached?: (attached: AttachedFile, issue: { id: string; identifier: string }) => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One uploaded-and-attached file: the attachment plus what was uploaded. */
|
|
110
|
+
export interface AttachedFile extends UploadResult {
|
|
111
|
+
id: string;
|
|
112
|
+
title: string;
|
|
113
|
+
url: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `issue attach <issue> <file...>`: upload each file to Linear's storage and
|
|
118
|
+
* attach it to the issue by its asset URL. Every file is validated first — it
|
|
119
|
+
* exists, is readable, and (with `public`) is a type Linear serves publicly —
|
|
120
|
+
* so a typo in file 3 does not leave files 1–2 uploaded and orphaned. Then the
|
|
121
|
+
* issue is resolved (a bad id fails before any bytes move), and each file is
|
|
122
|
+
* uploaded and attached in turn. `comment` posts ONE comment afterwards, its
|
|
123
|
+
* body followed by a blank line and one markdown embed per file, so an image
|
|
124
|
+
* shows inline in the thread as well as in the sidebar.
|
|
125
|
+
*/
|
|
126
|
+
export async function attachFiles(
|
|
127
|
+
client: LinearClient,
|
|
128
|
+
issueArg: string,
|
|
129
|
+
paths: string[],
|
|
130
|
+
opts: AttachFilesOptions,
|
|
131
|
+
): Promise<{
|
|
132
|
+
issue: { id: string; identifier: string };
|
|
133
|
+
attachments: AttachedFile[];
|
|
134
|
+
comment?: { id: string; url: string };
|
|
135
|
+
}> {
|
|
136
|
+
if (opts.title !== undefined && paths.length > 1) {
|
|
137
|
+
throw usageError(
|
|
138
|
+
"--title names a single attachment; with several files, drop it to use each file's name.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
validateUploads(paths, { public: opts.public });
|
|
142
|
+
const issue = await resolveIssue(client, issueArg);
|
|
143
|
+
|
|
144
|
+
const attachments: AttachedFile[] = [];
|
|
145
|
+
for (const path of paths) {
|
|
146
|
+
const upload = await uploadFile(client, path, { public: opts.public });
|
|
147
|
+
const attachment = await unwrapMutation(
|
|
148
|
+
withRetry(() =>
|
|
149
|
+
client.createAttachment({
|
|
150
|
+
issueId: issue.id,
|
|
151
|
+
url: upload.assetUrl,
|
|
152
|
+
title: opts.title ?? upload.filename,
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
"attachment",
|
|
156
|
+
"Attachment creation",
|
|
157
|
+
);
|
|
158
|
+
const attached: AttachedFile = {
|
|
159
|
+
...upload,
|
|
160
|
+
id: attachment.id,
|
|
161
|
+
title: attachment.title,
|
|
162
|
+
url: attachment.url,
|
|
163
|
+
};
|
|
164
|
+
attachments.push(attached);
|
|
165
|
+
opts.onAttached?.(attached, issue);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let comment: { id: string; url: string } | undefined;
|
|
169
|
+
if (opts.comment !== undefined) {
|
|
170
|
+
const created = await unwrapMutation(
|
|
171
|
+
withRetry(() =>
|
|
172
|
+
client.createComment({ issueId: issue.id, body: appendEmbeds(opts.comment, attachments) }),
|
|
173
|
+
),
|
|
174
|
+
"comment",
|
|
175
|
+
"Comment creation",
|
|
176
|
+
);
|
|
177
|
+
comment = { id: created.id, url: created.url };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { issue: { id: issue.id, identifier: issue.identifier }, attachments, comment };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Delete an attachment by id; returns the removed attachment's id/title. */
|
|
184
|
+
export async function deleteAttachment(client: LinearClient, id: string) {
|
|
185
|
+
const attachment = await withRetry(() => client.attachment(id));
|
|
186
|
+
if (!attachment) throw notFound(`No attachment ${id}.`);
|
|
187
|
+
await assertMutation(
|
|
188
|
+
withRetry(() => client.deleteAttachment(id)),
|
|
189
|
+
"Attachment deletion",
|
|
190
|
+
);
|
|
191
|
+
return { id: attachment.id, title: attachment.title };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Normalize a Date (SDK model) or string into an ISO string. */
|
|
195
|
+
export function dateStr(v: unknown): string {
|
|
196
|
+
if (v instanceof Date) return v.toISOString();
|
|
197
|
+
return String(v);
|
|
198
|
+
}
|