@calltelemetry/openclaw-linear 0.7.1 → 0.8.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/index.ts CHANGED
@@ -96,7 +96,7 @@ export default function register(api: OpenClawPluginApi) {
96
96
  // ---------------------------------------------------------------------------
97
97
 
98
98
  // Instantiate notifier (Discord, Slack, or both — config-driven)
99
- const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
99
+ const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
100
100
 
101
101
  // Register agent_end hook — safety net for sessions_spawn sub-agents.
102
102
  // In the current implementation, the worker→audit→verdict flow runs inline
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-linear",
3
3
  "name": "Linear Agent",
4
4
  "description": "Linear integration with OAuth support, agent pipeline, and webhook-driven AI agent lifecycle",
5
- "version": "0.7.0",
5
+ "version": "0.8.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/prompts.yaml CHANGED
@@ -28,10 +28,13 @@ worker:
28
28
 
29
29
  Instructions:
30
30
  1. Read the issue body carefully — it defines what needs to be done
31
- 2. Plan your approach
32
- 3. Implement the solution in the worktree
33
- 4. Run tests to verify your changes
34
- 5. Return a text summary of what you changed, what tests you ran, and any notes
31
+ 2. If the description is vague or missing, implement a reasonable interpretation and note your assumptions
32
+ 3. Plan your approach
33
+ 4. Implement the solution in the worktree
34
+ 5. Run tests to verify your changes
35
+ 6. If tests fail, diagnose and fix the failures before returning — do not return with failing tests unless you've exhausted your ability to fix them
36
+ 7. Commit your work with a clear commit message
37
+ 8. Return a text summary: what you changed, what tests passed, any assumptions you made, and any open questions
35
38
 
36
39
  Your text output will be captured automatically. Do NOT use linearis or attempt to post comments.
37
40
 
@@ -67,6 +70,9 @@ audit:
67
70
  - Post your audit findings as a comment: `linearis comments create {{identifier}} --body "..."`
68
71
  - If PASS: update status: `linearis issues update {{identifier}} --status "Done"`
69
72
 
73
+ When posting your audit comment, include a brief assessment: what was done well,
74
+ what the gaps are (if any), and what the user should look at next.
75
+
70
76
  You MUST return a JSON verdict as the last line of your response:
71
77
  {"pass": true/false, "criteria": ["list of criteria found"], "gaps": ["list of unmet criteria"], "testResults": "summary of test output"}
72
78
 
@@ -75,7 +81,8 @@ rework:
75
81
  PREVIOUS AUDIT FAILED (attempt {{attempt}}). The auditor found these gaps:
76
82
  {{gaps}}
77
83
 
78
- Address these specific issues in your rework. Focus on the gaps listed above.
84
+ Address these specific issues in your rework. Focus ONLY on the gaps listed above.
85
+ Do NOT undo or rewrite parts that already work — preserve correct code from prior attempts.
79
86
  Remember: you do NOT have linearis access. Just fix the code and return a text summary.
80
87
 
81
88
  planner:
@@ -97,6 +104,8 @@ planner:
97
104
  - After each user response, create or update issues to capture what you learned.
98
105
  - Briefly summarize what you added before asking your next question.
99
106
  - When the plan feels complete, invite the user to say "finalize plan".
107
+ - If the user is vague ("make it better", "you decide"), propose concrete options and ask them to pick.
108
+ - If you've gathered enough info after several turns with no new details, suggest: "This looks ready — say **finalize plan** when you're happy with it."
100
109
 
101
110
  interview: |
102
111
  ## Project: {{projectName}} ({{rootIdentifier}})
@@ -123,4 +132,9 @@ planner:
123
132
  features you want to build, then structure everything into Linear issues with proper
124
133
  epic hierarchy and dependency chains.
125
134
 
135
+ **How this works:**
136
+ - I'll ask questions one at a time and create issues as we go
137
+ - When you're happy with the plan, say **"finalize plan"** — I'll validate it and start dispatching
138
+ - If you want to stop, say **"abandon planning"**
139
+
126
140
  Let's start — what is this project about, and what are the main feature areas?
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Factory functions for Linear GraphQL response shapes.
3
+ *
4
+ * Matches the return types of LinearAgentApi methods.
5
+ */
6
+
7
+ export function makeIssueDetails(overrides?: Record<string, unknown>) {
8
+ return {
9
+ id: "issue-1",
10
+ identifier: "ENG-123",
11
+ title: "Fix webhook routing",
12
+ description: "The webhook handler needs fixing.",
13
+ estimate: 3,
14
+ state: { name: "In Progress" },
15
+ assignee: { name: "Agent" },
16
+ labels: { nodes: [] as Array<{ id: string; name: string }> },
17
+ team: { id: "team-1", name: "Engineering", issueEstimationType: "notUsed" },
18
+ comments: { nodes: [] as Array<{ body: string; user: { name: string } | null; createdAt: string }> },
19
+ project: null as { id: string; name: string } | null,
20
+ parent: null as { id: string; identifier: string } | null,
21
+ relations: { nodes: [] as Array<{ type: string; relatedIssue: { id: string; identifier: string; title: string } }> },
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ export function makeProjectIssue(
27
+ identifier: string,
28
+ opts?: {
29
+ title?: string;
30
+ description?: string;
31
+ estimate?: number;
32
+ priority?: number;
33
+ state?: { name: string; type: string };
34
+ parentIdentifier?: string;
35
+ labels?: string[];
36
+ relations?: Array<{ type: string; relatedIdentifier: string; relatedTitle?: string }>;
37
+ },
38
+ ) {
39
+ return {
40
+ id: `id-${identifier}`,
41
+ identifier,
42
+ title: opts?.title ?? `Issue ${identifier}`,
43
+ description: opts?.description ?? null,
44
+ estimate: opts?.estimate ?? null,
45
+ priority: opts?.priority ?? 0,
46
+ state: opts?.state ?? { name: "Backlog", type: "backlog" },
47
+ parent: opts?.parentIdentifier
48
+ ? { id: `id-${opts.parentIdentifier}`, identifier: opts.parentIdentifier }
49
+ : null,
50
+ labels: {
51
+ nodes: (opts?.labels ?? []).map((name) => ({ id: `label-${name}`, name })),
52
+ },
53
+ relations: {
54
+ nodes: (opts?.relations ?? []).map((r) => ({
55
+ type: r.type,
56
+ relatedIssue: {
57
+ id: `id-${r.relatedIdentifier}`,
58
+ identifier: r.relatedIdentifier,
59
+ title: r.relatedTitle ?? `Issue ${r.relatedIdentifier}`,
60
+ },
61
+ })),
62
+ },
63
+ };
64
+ }
65
+
66
+ export function makeProject(overrides?: Record<string, unknown>) {
67
+ return {
68
+ id: "proj-1",
69
+ name: "Test Project",
70
+ description: "A test project",
71
+ state: "started",
72
+ teams: { nodes: [{ id: "team-1", name: "Engineering" }] },
73
+ ...overrides,
74
+ };
75
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Factory functions for Linear webhook event payloads.
3
+ *
4
+ * Matches the shapes received at /linear/webhook from both
5
+ * workspace webhooks and OAuth app webhooks.
6
+ */
7
+
8
+ export function makeAgentSessionCreated(overrides?: Record<string, unknown>) {
9
+ return {
10
+ type: "AgentSession",
11
+ action: "create",
12
+ data: {
13
+ id: "sess-1",
14
+ context: { commentBody: "Please investigate this issue" },
15
+ },
16
+ issue: {
17
+ id: "issue-1",
18
+ identifier: "ENG-123",
19
+ title: "Fix webhook routing",
20
+ },
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ export function makeAgentSessionPrompted(overrides?: Record<string, unknown>) {
26
+ return {
27
+ type: "AgentSession",
28
+ action: "prompted",
29
+ data: {
30
+ id: "sess-prompted",
31
+ context: { prompt: "Looks good, approved!" },
32
+ },
33
+ issue: {
34
+ id: "issue-2",
35
+ identifier: "ENG-124",
36
+ title: "Approved issue",
37
+ },
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ export function makeCommentCreate(overrides?: Record<string, unknown>) {
43
+ return {
44
+ type: "Comment",
45
+ action: "create",
46
+ data: {
47
+ id: "comment-1",
48
+ body: "This needs work",
49
+ user: { id: "user-1", name: "Test User" },
50
+ issue: {
51
+ id: "issue-1",
52
+ identifier: "ENG-123",
53
+ title: "Fix webhook routing",
54
+ team: { id: "team-1" },
55
+ assignee: { id: "viewer-1" },
56
+ project: null,
57
+ },
58
+ createdAt: new Date().toISOString(),
59
+ },
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ export function makeIssueUpdate(overrides?: Record<string, unknown>) {
65
+ return {
66
+ type: "Issue",
67
+ action: "update",
68
+ data: {
69
+ id: "issue-1",
70
+ identifier: "ENG-123",
71
+ title: "Fix webhook routing",
72
+ state: { name: "In Progress", type: "started" },
73
+ assignee: { id: "viewer-1", name: "Agent" },
74
+ team: { id: "team-1" },
75
+ project: null,
76
+ },
77
+ ...overrides,
78
+ };
79
+ }
80
+
81
+ export function makeIssueCreate(overrides?: Record<string, unknown>) {
82
+ return {
83
+ type: "Issue",
84
+ action: "create",
85
+ data: {
86
+ id: "issue-new",
87
+ identifier: "ENG-200",
88
+ title: "New issue",
89
+ state: { name: "Backlog", type: "backlog" },
90
+ assignee: null,
91
+ team: { id: "team-1" },
92
+ project: null,
93
+ },
94
+ ...overrides,
95
+ };
96
+ }
97
+
98
+ export function makeAppUserNotification(overrides?: Record<string, unknown>) {
99
+ return {
100
+ type: "AppUserNotification",
101
+ action: "create",
102
+ data: {
103
+ type: "issueAssigned",
104
+ issue: {
105
+ id: "issue-1",
106
+ identifier: "ENG-123",
107
+ title: "Fix webhook routing",
108
+ },
109
+ user: { id: "user-1", name: "Test User" },
110
+ },
111
+ ...overrides,
112
+ };
113
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Shared test helpers for E2E and integration tests.
3
+ *
4
+ * Provides reusable mock factories that consolidate patterns scattered across
5
+ * unit test files. Existing unit tests keep their local factories; new E2E
6
+ * tests use these from the start.
7
+ */
8
+ import { vi } from "vitest";
9
+ import { mkdtempSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
13
+ import type { HookContext } from "../pipeline/pipeline.js";
14
+ import type { NotifyFn } from "../infra/notify.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Mock OpenClaw Plugin API
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export function createMockApi(overrides?: Partial<{
21
+ logger: Partial<OpenClawPluginApi["logger"]>;
22
+ pluginConfig: Record<string, unknown>;
23
+ runtime: Record<string, unknown>;
24
+ }>): OpenClawPluginApi {
25
+ return {
26
+ logger: {
27
+ info: vi.fn(),
28
+ warn: vi.fn(),
29
+ error: vi.fn(),
30
+ debug: vi.fn(),
31
+ ...overrides?.logger,
32
+ },
33
+ pluginConfig: overrides?.pluginConfig ?? {},
34
+ runtime: {
35
+ channel: {
36
+ discord: { sendMessageDiscord: vi.fn().mockResolvedValue(undefined) },
37
+ slack: { sendMessageSlack: vi.fn().mockResolvedValue(undefined) },
38
+ telegram: { sendMessageTelegram: vi.fn().mockResolvedValue(undefined) },
39
+ signal: { sendMessageSignal: vi.fn().mockResolvedValue(undefined) },
40
+ },
41
+ ...overrides?.runtime,
42
+ },
43
+ } as unknown as OpenClawPluginApi;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Mock Linear API
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface MockLinearApi {
51
+ getIssueDetails: ReturnType<typeof vi.fn>;
52
+ createComment: ReturnType<typeof vi.fn>;
53
+ emitActivity: ReturnType<typeof vi.fn>;
54
+ updateSession: ReturnType<typeof vi.fn>;
55
+ getProject: ReturnType<typeof vi.fn>;
56
+ getProjectIssues: ReturnType<typeof vi.fn>;
57
+ getTeamStates: ReturnType<typeof vi.fn>;
58
+ getTeamLabels: ReturnType<typeof vi.fn>;
59
+ createIssue: ReturnType<typeof vi.fn>;
60
+ updateIssue: ReturnType<typeof vi.fn>;
61
+ updateIssueExtended: ReturnType<typeof vi.fn>;
62
+ createIssueRelation: ReturnType<typeof vi.fn>;
63
+ getViewerId: ReturnType<typeof vi.fn>;
64
+ }
65
+
66
+ export function createMockLinearApi(overrides?: Partial<MockLinearApi>): MockLinearApi {
67
+ return {
68
+ getIssueDetails: vi.fn().mockResolvedValue(null),
69
+ createComment: vi.fn().mockResolvedValue("comment-id"),
70
+ emitActivity: vi.fn().mockResolvedValue(undefined),
71
+ updateSession: vi.fn().mockResolvedValue(undefined),
72
+ getProject: vi.fn().mockResolvedValue({
73
+ id: "proj-1",
74
+ name: "Test Project",
75
+ description: "",
76
+ state: "started",
77
+ teams: { nodes: [{ id: "team-1", name: "Team" }] },
78
+ }),
79
+ getProjectIssues: vi.fn().mockResolvedValue([]),
80
+ getTeamStates: vi.fn().mockResolvedValue([
81
+ { id: "st-1", name: "Backlog", type: "backlog" },
82
+ { id: "st-2", name: "In Progress", type: "started" },
83
+ { id: "st-3", name: "Done", type: "completed" },
84
+ ]),
85
+ getTeamLabels: vi.fn().mockResolvedValue([]),
86
+ createIssue: vi.fn().mockResolvedValue({ id: "new-issue-id", identifier: "PROJ-NEW" }),
87
+ updateIssue: vi.fn().mockResolvedValue(undefined),
88
+ updateIssueExtended: vi.fn().mockResolvedValue(undefined),
89
+ createIssueRelation: vi.fn().mockResolvedValue(undefined),
90
+ getViewerId: vi.fn().mockResolvedValue("viewer-1"),
91
+ ...overrides,
92
+ };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Mock HookContext
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export function createMockHookCtx(opts: {
100
+ configPath?: string;
101
+ planningStatePath?: string;
102
+ pluginConfig?: Record<string, unknown>;
103
+ linearApi?: MockLinearApi;
104
+ notify?: NotifyFn;
105
+ }): HookContext {
106
+ const configPath = opts.configPath ?? tmpStatePath("claw-hook-");
107
+ return {
108
+ api: createMockApi({
109
+ pluginConfig: {
110
+ dispatchStatePath: configPath,
111
+ planningStatePath: opts.planningStatePath ?? configPath,
112
+ ...opts.pluginConfig,
113
+ },
114
+ }),
115
+ linearApi: (opts.linearApi ?? createMockLinearApi()) as any,
116
+ notify: opts.notify ?? vi.fn().mockResolvedValue(undefined),
117
+ pluginConfig: {
118
+ dispatchStatePath: configPath,
119
+ planningStatePath: opts.planningStatePath ?? configPath,
120
+ ...opts.pluginConfig,
121
+ },
122
+ configPath,
123
+ };
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Temp path helper
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export function tmpStatePath(prefix = "claw-test-"): string {
131
+ const dir = mkdtempSync(join(tmpdir(), prefix));
132
+ return join(dir, "state.json");
133
+ }
@@ -61,6 +61,149 @@ function createApi(): OpenClawPluginApi {
61
61
  } as unknown as OpenClawPluginApi;
62
62
  }
63
63
 
64
+ describe("runAgent subprocess", () => {
65
+ it("extracts text from JSON payloads", async () => {
66
+ const api = createApi();
67
+ (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
68
+ code: 0,
69
+ stdout: JSON.stringify({ result: { payloads: [{ text: "hello" }, { text: "world" }] } }),
70
+ stderr: "",
71
+ });
72
+
73
+ const result = await runAgent({
74
+ api,
75
+ agentId: "test-agent",
76
+ sessionId: "session-1",
77
+ message: "do something",
78
+ });
79
+
80
+ expect(result.success).toBe(true);
81
+ expect(result.output).toContain("hello");
82
+ expect(result.output).toContain("world");
83
+ });
84
+
85
+ it("uses raw stdout when JSON parsing fails", async () => {
86
+ const api = createApi();
87
+ (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
88
+ code: 0,
89
+ stdout: "plain text output",
90
+ stderr: "",
91
+ });
92
+
93
+ const result = await runAgent({
94
+ api,
95
+ agentId: "test-agent",
96
+ sessionId: "session-1",
97
+ message: "do something",
98
+ });
99
+
100
+ expect(result.success).toBe(true);
101
+ expect(result.output).toBe("plain text output");
102
+ });
103
+
104
+ it("uses stderr when command fails with no stdout", async () => {
105
+ const api = createApi();
106
+ (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
107
+ code: 1,
108
+ stdout: "",
109
+ stderr: "error from stderr",
110
+ });
111
+
112
+ const result = await runAgent({
113
+ api,
114
+ agentId: "test-agent",
115
+ sessionId: "session-1",
116
+ message: "do something",
117
+ });
118
+
119
+ expect(result.success).toBe(false);
120
+ expect(result.output).toContain("error from stderr");
121
+ });
122
+
123
+ it("includes agentId in command arguments", async () => {
124
+ const api = createApi();
125
+ const runCmd = vi.fn().mockResolvedValue({
126
+ code: 0,
127
+ stdout: "ok",
128
+ stderr: "",
129
+ });
130
+ (api.runtime.system as any).runCommandWithTimeout = runCmd;
131
+
132
+ await runAgent({
133
+ api,
134
+ agentId: "my-agent",
135
+ sessionId: "session-1",
136
+ message: "test",
137
+ });
138
+
139
+ const args = runCmd.mock.calls[0][0];
140
+ expect(args).toContain("my-agent");
141
+ expect(args).toContain("--agent");
142
+ });
143
+
144
+ it("passes timeout in seconds to subprocess", async () => {
145
+ const api = createApi();
146
+ const runCmd = vi.fn().mockResolvedValue({
147
+ code: 0,
148
+ stdout: "ok",
149
+ stderr: "",
150
+ });
151
+ (api.runtime.system as any).runCommandWithTimeout = runCmd;
152
+
153
+ await runAgent({
154
+ api,
155
+ agentId: "test",
156
+ sessionId: "session-1",
157
+ message: "test",
158
+ timeoutMs: 60_000,
159
+ });
160
+
161
+ const args: string[] = runCmd.mock.calls[0][0];
162
+ const timeoutIdx = args.indexOf("--timeout");
163
+ expect(timeoutIdx).toBeGreaterThan(-1);
164
+ expect(args[timeoutIdx + 1]).toBe("60");
165
+ });
166
+
167
+ it("handles empty payloads array", async () => {
168
+ const api = createApi();
169
+ (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
170
+ code: 0,
171
+ stdout: JSON.stringify({ result: { payloads: [] } }),
172
+ stderr: "",
173
+ });
174
+
175
+ const result = await runAgent({
176
+ api,
177
+ agentId: "test",
178
+ sessionId: "s1",
179
+ message: "test",
180
+ });
181
+
182
+ expect(result.success).toBe(true);
183
+ // Falls back to raw stdout when no payload text
184
+ expect(result.output).toBeTruthy();
185
+ });
186
+
187
+ it("handles null payloads text", async () => {
188
+ const api = createApi();
189
+ (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
190
+ code: 0,
191
+ stdout: JSON.stringify({ result: { payloads: [{ text: null }, { text: "real" }] } }),
192
+ stderr: "",
193
+ });
194
+
195
+ const result = await runAgent({
196
+ api,
197
+ agentId: "test",
198
+ sessionId: "s1",
199
+ message: "test",
200
+ });
201
+
202
+ expect(result.success).toBe(true);
203
+ expect(result.output).toContain("real");
204
+ });
205
+ });
206
+
64
207
  describe("runAgent retry wrapper", () => {
65
208
  it("returns success on first attempt when no watchdog kill", async () => {
66
209
  const api = createApi();
@@ -300,7 +300,7 @@ describe("LinearAgentApi", () => {
300
300
  });
301
301
 
302
302
  await expect(api.updateIssue("i1", { estimate: 1 })).rejects.toThrow(
303
- /Linear API 403 \(after refresh\)/,
303
+ /Linear API authentication failed/,
304
304
  );
305
305
  });
306
306
  });
@@ -464,6 +464,98 @@ describe("LinearAgentApi", () => {
464
464
  });
465
465
  });
466
466
 
467
+ describe("getTeams", () => {
468
+ it("returns parsed team list", async () => {
469
+ fetchMock.mockResolvedValueOnce(
470
+ okResponse({
471
+ teams: {
472
+ nodes: [
473
+ { id: "t1", name: "Engineering", key: "ENG" },
474
+ { id: "t2", name: "Design", key: "DES" },
475
+ ],
476
+ },
477
+ }),
478
+ );
479
+
480
+ const api = new LinearAgentApi(TOKEN);
481
+ const teams = await api.getTeams();
482
+ expect(teams).toHaveLength(2);
483
+ expect(teams[0]).toEqual({ id: "t1", name: "Engineering", key: "ENG" });
484
+ expect(teams[1]).toEqual({ id: "t2", name: "Design", key: "DES" });
485
+ });
486
+
487
+ it("handles empty teams list", async () => {
488
+ fetchMock.mockResolvedValueOnce(okResponse({ teams: { nodes: [] } }));
489
+
490
+ const api = new LinearAgentApi(TOKEN);
491
+ const teams = await api.getTeams();
492
+ expect(teams).toEqual([]);
493
+ });
494
+ });
495
+
496
+ describe("createLabel", () => {
497
+ it("sends correct mutation and returns label", async () => {
498
+ fetchMock.mockResolvedValueOnce(
499
+ okResponse({
500
+ issueLabelCreate: {
501
+ success: true,
502
+ issueLabel: { id: "label-1", name: "repo:api" },
503
+ },
504
+ }),
505
+ );
506
+
507
+ const api = new LinearAgentApi(TOKEN);
508
+ const label = await api.createLabel("t1", "repo:api", {
509
+ color: "#5e6ad2",
510
+ description: "Multi-repo dispatch: api",
511
+ });
512
+
513
+ expect(label).toEqual({ id: "label-1", name: "repo:api" });
514
+
515
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
516
+ expect(body.query).toContain("issueLabelCreate");
517
+ expect(body.variables.input).toEqual({
518
+ teamId: "t1",
519
+ name: "repo:api",
520
+ color: "#5e6ad2",
521
+ description: "Multi-repo dispatch: api",
522
+ });
523
+ });
524
+
525
+ it("throws on API failure", async () => {
526
+ fetchMock.mockResolvedValueOnce(
527
+ okResponse({
528
+ issueLabelCreate: { success: false, issueLabel: null },
529
+ }),
530
+ );
531
+
532
+ const api = new LinearAgentApi(TOKEN);
533
+ await expect(
534
+ api.createLabel("t1", "repo:bad"),
535
+ ).rejects.toThrow(/Failed to create label/);
536
+ });
537
+
538
+ it("omits optional fields when not provided", async () => {
539
+ fetchMock.mockResolvedValueOnce(
540
+ okResponse({
541
+ issueLabelCreate: {
542
+ success: true,
543
+ issueLabel: { id: "label-2", name: "repo:frontend" },
544
+ },
545
+ }),
546
+ );
547
+
548
+ const api = new LinearAgentApi(TOKEN);
549
+ await api.createLabel("t1", "repo:frontend");
550
+
551
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
552
+ expect(body.variables.input).toEqual({
553
+ teamId: "t1",
554
+ name: "repo:frontend",
555
+ });
556
+ });
557
+ });
558
+
467
559
  describe("createSessionOnIssue", () => {
468
560
  it("returns sessionId on success", async () => {
469
561
  fetchMock.mockResolvedValueOnce(