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,262 @@
1
+ /**
2
+ * Comment service: all SDK access for comments lives here so commands stay thin.
3
+ *
4
+ * This is the ONE comment implementation: `comment add|list|update|delete|reply`,
5
+ * `issue comment`, `issue comments`, and `issue view --comments` all come here.
6
+ * There used to be a second copy in the issue service (typed SDK, one request
7
+ * per comment for the author, and a different JSON shape — `user` string vs
8
+ * `author` string, no `url`), which is exactly how the same data came out with
9
+ * two key sets depending on which command asked for it.
10
+ *
11
+ * Lists use a tailored GraphQL query so the author, thread parent and resolved
12
+ * state are fetched inline (one round-trip per page). The mutations use the
13
+ * typed SDK and unwrap the `{ success, comment }` payloads.
14
+ *
15
+ * NOTE: the typed `client.comment(id)` getter is broken in @linear/sdk v87 (it
16
+ * mis-serializes the id as the GraphQL `variables` body), so looking a comment
17
+ * up by id — to learn a reply's parent issue, or the current body before an
18
+ * edit — uses a tailored rawRequest instead.
19
+ */
20
+
21
+ import type { LinearClient } from "@linear/sdk";
22
+ import { withRetry } from "../client.js";
23
+ import { shape } from "../lib/shape.js";
24
+ import { collectRawQuery } from "../lib/pagination.js";
25
+ import { usageError, notFound } from "../lib/errors.js";
26
+ import { assertMutation, unwrapMutation } from "../lib/mutation.js";
27
+ import { resolveIssue } from "../lib/resolve.js";
28
+ import { appendEmbeds, uploadFile, validateUploads, type UploadResult } from "../lib/upload.js";
29
+
30
+ export interface CommentRow {
31
+ id: string;
32
+ body: string;
33
+ /** The author — an object like every other list row's relations; null for a deleted account. */
34
+ user: { id: string; displayName: string } | null;
35
+ createdAt: string;
36
+ /** Set once the body has been edited; null for a comment as first written. */
37
+ editedAt: string | null;
38
+ /** Set when the thread has been resolved (`comment resolve`); null while open. */
39
+ resolvedAt: string | null;
40
+ /** The comment this one replies to (`comment reply`); null for a top-level comment. */
41
+ parent: { id: string } | null;
42
+ url: string;
43
+ }
44
+
45
+ /** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
46
+ export const COMMENT_ROW_SHAPE = shape<CommentRow>({
47
+ id: "string",
48
+ body: "string",
49
+ user: { nullable: { id: "string", displayName: "string" } },
50
+ createdAt: "string",
51
+ editedAt: "string|null",
52
+ resolvedAt: "string|null",
53
+ parent: { nullable: { id: "string" } },
54
+ url: "string",
55
+ });
56
+
57
+ const LIST_QUERY = `
58
+ query CliComments($id: String!, $first: Int!, $after: String) {
59
+ issue(id: $id) {
60
+ comments(first: $first, after: $after) {
61
+ nodes {
62
+ id body createdAt editedAt resolvedAt url
63
+ user { id displayName }
64
+ parent { id }
65
+ }
66
+ pageInfo { hasNextPage endCursor }
67
+ }
68
+ }
69
+ }`;
70
+
71
+ /**
72
+ * List the comments on an issue (resolved by identifier/UUID), in thread order:
73
+ * top-level comments as the API returns them (newest first), each followed by
74
+ * its replies oldest-first, so a thread reads top to bottom.
75
+ */
76
+ export async function listComments(
77
+ client: LinearClient,
78
+ issueArg: string,
79
+ limit: number,
80
+ ): Promise<CommentRow[]> {
81
+ const issue = await resolveIssue(client, issueArg);
82
+ const rows = await collectRawQuery<CommentRow>(
83
+ client as any,
84
+ LIST_QUERY,
85
+ { id: issue.id },
86
+ "issue.comments",
87
+ limit,
88
+ toCommentRow,
89
+ );
90
+ return threadOrder(rows);
91
+ }
92
+
93
+ /** Map a tailored-query comment node to a row. Exported for tests. */
94
+ export function toCommentRow(c: any): CommentRow {
95
+ return {
96
+ id: c.id,
97
+ body: c.body,
98
+ user: c.user ? { id: c.user.id, displayName: c.user.displayName } : null,
99
+ createdAt: c.createdAt,
100
+ editedAt: c.editedAt ?? null,
101
+ resolvedAt: c.resolvedAt ?? null,
102
+ parent: c.parent ? { id: c.parent.id } : null,
103
+ url: c.url,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Group replies under their parent. Top-level rows keep their incoming order;
109
+ * replies follow their parent oldest-first. A reply whose parent is not in the
110
+ * page (it fell outside `--limit`) stays where it was, still marked by
111
+ * `parent`, rather than being dropped. Exported for tests.
112
+ */
113
+ export function threadOrder(rows: CommentRow[]): CommentRow[] {
114
+ const ids = new Set(rows.map((r) => r.id));
115
+ const replies = new Map<string, CommentRow[]>();
116
+ for (const r of rows) {
117
+ if (r.parent && ids.has(r.parent.id)) {
118
+ const list = replies.get(r.parent.id) ?? [];
119
+ list.push(r);
120
+ replies.set(r.parent.id, list);
121
+ }
122
+ }
123
+ const out: CommentRow[] = [];
124
+ for (const r of rows) {
125
+ if (r.parent && ids.has(r.parent.id)) continue; // emitted under its parent
126
+ out.push(r);
127
+ const kids = replies.get(r.id);
128
+ if (kids) out.push(...kids.sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
129
+ }
130
+ return out;
131
+ }
132
+
133
+ export interface AddCommentOptions {
134
+ /**
135
+ * `--attach <file>` (repeatable): files to upload and embed in the body — a
136
+ * blank line after it, then one markdown embed per line (images inline,
137
+ * anything else as a link). Validated as a batch before any is uploaded.
138
+ */
139
+ attachments?: string[];
140
+ /** Upload the attachments to public, world-readable URLs (raster images only). */
141
+ public?: boolean;
142
+ }
143
+
144
+ /** Add a comment to an issue, optionally with uploaded files embedded in it. */
145
+ export async function addComment(
146
+ client: LinearClient,
147
+ issueArg: string,
148
+ body: string,
149
+ opts: AddCommentOptions = {},
150
+ ) {
151
+ const paths = opts.attachments ?? [];
152
+ // Every file first — a missing one fails before anything is uploaded or resolved.
153
+ validateUploads(paths, { public: opts.public });
154
+ const issue = await resolveIssue(client, issueArg);
155
+ const uploads: UploadResult[] = [];
156
+ for (const path of paths) uploads.push(await uploadFile(client, path, { public: opts.public }));
157
+ const comment = await unwrapMutation(
158
+ withRetry(() => client.createComment({ issueId: issue.id, body: appendEmbeds(body, uploads) })),
159
+ "comment",
160
+ "Comment creation",
161
+ );
162
+ return { issue, comment, uploads };
163
+ }
164
+
165
+ const LOOKUP_QUERY = `
166
+ query CliCommentLookup($id: String!) {
167
+ comment(id: $id) {
168
+ id
169
+ body
170
+ issueId
171
+ issue { id identifier }
172
+ }
173
+ }`;
174
+
175
+ export interface CommentLookup {
176
+ id: string;
177
+ body: string;
178
+ issueId: string | null;
179
+ issue: { id: string; identifier: string } | null;
180
+ }
181
+
182
+ /**
183
+ * Reply to an existing comment. Linear nests replies via `parentId`, but the API
184
+ * still requires the owning entity, so we look the parent up (for its issueId)
185
+ * and pass both. Returns the new comment plus the parent's issue for output.
186
+ */
187
+ export async function replyToComment(client: LinearClient, commentId: string, body: string) {
188
+ const parent = await getComment(client, commentId);
189
+ if (!parent.issueId) throw usageError("Can only reply to comments that belong to an issue.");
190
+ const comment = await unwrapMutation(
191
+ withRetry(() => client.createComment({ parentId: parent.id, issueId: parent.issueId!, body })),
192
+ "comment",
193
+ "Reply creation",
194
+ );
195
+ return { parent, comment, issue: parent.issue };
196
+ }
197
+
198
+ /**
199
+ * Replace a comment's body. An empty or whitespace-only body is refused: the
200
+ * only way to produce one from the CLI is quitting a blank editor or passing
201
+ * `""`, and neither is a request to blank a comment (there is `comment delete`
202
+ * for getting rid of one). An unchanged body is refused too, so an editor
203
+ * session closed without edits does not write a no-op — and does not stamp
204
+ * `editedAt` on a comment nobody edited.
205
+ */
206
+ export async function updateComment(
207
+ client: LinearClient,
208
+ commentId: string,
209
+ body: string,
210
+ current?: string,
211
+ ) {
212
+ if (body.trim() === "") {
213
+ throw usageError(
214
+ "Refusing to blank the comment body. To remove a comment, use 'comment delete'.",
215
+ );
216
+ }
217
+ if (current !== undefined && body === current)
218
+ throw usageError("Comment body unchanged; nothing to update.");
219
+ return unwrapMutation(
220
+ withRetry(() => client.updateComment(commentId, { body })),
221
+ "comment",
222
+ "Comment update",
223
+ );
224
+ }
225
+
226
+ export async function deleteComment(client: LinearClient, commentId: string) {
227
+ await assertMutation(
228
+ withRetry(() => client.deleteComment(commentId)),
229
+ "Comment deletion",
230
+ );
231
+ return { id: commentId };
232
+ }
233
+
234
+ /** Resolve (or unresolve) a comment thread. */
235
+ export async function setResolved(client: LinearClient, commentId: string, resolved: boolean) {
236
+ return unwrapMutation(
237
+ withRetry(() =>
238
+ resolved ? client.commentResolve(commentId) : client.commentUnresolve(commentId),
239
+ ),
240
+ "comment",
241
+ `Comment ${resolved ? "resolve" : "unresolve"}`,
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Look a comment up by id via rawRequest (the typed getter is broken in v87):
247
+ * its current body, and the issue it belongs to. Used to seed the editor for
248
+ * `comment update` and to find a reply's owning issue.
249
+ */
250
+ export async function getComment(client: LinearClient, commentId: string): Promise<CommentLookup> {
251
+ const data: any = await withRetry(() =>
252
+ (client.client as any).rawRequest(LOOKUP_QUERY, { id: commentId }),
253
+ );
254
+ const node = data?.data?.comment;
255
+ if (!node) throw notFound(`No comment with id '${commentId}'.`);
256
+ return {
257
+ id: node.id,
258
+ body: node.body ?? "",
259
+ issueId: node.issueId ?? null,
260
+ issue: node.issue ?? null,
261
+ };
262
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Cycle service: all SDK access for cycles lives here so commands stay thin.
3
+ *
4
+ * Lists go through the typed `team.cycles()` connection (server-side sorted by
5
+ * number); single `view`/`current` and the 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 { collect, pageSize, setPaginationMetadata } from "../lib/pagination.js";
12
+ import { usageError, notFound } from "../lib/errors.js";
13
+ import { unwrapMutation } from "../lib/mutation.js";
14
+ import { resolveTeam, resolveCycleId, isUuid } from "../lib/resolve.js";
15
+
16
+ export interface CycleRow {
17
+ id: string;
18
+ number: number;
19
+ name: string | null;
20
+ startsAt: string;
21
+ endsAt: string;
22
+ progress: number;
23
+ completedAt: string | null;
24
+ }
25
+
26
+ /** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
27
+ export const CYCLE_ROW_SHAPE = shape<CycleRow>({
28
+ id: "string",
29
+ number: "number",
30
+ name: "string|null",
31
+ startsAt: "string",
32
+ endsAt: "string",
33
+ progress: "number",
34
+ completedAt: "string|null",
35
+ });
36
+
37
+ /** List a team's cycles, newest (highest number) first. */
38
+ export async function listCycles(
39
+ client: LinearClient,
40
+ teamInput: string | undefined,
41
+ limit: number,
42
+ defaultTeamKey: string | undefined,
43
+ ): Promise<CycleRow[]> {
44
+ const team = await resolveTeam(client, teamInput, defaultTeamKey);
45
+ const teamModel = await withRetry(() => client.team(team.id));
46
+ const conn = await withRetry(() => teamModel.cycles({ first: pageSize(limit) }));
47
+ // Cycle connection order is not its user-facing number order. Sort first so
48
+ // `--limit` means the newest cycles, not an arbitrary API prefix.
49
+ const nodes = await collect(conn as any, Infinity);
50
+ const rows = nodes.map(toRow);
51
+ // Surface most-recent (highest number) cycle first.
52
+ rows.sort((a, b) => b.number - a.number);
53
+ return setPaginationMetadata(
54
+ limit === Infinity ? rows : rows.slice(0, limit),
55
+ rows.length > limit,
56
+ );
57
+ }
58
+
59
+ /** The team's currently active cycle, if any. */
60
+ export async function getCurrentCycle(
61
+ client: LinearClient,
62
+ teamInput: string | undefined,
63
+ defaultTeamKey: string | undefined,
64
+ ): Promise<CycleDetail> {
65
+ const team = await resolveTeam(client, teamInput, defaultTeamKey);
66
+ const teamModel = await withRetry(() => client.team(team.id));
67
+ const cycle = await teamModel.activeCycle;
68
+ if (!cycle) throw notFound(`No active cycle for team ${team.key}.`);
69
+ return toDetail(cycle, team.key);
70
+ }
71
+
72
+ export interface CycleDetail {
73
+ id: string;
74
+ number: number;
75
+ name: string | null;
76
+ description: string | null;
77
+ startsAt: string;
78
+ endsAt: string;
79
+ completedAt: string | null;
80
+ progress: number;
81
+ team: string | null;
82
+ }
83
+
84
+ /** The detail's shape; checked against `CycleDetail`. */
85
+ export const CYCLE_DETAIL_SHAPE = shape<CycleDetail>({
86
+ id: "string",
87
+ number: "number",
88
+ name: "string|null",
89
+ description: "string|null",
90
+ startsAt: "string",
91
+ endsAt: "string",
92
+ completedAt: "string|null",
93
+ progress: "number",
94
+ team: "string|null",
95
+ });
96
+
97
+ /**
98
+ * Resolve a cycle id from `idArg`. A UUID is taken directly; a number or
99
+ * `current` needs a team (from --team / default) to resolve against.
100
+ */
101
+ export async function getCycleDetail(
102
+ client: LinearClient,
103
+ idArg: string,
104
+ teamInput: string | undefined,
105
+ defaultTeamKey: string | undefined,
106
+ ): Promise<CycleDetail> {
107
+ const cycleId = await resolveCycleArg(client, idArg, teamInput, defaultTeamKey);
108
+ const cycle = await withRetry(() => client.cycle(cycleId));
109
+ const team = await cycle.team;
110
+ return toDetail(cycle, team?.key ?? null);
111
+ }
112
+
113
+ export interface CreateOptions {
114
+ team?: string;
115
+ name?: string;
116
+ startsAt: string;
117
+ endsAt: string;
118
+ }
119
+
120
+ export async function createCycle(
121
+ client: LinearClient,
122
+ opts: CreateOptions,
123
+ defaultTeamKey: string | undefined,
124
+ ) {
125
+ const team = await resolveTeam(client, opts.team, defaultTeamKey);
126
+ const input: Record<string, any> = {
127
+ teamId: team.id,
128
+ startsAt: opts.startsAt,
129
+ endsAt: opts.endsAt,
130
+ };
131
+ if (opts.name !== undefined) input.name = opts.name;
132
+
133
+ return unwrapMutation(
134
+ withRetry(() => client.createCycle(input as any)),
135
+ "cycle",
136
+ "Cycle creation",
137
+ );
138
+ }
139
+
140
+ export interface UpdateOptions {
141
+ name?: string;
142
+ startsAt?: string;
143
+ endsAt?: string;
144
+ }
145
+
146
+ export async function updateCycle(
147
+ client: LinearClient,
148
+ idArg: string,
149
+ opts: UpdateOptions,
150
+ teamInput: string | undefined,
151
+ defaultTeamKey: string | undefined,
152
+ ) {
153
+ const cycleId = await resolveCycleArg(client, idArg, teamInput, defaultTeamKey);
154
+ const input: Record<string, any> = {};
155
+ if (opts.name !== undefined) input.name = opts.name;
156
+ if (opts.startsAt !== undefined) input.startsAt = opts.startsAt;
157
+ if (opts.endsAt !== undefined) input.endsAt = opts.endsAt;
158
+
159
+ if (Object.keys(input).length === 0)
160
+ throw usageError("Nothing to update; pass at least one of --name, --start, --end.");
161
+
162
+ return unwrapMutation(
163
+ withRetry(() => client.updateCycle(cycleId, input as any)),
164
+ "cycle",
165
+ "Cycle update",
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Resolve a cycle reference (UUID, number, or `current`) to a cycle id. A UUID
171
+ * is unambiguous; a number/`current` needs a team to scope against.
172
+ */
173
+ async function resolveCycleArg(
174
+ client: LinearClient,
175
+ idArg: string,
176
+ teamInput: string | undefined,
177
+ defaultTeamKey: string | undefined,
178
+ ): Promise<string> {
179
+ if (isUuid(idArg)) return idArg;
180
+ const teamKey = teamInput ?? defaultTeamKey;
181
+ if (!teamKey)
182
+ throw usageError(
183
+ "Resolving a cycle by number or 'current' requires --team (or pass a cycle id).",
184
+ );
185
+ const team = await resolveTeam(client, teamKey, undefined);
186
+ return resolveCycleId(client, team.id, idArg);
187
+ }
188
+
189
+ function toRow(c: any): CycleRow {
190
+ return {
191
+ id: c.id,
192
+ number: c.number,
193
+ name: c.name ?? null,
194
+ startsAt: dateStr(c.startsAt),
195
+ endsAt: dateStr(c.endsAt),
196
+ progress: c.progress,
197
+ completedAt: c.completedAt ? dateStr(c.completedAt) : null,
198
+ };
199
+ }
200
+
201
+ function toDetail(c: any, teamKey: string | null): CycleDetail {
202
+ return {
203
+ id: c.id,
204
+ number: c.number,
205
+ name: c.name ?? null,
206
+ description: c.description ?? null,
207
+ startsAt: dateStr(c.startsAt),
208
+ endsAt: dateStr(c.endsAt),
209
+ completedAt: c.completedAt ? dateStr(c.completedAt) : null,
210
+ progress: c.progress,
211
+ team: teamKey,
212
+ };
213
+ }
214
+
215
+ /** Normalize a Date (SDK model) or string into an ISO string. */
216
+ export function dateStr(v: unknown): string {
217
+ if (v instanceof Date) return v.toISOString();
218
+ return String(v);
219
+ }
220
+
221
+ /** Human-friendly progress percentage (0..1 → "42%"). Exported for tests. */
222
+ export function formatProgress(progress: number): string {
223
+ return `${Math.round((progress ?? 0) * 100)}%`;
224
+ }