@elnora-ai/linear 1.0.1 → 1.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 (299) hide show
  1. package/.claude-plugin/marketplace.json +7 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +13 -1
  4. package/README.md +116 -26
  5. package/agents/linear-issue-creator.md +129 -17
  6. package/agents/linear-issue-reviewer.md +122 -23
  7. package/agents/linear-issue-updater.md +137 -25
  8. package/agents/linear-state-curator.md +173 -0
  9. package/agents/linear-url-to-issues.md +189 -26
  10. package/commands/linear-cleanup.md +64 -29
  11. package/dist/cli.js +64 -1
  12. package/dist/cli.js.map +1 -1
  13. package/dist/client/auth.d.ts.map +1 -1
  14. package/dist/client/auth.js +13 -2
  15. package/dist/client/auth.js.map +1 -1
  16. package/dist/client/linear-client.d.ts +7 -0
  17. package/dist/client/linear-client.d.ts.map +1 -1
  18. package/dist/client/linear-client.js +13 -1
  19. package/dist/client/linear-client.js.map +1 -1
  20. package/dist/commands/agent-activities.d.ts +3 -0
  21. package/dist/commands/agent-activities.d.ts.map +1 -0
  22. package/dist/commands/agent-activities.js +144 -0
  23. package/dist/commands/agent-activities.js.map +1 -0
  24. package/dist/commands/agent-sessions.d.ts +3 -0
  25. package/dist/commands/agent-sessions.d.ts.map +1 -0
  26. package/dist/commands/agent-sessions.js +132 -0
  27. package/dist/commands/agent-sessions.js.map +1 -0
  28. package/dist/commands/attachments.d.ts +3 -0
  29. package/dist/commands/attachments.d.ts.map +1 -0
  30. package/dist/commands/attachments.js +265 -0
  31. package/dist/commands/attachments.js.map +1 -0
  32. package/dist/commands/audit.d.ts +3 -0
  33. package/dist/commands/audit.d.ts.map +1 -0
  34. package/dist/commands/audit.js +73 -0
  35. package/dist/commands/audit.js.map +1 -0
  36. package/dist/commands/comments.d.ts +3 -0
  37. package/dist/commands/comments.d.ts.map +1 -0
  38. package/dist/commands/comments.js +107 -0
  39. package/dist/commands/comments.js.map +1 -0
  40. package/dist/commands/completion.d.ts +3 -0
  41. package/dist/commands/completion.d.ts.map +1 -0
  42. package/dist/commands/completion.js +62 -0
  43. package/dist/commands/completion.js.map +1 -0
  44. package/dist/commands/context.d.ts +3 -0
  45. package/dist/commands/context.d.ts.map +1 -0
  46. package/dist/commands/context.js +94 -0
  47. package/dist/commands/context.js.map +1 -0
  48. package/dist/commands/curator.d.ts +14 -0
  49. package/dist/commands/curator.d.ts.map +1 -1
  50. package/dist/commands/curator.js +97 -19
  51. package/dist/commands/curator.js.map +1 -1
  52. package/dist/commands/customer-needs.d.ts +3 -0
  53. package/dist/commands/customer-needs.d.ts.map +1 -0
  54. package/dist/commands/customer-needs.js +198 -0
  55. package/dist/commands/customer-needs.js.map +1 -0
  56. package/dist/commands/customers.d.ts +5 -0
  57. package/dist/commands/customers.d.ts.map +1 -0
  58. package/dist/commands/customers.js +201 -0
  59. package/dist/commands/customers.js.map +1 -0
  60. package/dist/commands/cycles.d.ts +3 -0
  61. package/dist/commands/cycles.d.ts.map +1 -0
  62. package/dist/commands/cycles.js +67 -0
  63. package/dist/commands/cycles.js.map +1 -0
  64. package/dist/commands/documents.d.ts +3 -0
  65. package/dist/commands/documents.d.ts.map +1 -0
  66. package/dist/commands/documents.js +105 -0
  67. package/dist/commands/documents.js.map +1 -0
  68. package/dist/commands/favorites.d.ts +3 -0
  69. package/dist/commands/favorites.d.ts.map +1 -0
  70. package/dist/commands/favorites.js +101 -0
  71. package/dist/commands/favorites.js.map +1 -0
  72. package/dist/commands/index.d.ts +30 -0
  73. package/dist/commands/index.d.ts.map +1 -1
  74. package/dist/commands/index.js +30 -0
  75. package/dist/commands/index.js.map +1 -1
  76. package/dist/commands/initiatives.d.ts +3 -0
  77. package/dist/commands/initiatives.d.ts.map +1 -0
  78. package/dist/commands/initiatives.js +106 -0
  79. package/dist/commands/initiatives.js.map +1 -0
  80. package/dist/commands/issues.d.ts +21 -0
  81. package/dist/commands/issues.d.ts.map +1 -0
  82. package/dist/commands/issues.js +993 -0
  83. package/dist/commands/issues.js.map +1 -0
  84. package/dist/commands/labels.d.ts +3 -0
  85. package/dist/commands/labels.d.ts.map +1 -0
  86. package/dist/commands/labels.js +111 -0
  87. package/dist/commands/labels.js.map +1 -0
  88. package/dist/commands/milestones.d.ts +3 -0
  89. package/dist/commands/milestones.d.ts.map +1 -0
  90. package/dist/commands/milestones.js +94 -0
  91. package/dist/commands/milestones.js.map +1 -0
  92. package/dist/commands/notifications.d.ts +3 -0
  93. package/dist/commands/notifications.d.ts.map +1 -0
  94. package/dist/commands/notifications.js +130 -0
  95. package/dist/commands/notifications.js.map +1 -0
  96. package/dist/commands/project-labels.d.ts +3 -0
  97. package/dist/commands/project-labels.d.ts.map +1 -0
  98. package/dist/commands/project-labels.js +80 -0
  99. package/dist/commands/project-labels.js.map +1 -0
  100. package/dist/commands/project-relations.d.ts +3 -0
  101. package/dist/commands/project-relations.d.ts.map +1 -0
  102. package/dist/commands/project-relations.js +96 -0
  103. package/dist/commands/project-relations.js.map +1 -0
  104. package/dist/commands/projects.d.ts +3 -0
  105. package/dist/commands/projects.d.ts.map +1 -0
  106. package/dist/commands/projects.js +263 -0
  107. package/dist/commands/projects.js.map +1 -0
  108. package/dist/commands/quota.d.ts +3 -0
  109. package/dist/commands/quota.d.ts.map +1 -0
  110. package/dist/commands/quota.js +28 -0
  111. package/dist/commands/quota.js.map +1 -0
  112. package/dist/commands/reactions.d.ts +7 -0
  113. package/dist/commands/reactions.d.ts.map +1 -0
  114. package/dist/commands/reactions.js +53 -0
  115. package/dist/commands/reactions.js.map +1 -0
  116. package/dist/commands/relations.d.ts +3 -0
  117. package/dist/commands/relations.d.ts.map +1 -0
  118. package/dist/commands/relations.js +73 -0
  119. package/dist/commands/relations.js.map +1 -0
  120. package/dist/commands/states.d.ts +3 -0
  121. package/dist/commands/states.d.ts.map +1 -0
  122. package/dist/commands/states.js +52 -0
  123. package/dist/commands/states.js.map +1 -0
  124. package/dist/commands/status-updates.d.ts +3 -0
  125. package/dist/commands/status-updates.d.ts.map +1 -0
  126. package/dist/commands/status-updates.js +117 -0
  127. package/dist/commands/status-updates.js.map +1 -0
  128. package/dist/commands/sync.d.ts.map +1 -1
  129. package/dist/commands/sync.js +58 -18
  130. package/dist/commands/sync.js.map +1 -1
  131. package/dist/commands/teams.d.ts +3 -0
  132. package/dist/commands/teams.d.ts.map +1 -0
  133. package/dist/commands/teams.js +135 -0
  134. package/dist/commands/teams.js.map +1 -0
  135. package/dist/commands/templates.d.ts +3 -0
  136. package/dist/commands/templates.d.ts.map +1 -0
  137. package/dist/commands/templates.js +76 -0
  138. package/dist/commands/templates.js.map +1 -0
  139. package/dist/commands/users.d.ts +3 -0
  140. package/dist/commands/users.d.ts.map +1 -0
  141. package/dist/commands/users.js +40 -0
  142. package/dist/commands/users.js.map +1 -0
  143. package/dist/commands/views.d.ts +3 -0
  144. package/dist/commands/views.d.ts.map +1 -0
  145. package/dist/commands/views.js +177 -0
  146. package/dist/commands/views.js.map +1 -0
  147. package/dist/commands/webhooks.d.ts +3 -0
  148. package/dist/commands/webhooks.d.ts.map +1 -0
  149. package/dist/commands/webhooks.js +234 -0
  150. package/dist/commands/webhooks.js.map +1 -0
  151. package/dist/config/loader.d.ts.map +1 -1
  152. package/dist/config/loader.js +3 -0
  153. package/dist/config/loader.js.map +1 -1
  154. package/dist/config/types.d.ts +15 -1
  155. package/dist/config/types.d.ts.map +1 -1
  156. package/dist/config/types.js +1 -0
  157. package/dist/config/types.js.map +1 -1
  158. package/dist/curator/dispatch.d.ts +52 -0
  159. package/dist/curator/dispatch.d.ts.map +1 -0
  160. package/dist/curator/dispatch.js +144 -0
  161. package/dist/curator/dispatch.js.map +1 -0
  162. package/dist/curator/index.d.ts +5 -0
  163. package/dist/curator/index.d.ts.map +1 -0
  164. package/dist/curator/index.js +5 -0
  165. package/dist/curator/index.js.map +1 -0
  166. package/dist/curator/llm.d.ts +70 -0
  167. package/dist/curator/llm.d.ts.map +1 -0
  168. package/dist/curator/llm.js +107 -0
  169. package/dist/curator/llm.js.map +1 -0
  170. package/dist/curator/snapshot.d.ts +34 -0
  171. package/dist/curator/snapshot.d.ts.map +1 -0
  172. package/dist/curator/snapshot.js +127 -0
  173. package/dist/curator/snapshot.js.map +1 -0
  174. package/dist/curator/state.d.ts +50 -0
  175. package/dist/curator/state.d.ts.map +1 -0
  176. package/dist/curator/state.js +125 -0
  177. package/dist/curator/state.js.map +1 -0
  178. package/dist/lib/bulk-graphql.d.ts +144 -0
  179. package/dist/lib/bulk-graphql.d.ts.map +1 -0
  180. package/dist/lib/bulk-graphql.js +380 -0
  181. package/dist/lib/bulk-graphql.js.map +1 -0
  182. package/dist/lib/index.d.ts +2 -0
  183. package/dist/lib/index.d.ts.map +1 -0
  184. package/dist/lib/index.js +2 -0
  185. package/dist/lib/index.js.map +1 -0
  186. package/dist/output/cli.d.ts +17 -0
  187. package/dist/output/cli.d.ts.map +1 -0
  188. package/dist/output/cli.js +252 -0
  189. package/dist/output/cli.js.map +1 -0
  190. package/dist/output/formatter.d.ts +6 -0
  191. package/dist/output/formatter.d.ts.map +1 -1
  192. package/dist/output/formatter.js +10 -0
  193. package/dist/output/formatter.js.map +1 -1
  194. package/dist/output/index.d.ts +1 -0
  195. package/dist/output/index.d.ts.map +1 -1
  196. package/dist/output/index.js +1 -0
  197. package/dist/output/index.js.map +1 -1
  198. package/dist/scripts/sync-linear-templates.d.ts +26 -0
  199. package/dist/scripts/sync-linear-templates.d.ts.map +1 -0
  200. package/dist/scripts/sync-linear-templates.js +115 -0
  201. package/dist/scripts/sync-linear-templates.js.map +1 -0
  202. package/dist/signals/github-commits.d.ts +31 -0
  203. package/dist/signals/github-commits.d.ts.map +1 -0
  204. package/dist/signals/github-commits.js +127 -0
  205. package/dist/signals/github-commits.js.map +1 -0
  206. package/dist/signals/github-pr.d.ts +16 -0
  207. package/dist/signals/github-pr.d.ts.map +1 -0
  208. package/dist/signals/github-pr.js +98 -0
  209. package/dist/signals/github-pr.js.map +1 -0
  210. package/dist/signals/index.d.ts +4 -0
  211. package/dist/signals/index.d.ts.map +1 -1
  212. package/dist/signals/index.js +4 -0
  213. package/dist/signals/index.js.map +1 -1
  214. package/dist/signals/linear-issues.d.ts +20 -0
  215. package/dist/signals/linear-issues.d.ts.map +1 -0
  216. package/dist/signals/linear-issues.js +115 -0
  217. package/dist/signals/linear-issues.js.map +1 -0
  218. package/dist/signals/registry.d.ts +4 -3
  219. package/dist/signals/registry.d.ts.map +1 -1
  220. package/dist/signals/registry.js +33 -11
  221. package/dist/signals/registry.js.map +1 -1
  222. package/dist/signals/slack-messages.d.ts +20 -0
  223. package/dist/signals/slack-messages.d.ts.map +1 -0
  224. package/dist/signals/slack-messages.js +129 -0
  225. package/dist/signals/slack-messages.js.map +1 -0
  226. package/dist/utils/errors.d.ts +63 -0
  227. package/dist/utils/errors.d.ts.map +1 -0
  228. package/dist/utils/errors.js +94 -0
  229. package/dist/utils/errors.js.map +1 -0
  230. package/dist/utils/index.d.ts +9 -0
  231. package/dist/utils/index.d.ts.map +1 -0
  232. package/dist/utils/index.js +9 -0
  233. package/dist/utils/index.js.map +1 -0
  234. package/dist/utils/label-policy.d.ts +53 -0
  235. package/dist/utils/label-policy.d.ts.map +1 -0
  236. package/dist/utils/label-policy.js +93 -0
  237. package/dist/utils/label-policy.js.map +1 -0
  238. package/dist/utils/parse.d.ts +48 -0
  239. package/dist/utils/parse.d.ts.map +1 -0
  240. package/dist/utils/parse.js +133 -0
  241. package/dist/utils/parse.js.map +1 -0
  242. package/dist/utils/project-status.d.ts +6 -0
  243. package/dist/utils/project-status.d.ts.map +1 -0
  244. package/dist/utils/project-status.js +33 -0
  245. package/dist/utils/project-status.js.map +1 -0
  246. package/dist/utils/rate-limit.d.ts +24 -0
  247. package/dist/utils/rate-limit.d.ts.map +1 -0
  248. package/dist/utils/rate-limit.js +89 -0
  249. package/dist/utils/rate-limit.js.map +1 -0
  250. package/dist/utils/resolve.d.ts +84 -0
  251. package/dist/utils/resolve.d.ts.map +1 -0
  252. package/dist/utils/resolve.js +172 -0
  253. package/dist/utils/resolve.js.map +1 -0
  254. package/dist/utils/sleep.d.ts +2 -0
  255. package/dist/utils/sleep.d.ts.map +1 -0
  256. package/dist/utils/sleep.js +4 -0
  257. package/dist/utils/sleep.js.map +1 -0
  258. package/dist/utils/webhook-verify.d.ts +42 -0
  259. package/dist/utils/webhook-verify.d.ts.map +1 -0
  260. package/dist/utils/webhook-verify.js +65 -0
  261. package/dist/utils/webhook-verify.js.map +1 -0
  262. package/package.json +4 -1
  263. package/references/agent-description-template.md +31 -0
  264. package/references/cli-reference.md +227 -0
  265. package/references/curator-tiering-rules.md +76 -0
  266. package/references/label-policy.example.json +37 -0
  267. package/references/label-policy.placeholder.json +6 -0
  268. package/references/settings-template.md +30 -0
  269. package/references/sla-reference.md +70 -0
  270. package/references/template-index.md +34 -0
  271. package/references/workspace-labels.md +124 -0
  272. package/references/workspace-projects.md +56 -0
  273. package/references/workspace-routing.md +58 -0
  274. package/schemas/label-policy.json +72 -0
  275. package/skills/linear-workspace/SKILL.md +65 -4
  276. package/templates/ACC-PRO-provision.md +74 -0
  277. package/templates/ACC-PRV-privileged.md +66 -0
  278. package/templates/ACC-QTR-review.md +77 -0
  279. package/templates/ACC-REV-revoke.md +67 -0
  280. package/templates/AI-USE-capability.md +111 -0
  281. package/templates/AUD-CAP-corrective.md +89 -0
  282. package/templates/AUD-INT-internal.md +92 -0
  283. package/templates/AUD-MGT-management.md +110 -0
  284. package/templates/CHG-MAJ-major.md +110 -0
  285. package/templates/CHG-SIG-significant.md +83 -0
  286. package/templates/CHG-STD-standard.md +47 -0
  287. package/templates/LRN-DOC-lessons.md +75 -0
  288. package/templates/OPS-BCK-backup.md +99 -0
  289. package/templates/OPS-DAT-data-mod.md +98 -0
  290. package/templates/RCA-DOC-root-cause.md +105 -0
  291. package/templates/RSK-ASS-assessment.md +87 -0
  292. package/templates/RSK-VND-vendor.md +113 -0
  293. package/templates/SEC-INC-incident.md +76 -0
  294. package/templates/SEC-PEN-pentest.md +58 -0
  295. package/templates/SEC-VLN-vulnerability.md +69 -0
  296. package/templates/SLA-AVL-availability.md +86 -0
  297. package/templates/SLA-OPS-operational.md +70 -0
  298. package/templates/agent-server-template/README.md +88 -0
  299. package/templates/agent-server-template/server.example.ts +185 -0
@@ -0,0 +1,993 @@
1
+ // `elnora-linear issues` — the primary command group; covers ~80% of agent usage.
2
+ //
3
+ // The bulk-ops machinery batches heterogeneous mutations (create / update /
4
+ // relate / comment / label-add / label-remove / archive) into aliased GraphQL
5
+ // documents — 100 ops execute as ~10 HTTP requests instead of 100. All name
6
+ // lookups (state, label, project, team, assignee) happen once upfront, not per
7
+ // op. State-name resolution honours each op's own team prefix (ENG-N → ELN) so
8
+ // `--team ELN` on a SEC-5 update doesn't silently corrupt SEC's "Done" state.
9
+ import { readFileSync } from "node:fs";
10
+ import { resolve as resolvePath } from "node:path";
11
+ import { getClient } from "../client/index.js";
12
+ import { batchMutations, bulkGetIssue, bulkListIssues, bulkSearchIssues, formatBulkIssue, getLastRateLimit, resolveIssueIds, resolveStateId, } from "../lib/bulk-graphql.js";
13
+ import { handleAsyncCommand, outputSuccess } from "../output/index.js";
14
+ import { CliError, fetchAllNodes, findIssueByIdentifier, getTeamLabelPolicy, LabelValidationError, NotFoundError, parseDate, parseIssueIdentifier, parseLimit, parsePositiveInt, parsePriority, requireNonEmptyUpdate, requireYes, resolveLabels, resolveProject, resolveState, resolveTeam, resolveUser, ValidationError, validateLabelsAgainstTeam, } from "../utils/index.js";
15
+ function printRateLimitStats(prefix) {
16
+ const r = getLastRateLimit();
17
+ if (r.remaining === undefined || r.limit === undefined)
18
+ return;
19
+ const pct = ((r.remaining / r.limit) * 100).toFixed(1);
20
+ process.stderr.write(`${prefix}: ${r.remaining}/${r.limit} req remaining (${pct}% headroom)${r.resetSeconds !== undefined ? `, resets in ${r.resetSeconds}s` : ""}\n`);
21
+ }
22
+ /**
23
+ * Pick the team key that scopes a bulk-ops op for state-id resolution.
24
+ *
25
+ * Precedence:
26
+ * 1. Explicit op.team (must resolve in teamMap).
27
+ * 2. For update ops, derive from the issue id prefix (e.g. SEC-5 → SEC).
28
+ * 3. Fall back to the run-level --team default.
29
+ *
30
+ * Rule 2 is the cross-team-state safeguard: without it, an update on SEC-5
31
+ * with --team ELN would look up "Done" against ELN and apply ELN's UUID to
32
+ * a SEC issue (silent corruption when both teams have a state of that name).
33
+ */
34
+ export function resolveBulkOpTeamKey(op, teamMap, defaultTeamKey) {
35
+ if (typeof op.team === "string") {
36
+ return teamMap[op.team]?.key ?? defaultTeamKey;
37
+ }
38
+ if (op.kind === "update" && typeof op.id === "string") {
39
+ const m = op.id.match(/^([A-Z]+)-\d+$/);
40
+ if (m)
41
+ return m[1];
42
+ }
43
+ return defaultTeamKey;
44
+ }
45
+ async function enforceTeamLabelPolicy(opts) {
46
+ if (opts.skip)
47
+ return;
48
+ const policy = getTeamLabelPolicy(opts.teamKey);
49
+ if (!policy)
50
+ return;
51
+ const [teamScoped, workspaceScoped] = await Promise.all([
52
+ opts.client.issueLabels({
53
+ first: 250,
54
+ filter: { team: { id: { eq: opts.teamId } } },
55
+ }),
56
+ opts.client.issueLabels({
57
+ first: 250,
58
+ filter: { team: { null: true } },
59
+ }),
60
+ ]);
61
+ const catalog = [...teamScoped.nodes.map((l) => l.name), ...workspaceScoped.nodes.map((l) => l.name)];
62
+ const result = validateLabelsAgainstTeam(opts.teamKey, opts.finalLabelNames, catalog);
63
+ if (result.valid)
64
+ return;
65
+ const missing = result.failures
66
+ .filter((f) => f.reason === "missing")
67
+ .map((f) => ({
68
+ prefixes: f.group.prefixes,
69
+ min: f.group.min,
70
+ description: f.group.description,
71
+ }));
72
+ const excess = result.failures
73
+ .filter((f) => f.reason === "excess")
74
+ .map((f) => ({
75
+ prefixes: f.group.prefixes,
76
+ max: f.group.max,
77
+ passed: f.group.prefixes.flatMap((p) => opts.finalLabelNames.filter((n) => n.startsWith(p))),
78
+ }));
79
+ throw new LabelValidationError({
80
+ error: "labels_invalid",
81
+ team: opts.teamName,
82
+ teamKey: opts.teamKey,
83
+ missing,
84
+ excess,
85
+ passed: opts.finalLabelNames,
86
+ availableForPrefix: result.availableForPrefix,
87
+ suggestedRetry: opts.retryCommand ?? "",
88
+ });
89
+ }
90
+ export function setupIssuesCommand(program) {
91
+ const issues = program.command("issues").description("Manage Linear issues");
92
+ issues
93
+ .command("list")
94
+ .description("List issues with optional filters")
95
+ .option("--team <team>", "Filter by team name or key")
96
+ .option("--project <project>", "Filter by project name")
97
+ .option("--assignee <assignee>", "Filter by assignee (name, email, or 'me')")
98
+ .option("--state <state>", "Filter by state name")
99
+ .option("--label <label>", "Filter by label name")
100
+ .option("--limit <n>", "Max results", "50")
101
+ .option("--query <query>", "Search query")
102
+ .action(handleAsyncCommand(async (opts) => {
103
+ const client = await getClient();
104
+ const filter = {};
105
+ const [teamResult, projectResult, assigneeResult, labelResult] = await Promise.all([
106
+ opts.team ? resolveTeam(client, opts.team) : undefined,
107
+ opts.project ? resolveProject(client, opts.project) : undefined,
108
+ opts.assignee ? resolveUser(client, opts.assignee) : undefined,
109
+ opts.label ? resolveLabels(client, opts.label) : undefined,
110
+ ]);
111
+ if (teamResult)
112
+ filter.team = { id: { eq: teamResult.id } };
113
+ if (projectResult)
114
+ filter.project = { id: { eq: projectResult.id } };
115
+ if (assigneeResult)
116
+ filter.assignee = { id: { eq: assigneeResult.id } };
117
+ if (labelResult)
118
+ filter.labels = { some: { id: { in: labelResult.map((l) => l.id) } } };
119
+ if (opts.state) {
120
+ if (teamResult) {
121
+ const state = await resolveState(client, opts.state, teamResult.id);
122
+ filter.state = { id: { eq: state.id } };
123
+ }
124
+ else {
125
+ const allStates = await client.workflowStates({ first: 250 });
126
+ const matches = allStates.nodes.filter((s) => s.name.toLowerCase() === opts.state.toLowerCase());
127
+ if (matches.length === 0)
128
+ throw new NotFoundError("State", opts.state);
129
+ filter.state = { id: { in: matches.map((s) => s.id) } };
130
+ }
131
+ }
132
+ const limit = parseLimit(opts.limit);
133
+ if (opts.query) {
134
+ const orFilter = {
135
+ or: [{ title: { containsIgnoreCase: opts.query } }, { description: { containsIgnoreCase: opts.query } }],
136
+ };
137
+ const combinedFilter = Object.keys(filter).length ? { and: [filter, orFilter] } : orFilter;
138
+ const nodes = await bulkListIssues(combinedFilter, { max: limit });
139
+ outputSuccess({ issues: nodes.map((n) => formatBulkIssue(n)), count: nodes.length });
140
+ return;
141
+ }
142
+ const nodes = await bulkListIssues(filter, { max: limit });
143
+ outputSuccess({ issues: nodes.map((n) => formatBulkIssue(n)), count: nodes.length });
144
+ }));
145
+ issues
146
+ .command("search <query>")
147
+ .description("Search issues by text")
148
+ .option("--team <team>", "Filter by team")
149
+ .option("--limit <n>", "Max results", "25")
150
+ .action(handleAsyncCommand(async (query, opts) => {
151
+ const limit = parseLimit(opts.limit, 25);
152
+ let teamKey = opts.team;
153
+ if (teamKey && !/^[A-Z]+$/.test(teamKey)) {
154
+ const client = await getClient();
155
+ const t = await resolveTeam(client, teamKey);
156
+ teamKey = t.key;
157
+ }
158
+ const nodes = await bulkSearchIssues(query, { first: limit, teamKey });
159
+ outputSuccess({
160
+ issues: nodes.map((n) => formatBulkIssue(n)),
161
+ count: nodes.length,
162
+ query,
163
+ });
164
+ }));
165
+ issues
166
+ .command("create <title>")
167
+ .description("Create a new issue")
168
+ .requiredOption("--team <team>", "Team name or key")
169
+ .option("-d, --description <desc>", "Issue description (markdown)")
170
+ .option("-a, --assignee <assignee>", "Assignee (name, email, or 'me')")
171
+ .option("-p, --priority <priority>", "Priority: 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low")
172
+ .option("--project <project>", "Project name")
173
+ .option("--labels <labels>", "Comma-separated label names")
174
+ .option("--state <state>", "Workflow state name")
175
+ .option("--due-date <date>", "Due date (YYYY-MM-DD)")
176
+ .option("--parent <parent>", "Parent issue ID (e.g., ENG-123)")
177
+ .option("--skip-label-check", "Bypass team label-policy validation (use only when intentionally violating policy)")
178
+ .action(handleAsyncCommand(async (title, opts) => {
179
+ const client = await getClient();
180
+ const [teamResult, userResult, projectResult, labelResults, parentIssue] = await Promise.all([
181
+ resolveTeam(client, opts.team),
182
+ opts.assignee ? resolveUser(client, opts.assignee) : undefined,
183
+ opts.project ? resolveProject(client, opts.project) : undefined,
184
+ opts.labels ? resolveLabels(client, opts.labels) : undefined,
185
+ opts.parent ? findIssueByIdentifier(client, parseIssueIdentifier(opts.parent)) : undefined,
186
+ ]);
187
+ const passedLabels = labelResults?.map((l) => l.name) ?? [];
188
+ const shellQuote = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
189
+ await enforceTeamLabelPolicy({
190
+ client,
191
+ teamKey: teamResult.key,
192
+ teamId: teamResult.id,
193
+ teamName: teamResult.name,
194
+ finalLabelNames: passedLabels,
195
+ skip: Boolean(opts.skipLabelCheck),
196
+ retryCommand: `elnora-linear issues create ${shellQuote(title)} --team ${shellQuote(teamResult.name)}` +
197
+ (opts.project ? ` --project ${shellQuote(opts.project)}` : "") +
198
+ ' --labels "<add required labels per team policy>"' +
199
+ (opts.priority ? ` --priority ${opts.priority}` : "") +
200
+ (opts.assignee ? ` --assignee ${shellQuote(opts.assignee)}` : ""),
201
+ });
202
+ const input = { teamId: teamResult.id, title };
203
+ if (opts.description)
204
+ input.description = opts.description;
205
+ if (opts.priority) {
206
+ const priority = parsePriority(opts.priority);
207
+ if (priority !== undefined)
208
+ input.priority = priority;
209
+ }
210
+ if (opts.dueDate)
211
+ input.dueDate = parseDate(opts.dueDate);
212
+ if (userResult)
213
+ input.assigneeId = userResult.id;
214
+ if (projectResult)
215
+ input.projectId = projectResult.id;
216
+ if (labelResults)
217
+ input.labelIds = labelResults.map((l) => l.id);
218
+ if (parentIssue)
219
+ input.parentId = parentIssue.id;
220
+ let resolvedStateName = null;
221
+ if (opts.state) {
222
+ const state = await resolveState(client, opts.state, teamResult.id);
223
+ input.stateId = state.id;
224
+ resolvedStateName = state.name;
225
+ }
226
+ const payload = await client.createIssue(input);
227
+ if (!payload.success)
228
+ throw new CliError("Failed to create issue");
229
+ const issue = await payload.issue;
230
+ if (!issue) {
231
+ outputSuccess({ created: true });
232
+ return;
233
+ }
234
+ let formatted;
235
+ if (resolvedStateName) {
236
+ formatted = {
237
+ identifier: issue.identifier,
238
+ title,
239
+ state: resolvedStateName,
240
+ priority: opts.priority ? parsePriority(opts.priority) : 0,
241
+ assignee: userResult?.name ?? null,
242
+ team: teamResult.name,
243
+ project: projectResult?.name ?? null,
244
+ labels: labelResults?.map((l) => l.name) ?? [],
245
+ dueDate: input.dueDate ?? null,
246
+ url: issue.url,
247
+ };
248
+ }
249
+ else {
250
+ formatted = await formatIssue(issue);
251
+ }
252
+ outputSuccess({ created: true, issue: formatted });
253
+ }));
254
+ issues
255
+ .command("get <id>")
256
+ .description("Get issue details by ID (e.g., ENG-123 or UUID)")
257
+ .option("--with-comments", "Also return up to 50 most recent comments on the issue")
258
+ .action(handleAsyncCommand(async (id, opts) => {
259
+ if (/^[A-Z]+-\d+$/.test(id) && !opts.withComments) {
260
+ const node = await bulkGetIssue(id);
261
+ if (!node)
262
+ throw new NotFoundError("Issue", id);
263
+ outputSuccess(formatBulkIssue(node, true));
264
+ return;
265
+ }
266
+ const client = await getClient();
267
+ const issue = await findIssueByIdentifier(client, id);
268
+ const formatted = (await formatIssue(issue, true));
269
+ if (opts.withComments) {
270
+ const conn = await issue.comments({ first: 50 });
271
+ const comments = await Promise.all((conn?.nodes ?? []).map(async (c) => {
272
+ const user = await c.user;
273
+ return {
274
+ id: c.id,
275
+ user: user?.name ?? null,
276
+ body: c.body,
277
+ createdAt: c.createdAt,
278
+ };
279
+ }));
280
+ formatted.comments = comments;
281
+ }
282
+ outputSuccess(formatted);
283
+ }));
284
+ issues
285
+ .command("update <id>")
286
+ .description("Update an existing issue")
287
+ .option("--title <title>", "New title")
288
+ .option("-d, --description <desc>", "New description (markdown)")
289
+ .option("--state <state>", "New state name")
290
+ .option("-a, --assignee <assignee>", "New assignee (name, email, 'me', or 'none')")
291
+ .option("-p, --priority <priority>", "New priority: 0-4")
292
+ .option("--labels <labels>", "Comma-separated label names (replaces existing)")
293
+ .option("--project <project>", "Move to project")
294
+ .option("--due-date <date>", "Due date (YYYY-MM-DD)")
295
+ .option("--team <team>", "Move to different team")
296
+ .option("--skip-label-check", "Bypass team label-policy validation (use only when intentionally violating policy)")
297
+ .option("--with-issue", "Return the full updated issue body (default: just identifier + confirmation)")
298
+ .action(handleAsyncCommand(async (id, opts) => {
299
+ const client = await getClient();
300
+ const withIssue = Boolean(opts.withIssue);
301
+ const issue = await findIssueByIdentifier(client, id);
302
+ const update = {};
303
+ if (opts.title)
304
+ update.title = opts.title;
305
+ if (opts.description)
306
+ update.description = opts.description;
307
+ if (opts.priority) {
308
+ const priority = parsePriority(opts.priority);
309
+ if (priority !== undefined)
310
+ update.priority = priority;
311
+ }
312
+ if (opts.dueDate)
313
+ update.dueDate = parseDate(opts.dueDate);
314
+ const [userResult, labelResults, projectResult, teamResult] = await Promise.all([
315
+ opts.assignee && opts.assignee.toLowerCase() !== "none" ? resolveUser(client, opts.assignee) : undefined,
316
+ opts.labels ? resolveLabels(client, opts.labels) : undefined,
317
+ opts.project ? resolveProject(client, opts.project) : undefined,
318
+ opts.team ? resolveTeam(client, opts.team) : undefined,
319
+ ]);
320
+ if (opts.assignee) {
321
+ update.assigneeId = opts.assignee.toLowerCase() === "none" ? null : userResult?.id;
322
+ }
323
+ if (labelResults)
324
+ update.labelIds = labelResults.map((l) => l.id);
325
+ if (projectResult)
326
+ update.projectId = projectResult.id;
327
+ if (teamResult)
328
+ update.teamId = teamResult.id;
329
+ if (labelResults || teamResult) {
330
+ const effectiveTeam = teamResult ?? (await issue.team);
331
+ if (effectiveTeam) {
332
+ const finalLabels = labelResults
333
+ ? labelResults.map((l) => l.name)
334
+ : (await issue.labels()).nodes.map((l) => l.name);
335
+ await enforceTeamLabelPolicy({
336
+ client,
337
+ teamKey: effectiveTeam.key,
338
+ teamId: effectiveTeam.id,
339
+ teamName: effectiveTeam.name,
340
+ finalLabelNames: finalLabels,
341
+ skip: Boolean(opts.skipLabelCheck),
342
+ retryCommand: `elnora-linear issues update ${issue.identifier} --labels "<full final label set per team policy>"`,
343
+ });
344
+ }
345
+ }
346
+ if (opts.state) {
347
+ const teamObj = teamResult ?? (await issue.team);
348
+ if (teamObj) {
349
+ const state = await resolveState(client, opts.state, teamObj.id);
350
+ update.stateId = state.id;
351
+ }
352
+ else {
353
+ throw new CliError(`Cannot resolve state "${opts.state}": issue has no team. Use --team to specify.`);
354
+ }
355
+ }
356
+ requireNonEmptyUpdate(update);
357
+ const payload = await client.updateIssue(issue.id, update);
358
+ if (!payload.success)
359
+ throw new CliError("Failed to update issue");
360
+ if (withIssue) {
361
+ const refreshed = await bulkGetIssue(issue.identifier);
362
+ outputSuccess({
363
+ updated: true,
364
+ issue: refreshed ? formatBulkIssue(refreshed, true) : { identifier: issue.identifier },
365
+ });
366
+ }
367
+ else {
368
+ outputSuccess({ updated: true, identifier: issue.identifier });
369
+ }
370
+ }));
371
+ issues
372
+ .command("delete <id>")
373
+ .description("Archive an issue (recoverable). Use --permanent --yes for irreversible delete.")
374
+ .option("--permanent", "Permanently delete (irreversible — requires --yes)")
375
+ .option("--yes", "Confirm permanent deletion")
376
+ .action(handleAsyncCommand(async (id, opts) => {
377
+ if (opts.permanent) {
378
+ requireYes(opts, `permanently delete issue ${id}`);
379
+ }
380
+ const client = await getClient();
381
+ const issue = await findIssueByIdentifier(client, id);
382
+ if (opts.permanent) {
383
+ const payload = await client.deleteIssue(issue.id);
384
+ outputSuccess({ deleted: payload.success, id: issue.identifier, permanent: true });
385
+ }
386
+ else {
387
+ const payload = await client.archiveIssue(issue.id);
388
+ outputSuccess({ archived: payload.success, id: issue.identifier });
389
+ }
390
+ }));
391
+ issues
392
+ .command("restore <id>")
393
+ .description("Restore an archived issue")
394
+ .action(handleAsyncCommand(async (id) => {
395
+ const client = await getClient();
396
+ const issue = await findIssueByIdentifier(client, id);
397
+ const payload = await client.unarchiveIssue(issue.id);
398
+ outputSuccess({ restored: payload.success, id: issue.identifier });
399
+ }));
400
+ issues
401
+ .command("subscribe <id>")
402
+ .description("Subscribe to issue updates")
403
+ .action(handleAsyncCommand(async (id) => {
404
+ const client = await getClient();
405
+ const issue = await findIssueByIdentifier(client, id);
406
+ const payload = await client.issueSubscribe(issue.id);
407
+ if (!payload.success)
408
+ throw new CliError("Failed to subscribe");
409
+ outputSuccess({ subscribed: true, id: issue.identifier });
410
+ }));
411
+ issues
412
+ .command("unsubscribe <id>")
413
+ .description("Unsubscribe from issue updates")
414
+ .action(handleAsyncCommand(async (id) => {
415
+ const client = await getClient();
416
+ const issue = await findIssueByIdentifier(client, id);
417
+ const payload = await client.issueUnsubscribe(issue.id);
418
+ if (!payload.success)
419
+ throw new CliError("Failed to unsubscribe");
420
+ outputSuccess({ unsubscribed: true, id: issue.identifier });
421
+ }));
422
+ issues
423
+ .command("add-label <id> <label>")
424
+ .description("Add a single label without disturbing siblings (atomic)")
425
+ .option("--skip-label-check", "Bypass team label-policy validation (use only when intentionally violating policy)")
426
+ .action(handleAsyncCommand(async (id, label, opts) => {
427
+ if (label.includes(",")) {
428
+ throw new ValidationError('add-label takes a single label. Use `issues update <id> --labels "a,b,c"` for multi-label edits.');
429
+ }
430
+ const client = await getClient();
431
+ const [issue, labels] = await Promise.all([findIssueByIdentifier(client, id), resolveLabels(client, label)]);
432
+ const team = await issue.team;
433
+ if (team) {
434
+ const currentLabelNames = (await issue.labels()).nodes.map((l) => l.name);
435
+ const finalLabels = currentLabelNames.includes(labels[0].name)
436
+ ? currentLabelNames
437
+ : [...currentLabelNames, labels[0].name];
438
+ await enforceTeamLabelPolicy({
439
+ client,
440
+ teamKey: team.key,
441
+ teamId: team.id,
442
+ teamName: team.name,
443
+ finalLabelNames: finalLabels,
444
+ skip: Boolean(opts.skipLabelCheck),
445
+ retryCommand: `elnora-linear issues add-label ${issue.identifier} "${labels[0].name}"`,
446
+ });
447
+ }
448
+ const payload = await client.issueAddLabel(issue.id, labels[0].id);
449
+ if (!payload.success)
450
+ throw new CliError("Failed to add label");
451
+ outputSuccess({ added: true, id: issue.identifier, label: labels[0].name });
452
+ }));
453
+ issues
454
+ .command("remove-label <id> <label>")
455
+ .description("Remove a single label without disturbing siblings (atomic)")
456
+ .option("--skip-label-check", "Bypass team label-policy validation (use only when intentionally violating policy)")
457
+ .action(handleAsyncCommand(async (id, label, opts) => {
458
+ if (label.includes(",")) {
459
+ throw new ValidationError('remove-label takes a single label. Use `issues update <id> --labels "a,b,c"` to set the full label set.');
460
+ }
461
+ const client = await getClient();
462
+ const [issue, labels] = await Promise.all([findIssueByIdentifier(client, id), resolveLabels(client, label)]);
463
+ const team = await issue.team;
464
+ if (team) {
465
+ const currentLabelNames = (await issue.labels()).nodes.map((l) => l.name);
466
+ const finalLabels = currentLabelNames.filter((n) => n !== labels[0].name);
467
+ await enforceTeamLabelPolicy({
468
+ client,
469
+ teamKey: team.key,
470
+ teamId: team.id,
471
+ teamName: team.name,
472
+ finalLabelNames: finalLabels,
473
+ skip: Boolean(opts.skipLabelCheck),
474
+ retryCommand: `elnora-linear issues update ${issue.identifier} --labels "<full final set>"`,
475
+ });
476
+ }
477
+ const payload = await client.issueRemoveLabel(issue.id, labels[0].id);
478
+ if (!payload.success)
479
+ throw new CliError("Failed to remove label");
480
+ outputSuccess({ removed: true, id: issue.identifier, label: labels[0].name });
481
+ }));
482
+ issues
483
+ .command("batch-create <jsonFile>")
484
+ .description("Create multiple issues from a JSON array file (or '-' for stdin). Cap 50. N>=10 requires --yes.")
485
+ .option("--yes", "Confirm batch creation when N >= 10")
486
+ .action(handleAsyncCommand(async (jsonFile, opts) => {
487
+ const inputs = readBatchInput(jsonFile);
488
+ if (inputs.length === 0)
489
+ throw new ValidationError("Batch input is empty.");
490
+ if (inputs.length > 50) {
491
+ throw new ValidationError(`Batch too large (${inputs.length}). Linear API caps batches at 50.`);
492
+ }
493
+ if (inputs.length >= 10 && !opts.yes) {
494
+ throw new ValidationError(`Refusing to create ${inputs.length} issues without --yes.`, "Re-run with --yes to confirm.");
495
+ }
496
+ const client = await getClient();
497
+ const payload = await client.createIssueBatch({ issues: inputs });
498
+ if (!payload.success)
499
+ throw new CliError("Failed to create issue batch");
500
+ const issues = await payload.issues;
501
+ outputSuccess({
502
+ created: issues?.length ?? inputs.length,
503
+ ids: issues?.map((i) => i.identifier) ?? [],
504
+ });
505
+ }));
506
+ issues
507
+ .command("batch-update <ids> <jsonPatchFile>")
508
+ .description("Apply the same update to multiple issues. <ids> = comma-separated ENG-X or UUIDs. <jsonPatchFile> = path to JSON IssueUpdateInput (or '-' for stdin).")
509
+ .option("--yes", "Confirm batch update when N >= 10")
510
+ .action(handleAsyncCommand(async (ids, jsonPatchFile, opts) => {
511
+ const idList = ids
512
+ .split(",")
513
+ .map((s) => s.trim())
514
+ .filter(Boolean);
515
+ if (idList.length === 0)
516
+ throw new ValidationError("No issue IDs provided.");
517
+ if (idList.length > 50) {
518
+ throw new ValidationError(`Batch too large (${idList.length}). Linear API caps batches at 50.`);
519
+ }
520
+ if (idList.length >= 10 && !opts.yes) {
521
+ throw new ValidationError(`Refusing to update ${idList.length} issues without --yes.`, "Re-run with --yes to confirm.");
522
+ }
523
+ const patch = readBatchPatch(jsonPatchFile);
524
+ const client = await getClient();
525
+ const uuids = await Promise.all(idList.map(async (id) => (await findIssueByIdentifier(client, id)).id));
526
+ const payload = await client.updateIssueBatch(uuids, patch);
527
+ if (!payload.success)
528
+ throw new CliError("Failed to update issue batch");
529
+ outputSuccess({ updated: idList.length, ids: idList });
530
+ }));
531
+ issues
532
+ .command("bulk-list")
533
+ .description("Bulk-fetch issues with all relations in one GraphQL call. Designed for agent workflows that scan large slices of the workspace without exhausting the rate limit.")
534
+ .option("--team <team>", "Team key (e.g. ELN)")
535
+ .option("--state-type <type>", "Filter by state type: backlog|unstarted|started|completed|canceled")
536
+ .option("--state <name>", "Filter by state name (within --team)")
537
+ .option("--project <id>", "Filter by project UUID")
538
+ .option("--max <n>", "Cap total results (default: unlimited, fetches all pages)")
539
+ .option("--page-size <n>", "Issues per page (default 250, Linear's max)")
540
+ .option("--with-description", "Include issue descriptions (default: omitted to keep payloads small)")
541
+ .option("--with-relations", "Include children and relations (default: omitted)")
542
+ .option("--stats", "Print rate-limit budget summary to stderr after the run")
543
+ .action(handleAsyncCommand(async (opts) => {
544
+ const filter = {};
545
+ if (opts.team)
546
+ filter.team = { key: { eq: opts.team } };
547
+ if (opts.stateType)
548
+ filter.state = { type: { eq: opts.stateType } };
549
+ if (opts.state && opts.team) {
550
+ const stateId = await resolveStateId(opts.team, opts.state);
551
+ if (!stateId)
552
+ throw new NotFoundError("State", opts.state);
553
+ filter.state = { id: { eq: stateId } };
554
+ }
555
+ if (opts.project)
556
+ filter.project = { id: { eq: opts.project } };
557
+ const max = parsePositiveInt(opts.max, "--max");
558
+ const pageSize = parseLimit(opts.pageSize, 250, "--page-size");
559
+ const flags = opts;
560
+ const includeDescription = Boolean(flags.withDescription);
561
+ const includeRelations = Boolean(flags.withRelations);
562
+ const nodes = await bulkListIssues(filter, { pageSize, max, includeDescription, includeRelations });
563
+ outputSuccess({
564
+ issues: nodes.map((n) => {
565
+ const row = {
566
+ identifier: n.identifier,
567
+ title: n.title,
568
+ state: n.state?.name ?? null,
569
+ stateType: n.state?.type ?? null,
570
+ priority: n.priority,
571
+ assignee: n.assignee?.name ?? null,
572
+ team: n.team?.name ?? null,
573
+ project: n.project?.name ?? null,
574
+ labels: n.labels.nodes.map((l) => l.name),
575
+ parent: n.parent?.identifier ?? null,
576
+ url: n.url,
577
+ };
578
+ if (includeDescription)
579
+ row.description = n.description;
580
+ if (includeRelations) {
581
+ row.children = n.children.nodes.map((c) => c.identifier);
582
+ row.relations = n.relations.nodes.map((r) => ({
583
+ type: r.type,
584
+ with: r.relatedIssue?.identifier ?? null,
585
+ }));
586
+ row.updatedAt = n.updatedAt;
587
+ row.createdAt = n.createdAt;
588
+ }
589
+ return row;
590
+ }),
591
+ count: nodes.length,
592
+ });
593
+ if (flags.stats)
594
+ printRateLimitStats("bulk-list");
595
+ }));
596
+ issues
597
+ .command("bulk-ops <opsFile>")
598
+ .description("Execute a JSON file of bulk operations as batched GraphQL mutations. Ops: create | update | relate | comment | label-add | label-remove | archive. Pass '-' to read from stdin.")
599
+ .requiredOption("--team <team>", "Team key for state-name resolution (e.g. ENG)")
600
+ .option("--batch-size <n>", "Mutations per HTTP request (default 10)", "10")
601
+ .option("--dry-run", "Print the resolved op plan and exit without writing")
602
+ .option("--yes", "Confirm when ops count >= 25")
603
+ .option("--stats", "Print rate-limit budget summary to stderr after the run")
604
+ .action(handleAsyncCommand(async (opsFile, opts) => {
605
+ const raw = readJsonSource(opsFile);
606
+ let ops;
607
+ try {
608
+ ops = JSON.parse(raw);
609
+ }
610
+ catch (e) {
611
+ throw new ValidationError(`Invalid JSON in ops file: ${e instanceof Error ? e.message : String(e)}`);
612
+ }
613
+ if (!Array.isArray(ops))
614
+ throw new ValidationError("Ops file must be a JSON array.");
615
+ const opsList = ops;
616
+ if (opsList.length === 0) {
617
+ outputSuccess({ executed: 0, results: [] });
618
+ return;
619
+ }
620
+ if (opsList.length >= 25 && !opts.yes && !opts.dryRun) {
621
+ throw new ValidationError(`Refusing to apply ${opsList.length} ops without --yes.`, "Re-run with --yes to confirm, or --dry-run to inspect the resolved plan.");
622
+ }
623
+ const idSet = new Set();
624
+ const stateNames = new Set();
625
+ const labelNames = new Set();
626
+ const projectNames = new Set();
627
+ const teamNames = new Set();
628
+ const assigneeNames = new Set();
629
+ for (const op of opsList) {
630
+ for (const k of ["id", "from", "to", "parent", "issue"]) {
631
+ const v = op[k];
632
+ if (typeof v === "string" && /^[A-Z]+-\d+$/.test(v))
633
+ idSet.add(v);
634
+ }
635
+ if (typeof op.state === "string")
636
+ stateNames.add(op.state);
637
+ if (typeof op.label === "string")
638
+ labelNames.add(op.label);
639
+ if (Array.isArray(op.labels)) {
640
+ for (const l of op.labels)
641
+ if (typeof l === "string")
642
+ labelNames.add(l);
643
+ }
644
+ if (typeof op.project === "string")
645
+ projectNames.add(op.project);
646
+ if (typeof op.team === "string")
647
+ teamNames.add(op.team);
648
+ if (typeof op.assignee === "string")
649
+ assigneeNames.add(op.assignee);
650
+ }
651
+ const defaultTeamKey = String(opts.team);
652
+ teamNames.add(defaultTeamKey);
653
+ const client = await getClient();
654
+ const [idMap, labelMap, projectMap, teamMap, assigneeMap] = await Promise.all([
655
+ resolveIssueIds([...idSet]),
656
+ (async () => {
657
+ const out = {};
658
+ if (labelNames.size === 0)
659
+ return out;
660
+ const conn = await client.issueLabels({ first: 250 });
661
+ const all = await fetchAllNodes(conn);
662
+ for (const l of all) {
663
+ if (labelNames.has(l.name))
664
+ out[l.name] = l.id;
665
+ }
666
+ return out;
667
+ })(),
668
+ (async () => {
669
+ const out = {};
670
+ if (projectNames.size === 0)
671
+ return out;
672
+ const conn = await client.projects({ first: 250 });
673
+ const all = await fetchAllNodes(conn);
674
+ for (const p of all) {
675
+ if (projectNames.has(p.name))
676
+ out[p.name] = p.id;
677
+ }
678
+ return out;
679
+ })(),
680
+ (async () => {
681
+ const out = {};
682
+ if (teamNames.size === 0)
683
+ return out;
684
+ const conn = await client.teams({ first: 100 });
685
+ const all = await fetchAllNodes(conn);
686
+ for (const t of all) {
687
+ if (teamNames.has(t.name) || teamNames.has(t.key)) {
688
+ out[t.name] = { id: t.id, key: t.key };
689
+ out[t.key] = { id: t.id, key: t.key };
690
+ }
691
+ }
692
+ return out;
693
+ })(),
694
+ (async () => {
695
+ const out = {};
696
+ if (assigneeNames.size === 0)
697
+ return out;
698
+ const conn = await client.users({ first: 250 });
699
+ const all = await fetchAllNodes(conn);
700
+ for (const u of all) {
701
+ if (assigneeNames.has(u.name) || assigneeNames.has(u.email)) {
702
+ if (assigneeNames.has(u.name))
703
+ out[u.name] = u.id;
704
+ if (assigneeNames.has(u.email))
705
+ out[u.email] = u.id;
706
+ }
707
+ }
708
+ return out;
709
+ })(),
710
+ ]);
711
+ const stateMap = {};
712
+ const stateLookupTasks = [];
713
+ const stateRequests = new Set();
714
+ for (const op of opsList) {
715
+ if (typeof op.state !== "string")
716
+ continue;
717
+ const teamKey = resolveBulkOpTeamKey(op, teamMap, defaultTeamKey);
718
+ const key = `${teamKey}:${op.state}`;
719
+ if (stateRequests.has(key))
720
+ continue;
721
+ stateRequests.add(key);
722
+ stateLookupTasks.push((async () => {
723
+ const id = await resolveStateId(teamKey, op.state);
724
+ if (id)
725
+ stateMap[key] = id;
726
+ })());
727
+ }
728
+ await Promise.all(stateLookupTasks);
729
+ const missingIds = [...idSet].filter((i) => !idMap[i]);
730
+ if (missingIds.length > 0)
731
+ throw new NotFoundError("Issues", missingIds.join(", "));
732
+ const missingLabels = [...labelNames].filter((n) => !labelMap[n]);
733
+ if (missingLabels.length > 0)
734
+ throw new NotFoundError("Labels", missingLabels.join(", "));
735
+ const missingProjects = [...projectNames].filter((n) => !projectMap[n]);
736
+ if (missingProjects.length > 0)
737
+ throw new NotFoundError("Projects", missingProjects.join(", "));
738
+ const missingTeams = [...teamNames].filter((n) => !teamMap[n]);
739
+ if (missingTeams.length > 0)
740
+ throw new NotFoundError("Teams", missingTeams.join(", "));
741
+ const missingAssignees = [...assigneeNames].filter((n) => !assigneeMap[n]);
742
+ if (missingAssignees.length > 0)
743
+ throw new NotFoundError("Users", missingAssignees.join(", "));
744
+ const missingStates = [...stateRequests].filter((k) => !stateMap[k]);
745
+ if (missingStates.length > 0)
746
+ throw new NotFoundError("States", missingStates.join(", "));
747
+ const mutations = [];
748
+ const plan = [];
749
+ for (let i = 0; i < opsList.length; i++) {
750
+ const op = opsList[i];
751
+ const kind = op.kind;
752
+ if (kind === "create") {
753
+ if (typeof op.title !== "string")
754
+ throw new ValidationError(`Op #${i}: create requires title`);
755
+ const teamSpec = op.team ?? defaultTeamKey;
756
+ const team = teamMap[teamSpec];
757
+ if (!team)
758
+ throw new ValidationError(`Op #${i}: unknown team ${teamSpec}`);
759
+ const input = { title: op.title, teamId: team.id };
760
+ if (typeof op.description === "string")
761
+ input.description = op.description;
762
+ if (typeof op.priority === "number")
763
+ input.priority = op.priority;
764
+ if (typeof op.dueDate === "string")
765
+ input.dueDate = op.dueDate;
766
+ if (typeof op.project === "string")
767
+ input.projectId = projectMap[op.project];
768
+ if (Array.isArray(op.labels)) {
769
+ input.labelIds = op.labels.map((l) => labelMap[l]);
770
+ }
771
+ if (typeof op.state === "string") {
772
+ input.stateId = stateMap[`${team.key}:${op.state}`];
773
+ }
774
+ if (typeof op.parent === "string")
775
+ input.parentId = idMap[op.parent];
776
+ if (typeof op.assignee === "string")
777
+ input.assigneeId = assigneeMap[op.assignee];
778
+ mutations.push({
779
+ alias: `op${i}`,
780
+ field: "issueCreate",
781
+ vars: { input: { type: "IssueCreateInput!", value: input } },
782
+ selection: "success issue { identifier }",
783
+ });
784
+ plan.push({ alias: `op${i}`, kind: "create", title: op.title, input });
785
+ }
786
+ else if (kind === "update") {
787
+ const input = {};
788
+ if (typeof op.state === "string") {
789
+ const teamKey = resolveBulkOpTeamKey(op, teamMap, defaultTeamKey);
790
+ input.stateId = stateMap[`${teamKey}:${op.state}`];
791
+ }
792
+ if (typeof op.parent === "string")
793
+ input.parentId = idMap[op.parent];
794
+ if (op.parent === null)
795
+ input.parentId = null;
796
+ if (typeof op.description === "string")
797
+ input.description = op.description;
798
+ if (typeof op.priority === "number")
799
+ input.priority = op.priority;
800
+ const id = idMap[op.id];
801
+ if (!id)
802
+ throw new ValidationError(`Op #${i}: unknown issue ${op.id}`);
803
+ mutations.push({
804
+ alias: `op${i}`,
805
+ field: "issueUpdate",
806
+ vars: {
807
+ id: { type: "String!", value: id },
808
+ input: { type: "IssueUpdateInput!", value: input },
809
+ },
810
+ selection: "success issue { identifier }",
811
+ });
812
+ plan.push({ alias: `op${i}`, kind: "update", id: op.id, input });
813
+ }
814
+ else if (kind === "relate") {
815
+ const fromId = idMap[op.from];
816
+ const toId = idMap[op.to];
817
+ const rtype = op.type || "related";
818
+ if (!fromId || !toId)
819
+ throw new ValidationError(`Op #${i}: unknown issue in relate`);
820
+ mutations.push({
821
+ alias: `op${i}`,
822
+ field: "issueRelationCreate",
823
+ vars: {
824
+ input: {
825
+ type: "IssueRelationCreateInput!",
826
+ value: { issueId: fromId, relatedIssueId: toId, type: rtype },
827
+ },
828
+ },
829
+ selection: "success issueRelation { id type }",
830
+ });
831
+ plan.push({ alias: `op${i}`, kind: "relate", from: op.from, to: op.to, type: rtype });
832
+ }
833
+ else if (kind === "comment") {
834
+ const issueId = idMap[op.issue];
835
+ if (!issueId)
836
+ throw new ValidationError(`Op #${i}: unknown issue ${op.issue}`);
837
+ if (typeof op.body !== "string")
838
+ throw new ValidationError(`Op #${i}: comment requires body`);
839
+ mutations.push({
840
+ alias: `op${i}`,
841
+ field: "commentCreate",
842
+ vars: {
843
+ input: {
844
+ type: "CommentCreateInput!",
845
+ value: { issueId, body: op.body },
846
+ },
847
+ },
848
+ selection: "success comment { id }",
849
+ });
850
+ plan.push({ alias: `op${i}`, kind: "comment", issue: op.issue });
851
+ }
852
+ else if (kind === "label-add" || kind === "label-remove") {
853
+ const issueId = idMap[op.issue];
854
+ const labelId = labelMap[op.label];
855
+ if (!issueId)
856
+ throw new ValidationError(`Op #${i}: unknown issue ${op.issue}`);
857
+ if (!labelId)
858
+ throw new ValidationError(`Op #${i}: unknown label ${op.label}`);
859
+ const field = kind === "label-add" ? "issueAddLabel" : "issueRemoveLabel";
860
+ mutations.push({
861
+ alias: `op${i}`,
862
+ field,
863
+ vars: {
864
+ id: { type: "String!", value: issueId },
865
+ labelId: { type: "String!", value: labelId },
866
+ },
867
+ selection: "success",
868
+ });
869
+ plan.push({ alias: `op${i}`, kind, issue: op.issue, label: op.label });
870
+ }
871
+ else if (kind === "archive") {
872
+ const id = idMap[op.id];
873
+ if (!id)
874
+ throw new ValidationError(`Op #${i}: unknown issue ${op.id}`);
875
+ mutations.push({
876
+ alias: `op${i}`,
877
+ field: "issueArchive",
878
+ vars: { id: { type: "String!", value: id } },
879
+ selection: "success",
880
+ });
881
+ plan.push({ alias: `op${i}`, kind: "archive", id: op.id });
882
+ }
883
+ else {
884
+ throw new ValidationError(`Op #${i}: unknown kind "${kind}"`);
885
+ }
886
+ }
887
+ if (opts.dryRun) {
888
+ outputSuccess({ resolved: plan.length, plan });
889
+ return;
890
+ }
891
+ const batchSize = parseInt(opts.batchSize, 10) || 10;
892
+ const results = await batchMutations(mutations, { batchSize });
893
+ const failed = results.filter((r) => !r.ok);
894
+ outputSuccess({
895
+ executed: results.length,
896
+ succeeded: results.length - failed.length,
897
+ failed: failed.length,
898
+ failures: failed,
899
+ });
900
+ if (opts.stats)
901
+ printRateLimitStats("bulk-ops");
902
+ }));
903
+ }
904
+ function readBatchInput(jsonFile) {
905
+ const raw = readJsonSource(jsonFile);
906
+ let parsed;
907
+ try {
908
+ parsed = JSON.parse(raw);
909
+ }
910
+ catch (e) {
911
+ const msg = e instanceof Error ? e.message : String(e);
912
+ throw new ValidationError(`Invalid JSON in batch file: ${msg}`);
913
+ }
914
+ if (!Array.isArray(parsed)) {
915
+ throw new ValidationError("Batch input must be a JSON array of issue inputs.");
916
+ }
917
+ return parsed;
918
+ }
919
+ function readBatchPatch(jsonFile) {
920
+ const raw = readJsonSource(jsonFile);
921
+ let parsed;
922
+ try {
923
+ parsed = JSON.parse(raw);
924
+ }
925
+ catch (e) {
926
+ const msg = e instanceof Error ? e.message : String(e);
927
+ throw new ValidationError(`Invalid JSON in patch file: ${msg}`);
928
+ }
929
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
930
+ throw new ValidationError("Batch patch must be a JSON object (IssueUpdateInput).");
931
+ }
932
+ return parsed;
933
+ }
934
+ function readJsonSource(jsonFile) {
935
+ if (jsonFile === "-") {
936
+ return readFileSync(0, "utf-8");
937
+ }
938
+ const filePath = resolvePath(jsonFile);
939
+ try {
940
+ return readFileSync(filePath, "utf-8");
941
+ }
942
+ catch (e) {
943
+ const msg = e instanceof Error ? e.message : String(e);
944
+ throw new ValidationError(`Cannot read "${jsonFile}": ${msg}`);
945
+ }
946
+ }
947
+ export async function getLabelNames(issue, client) {
948
+ if ("labels" in issue && typeof issue.labels === "function") {
949
+ const conn = await issue.labels();
950
+ return conn?.nodes?.map((l) => l.name) ?? [];
951
+ }
952
+ const ids = issue.labelIds;
953
+ if (!client || !ids || ids.length === 0)
954
+ return [];
955
+ const conn = await client.issueLabels({
956
+ filter: { id: { in: ids } },
957
+ first: ids.length,
958
+ });
959
+ return conn?.nodes?.map((l) => l.name) ?? [];
960
+ }
961
+ async function formatIssue(issue, detailed = false, client) {
962
+ const [state, assignee, team, project] = await Promise.all([issue.state, issue.assignee, issue.team, issue.project]);
963
+ let labelNames = [];
964
+ try {
965
+ labelNames = await getLabelNames(issue, client);
966
+ }
967
+ catch (e) {
968
+ const msg = e instanceof Error ? e.message : String(e);
969
+ process.stderr.write(`Warning: could not fetch labels for ${issue.identifier}: ${msg}\n`);
970
+ }
971
+ const result = {
972
+ identifier: issue.identifier,
973
+ title: issue.title,
974
+ state: state?.name ?? null,
975
+ priority: issue.priority,
976
+ assignee: assignee?.name ?? null,
977
+ team: team?.name ?? null,
978
+ project: project?.name ?? null,
979
+ labels: labelNames,
980
+ dueDate: issue.dueDate ?? null,
981
+ url: issue.url,
982
+ };
983
+ if (detailed) {
984
+ result.id = issue.id;
985
+ result.createdAt = issue.createdAt;
986
+ result.updatedAt = issue.updatedAt;
987
+ result.description = issue.description ?? null;
988
+ const parent = await issue.parent;
989
+ result.parent = parent ? { id: parent.id, identifier: parent.identifier, title: parent.title } : null;
990
+ }
991
+ return result;
992
+ }
993
+ //# sourceMappingURL=issues.js.map