Skip to content

Commit 9c0bc35

Browse files
authored
Merge pull request #248 from windmill-labs/feat/linear-pickup-comment
feat: post Linear comment when auto-create picks up an issue
2 parents b790a50 + 55cd8c2 commit 9c0bc35

6 files changed

Lines changed: 194 additions & 7 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ See your assigned Linear issues alongside your worktrees. Webmux matches branche
5050

5151
Each issue is processed once while it stays in Todo + labeled. Remove the label and re-add it to retrigger.
5252

53+
When the auto-create watcher picks up a `webmux_oneshot` issue, it posts a structured comment on the Linear issue (prefix `` **Webmux pickup — branch `<branch>`** ``) so external automation can track when the autonomous run starts. (Regular `webmux` pickups are user-driven and skip the comment.)
54+
5355
## Quick Start
5456

5557
```bash

backend/src/__tests__/linear-auto-create-service.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ function createDeps(input: {
7272
existingBranches?: string[];
7373
fetchResult?: FetchIssuesResult;
7474
onFetch?: (options: { skipCache?: boolean } | undefined) => void;
75-
runOneshotForIssue?: (issueId: string) => Promise<void>;
75+
runOneshotForIssue?: (issueId: string) => Promise<{ branch: string }>;
76+
onOneshotPickedUp?: (input: { issue: LinearIssue; branch: string }) => Promise<void>;
7677
} = {}): {
7778
deps: LinearAutoCreateDependencies;
7879
created: CreateLifecycleWorktreeInput[];
@@ -116,6 +117,7 @@ function createDeps(input: {
116117
};
117118
},
118119
...(input.runOneshotForIssue ? { runOneshotForIssue: input.runOneshotForIssue } : {}),
120+
...(input.onOneshotPickedUp ? { onOneshotPickedUp: input.onOneshotPickedUp } : {}),
119121
},
120122
};
121123
}
@@ -360,6 +362,7 @@ describe("runLinearAutoCreateOnce", () => {
360362
issues: [oneshotIssue, createIssue1],
361363
runOneshotForIssue: async (id) => {
362364
triggered.push(id);
365+
return { branch: "eng-200-oneshot" };
363366
},
364367
});
365368

@@ -369,6 +372,97 @@ describe("runLinearAutoCreateOnce", () => {
369372
expect(created.map((c) => c.branch)).toEqual(["eng-201-create"]);
370373
});
371374

375+
it("does not notify onOneshotPickedUp for the regular `webmux` (create) path", async () => {
376+
// Regular pickups are user-driven (the user added the label themselves);
377+
// the comment would be noise. Only the autonomous oneshot path gets it.
378+
const issue = createIssue();
379+
const pickups: string[] = [];
380+
const { deps } = createDeps({
381+
issues: [issue],
382+
onOneshotPickedUp: async ({ issue: picked }) => {
383+
pickups.push(picked.identifier);
384+
},
385+
});
386+
387+
await runLinearAutoCreateOnce(deps);
388+
389+
expect(pickups).toEqual([]);
390+
});
391+
392+
it("notifies onOneshotPickedUp with the branch resolved by runOneshotForIssue, not the issue's branchName", async () => {
393+
// Regression guard: `buildSeedFromLinear` may resolve to an attachment-payload
394+
// or PR branch instead of `issue.branchName`. The pickup-comment contract
395+
// requires the *actual* working branch.
396+
const oneshotIssue = createIssue({
397+
id: "issue-oneshot", identifier: "ENG-200", branchName: "eng-200-original",
398+
labels: [{ name: "webmux_oneshot", color: "#fff" }],
399+
});
400+
const pickups: Array<{ identifier: string; branch: string }> = [];
401+
const { deps } = createDeps({
402+
issues: [oneshotIssue],
403+
runOneshotForIssue: async () => ({ branch: "eng-200-resumed-from-attachment" }),
404+
onOneshotPickedUp: async ({ issue: picked, branch }) => {
405+
pickups.push({ identifier: picked.identifier, branch });
406+
},
407+
});
408+
409+
await runLinearAutoCreateOnce(deps);
410+
411+
expect(pickups).toEqual([
412+
{ identifier: "ENG-200", branch: "eng-200-resumed-from-attachment" },
413+
]);
414+
});
415+
416+
it("does not notify onOneshotPickedUp when the oneshot launch itself fails", async () => {
417+
const oneshotIssue = createIssue({
418+
id: "issue-oneshot", identifier: "ENG-200", branchName: "eng-200-oneshot",
419+
labels: [{ name: "webmux_oneshot", color: "#fff" }],
420+
});
421+
const pickups: string[] = [];
422+
const deps: LinearAutoCreateDependencies = {
423+
lifecycleService: {
424+
async createWorktree(): Promise<{ branch: string; worktreeId: string }> {
425+
throw new Error("should not be called");
426+
},
427+
},
428+
git: { listWorktrees: () => [] },
429+
projectRoot: "/repo",
430+
fetchIssues: async () => ({ ok: true, data: [oneshotIssue] }),
431+
runOneshotForIssue: async () => { throw new Error("server unreachable"); },
432+
onOneshotPickedUp: async ({ issue: picked }) => {
433+
pickups.push(picked.identifier);
434+
},
435+
};
436+
437+
await runLinearAutoCreateOnce(deps);
438+
439+
expect(pickups).toEqual([]);
440+
});
441+
442+
it("swallows onOneshotPickedUp failures so the pickup still completes", async () => {
443+
const oneshotIssue = createIssue({
444+
id: "issue-oneshot", identifier: "ENG-200", branchName: "eng-200-oneshot",
445+
labels: [{ name: "webmux_oneshot", color: "#fff" }],
446+
});
447+
const oneshotCalls: string[] = [];
448+
const { deps } = createDeps({
449+
issues: [oneshotIssue],
450+
runOneshotForIssue: async (id) => {
451+
oneshotCalls.push(id);
452+
return { branch: "eng-200-oneshot" };
453+
},
454+
onOneshotPickedUp: async () => {
455+
throw new Error("Linear comment failed");
456+
},
457+
});
458+
459+
await runLinearAutoCreateOnce(deps);
460+
// Pickup still happened and is deduped on the next pass.
461+
await runLinearAutoCreateOnce(deps);
462+
463+
expect(oneshotCalls).toEqual(["ENG-200"]);
464+
});
465+
372466
it("skips webmux_oneshot issues when no runOneshotForIssue dep is provided", async () => {
373467
const oneshotIssue = createIssue({
374468
id: "issue-oneshot", identifier: "ENG-200", branchName: "eng-200-oneshot",

backend/src/__tests__/linear-service.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ describe("Linear issue creation", () => {
361361
});
362362

363363
import {
364+
buildLinearPickupMarkdown,
364365
buildLinearSummaryMarkdown,
365366
buildWebmuxAttachmentTitle,
366367
findLinkedGitHubPr,
@@ -477,6 +478,22 @@ describe("findLinkedGitHubPr", () => {
477478
});
478479
});
479480

481+
describe("buildLinearPickupMarkdown", () => {
482+
it("renders the pickup comment verbatim", () => {
483+
// Pins the exact wire format because external automation greps the
484+
// prefix — changing it silently would break those integrations.
485+
const md = buildLinearPickupMarkdown({
486+
branch: "eng-200-oneshot",
487+
pickedUpAt: new Date("2026-05-20T12:34:56.789Z"),
488+
});
489+
expect(md).toBe(
490+
"**Webmux pickup — branch `eng-200-oneshot`**\n" +
491+
"\n" +
492+
"- Picked up: 2026-05-20T12:34:56.789Z",
493+
);
494+
});
495+
});
496+
480497
describe("buildLinearSummaryMarkdown", () => {
481498
it("includes turns and the attachment title", () => {
482499
const md = buildLinearSummaryMarkdown({

backend/src/server.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
attachToIssue,
6363
branchMatchesIssue,
6464
buildLinearIssuesResponse,
65+
buildLinearPickupMarkdown,
6566
createIssueComment,
6667
createLinearIssue,
6768
deriveLinearIssueTitle,
@@ -136,8 +137,11 @@ let stopLinearAutoCreate: (() => void) | null = null;
136137
let autoRemoveOnMergeEnabled = config.integrations.github.autoRemoveOnMerge;
137138

138139
/** Create a worktree in oneshot mode for the given Linear issue and arm the
139-
* server-side watcher to post results back + close the session when done. */
140-
async function runOneshotForIssue(issueId: string): Promise<void> {
140+
* server-side watcher to post results back + close the session when done. Returns
141+
* the resolved working branch — the seed may pick `attachmentPayload.branch ??
142+
* pr.branch ?? issue.branchName`, so the caller (e.g. the pickup-comment poster)
143+
* must use this value, not `issue.branchName`. */
144+
async function runOneshotForIssue(issueId: string): Promise<{ branch: string }> {
141145
const seed = await buildSeedFromLinear({ issueId }, defaultSeedFromLinearDeps);
142146
if (!seed.ok) {
143147
throw new Error(`Linear seed failed for ${issueId}: ${seed.error}`);
@@ -160,6 +164,7 @@ async function runOneshotForIssue(issueId: string): Promise<void> {
160164
postToLinearOnDone: { kind: "issue", issueId },
161165
},
162166
});
167+
return { branch };
163168
}
164169

165170
/** Safe to call multiple times — the guard prevents duplicate monitors. */
@@ -171,10 +176,34 @@ function startLinearAutoCreate(): void {
171176
git,
172177
projectRoot: PROJECT_DIR,
173178
runOneshotForIssue,
179+
onOneshotPickedUp: postLinearOneshotPickupComment,
174180
...(watchTeamKeys && watchTeamKeys.length > 0 ? { watchTeamKeys } : {}),
175181
});
176182
}
177183

184+
/** Post the structured pickup comment on the Linear issue when the auto-create watcher
185+
* picks up a `webmux_oneshot` issue, so external automation can see the autonomous run
186+
* started. `branch` is the *actual* working branch (which can differ from
187+
* `issue.branchName` — see `runOneshotForIssue`). Failures are logged and swallowed —
188+
* pickup itself must not depend on this. Markdown is built by the pure
189+
* `buildLinearPickupMarkdown` in `linear-service.ts` so the grep-able prefix has a
190+
* unit-test contract. */
191+
async function postLinearOneshotPickupComment(input: {
192+
issue: { id: string; identifier: string };
193+
branch: string;
194+
}): Promise<void> {
195+
const body = buildLinearPickupMarkdown({
196+
branch: input.branch,
197+
pickedUpAt: new Date(),
198+
});
199+
const result = await createIssueComment({ issueId: input.issue.id, body });
200+
if (!result.ok) {
201+
log.warn(`[linear-auto-create] failed to post pickup comment for ${input.issue.identifier}: ${result.error}`);
202+
return;
203+
}
204+
log.info(`[linear-auto-create] posted pickup comment for ${input.issue.identifier}: ${result.data.url}`);
205+
}
206+
178207
/** Map the wire-side `OneshotConfig` (all-optional fields) to the persisted
179208
* `OneshotMeta` shape (autoCloseOnDone has a definite boolean). Default is
180209
* `true` — callers must opt out explicitly. */

backend/src/services/linear-auto-create-service.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,21 @@ export interface LinearAutoCreateDependencies {
1818
git: Pick<GitGateway, "listWorktrees">;
1919
projectRoot: string;
2020
fetchIssues?: typeof fetchAssignedIssues;
21-
/** Optional handler for the `webmux_oneshot` label variant. When omitted, oneshot triggering is skipped. */
22-
runOneshotForIssue?: (issueId: string) => Promise<void>;
21+
/** Optional handler for the `webmux_oneshot` label variant. Must return the actual
22+
* working branch — the oneshot seed may resolve to `attachmentPayload.branch ??
23+
* pr.branch ?? issue.branchName`, which is not always `issue.branchName`. When
24+
* omitted, oneshot triggering is skipped. */
25+
runOneshotForIssue?: (issueId: string) => Promise<{ branch: string }>;
2326
/** Restrict triggering to issues whose team.key is in this list (uppercase).
2427
* Undefined or empty → no team filter (all teams). */
2528
watchTeamKeys?: string[];
29+
/** Optional callback invoked after a successful oneshot pickup so external automation
30+
* can be notified. `branch` is the actual working branch (can differ from
31+
* `issue.branchName` — see `runOneshotForIssue`). Failures are logged and
32+
* swallowed — they must not block the pickup itself.
33+
* Only fires for the `webmux_oneshot` path: regular `webmux` pickups are
34+
* user-driven and don't need a Linear-side bookend. */
35+
onOneshotPickedUp?: (input: { issue: LinearIssue; branch: string }) => Promise<void>;
2636
}
2737

2838
export interface LinearAutoCreateMonitorOptions {
@@ -145,9 +155,10 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
145155
for (const issue of oneshotIssues) {
146156
try {
147157
log.info(`[linear-auto-create] launching oneshot for ${issue.identifier}: ${issue.title}`);
148-
await deps.runOneshotForIssue!(issue.identifier);
158+
const { branch } = await deps.runOneshotForIssue!(issue.identifier);
149159
processedIssueIds.add(issue.id);
150-
log.info(`[linear-auto-create] launched oneshot for ${issue.identifier}`);
160+
log.info(`[linear-auto-create] launched oneshot for ${issue.identifier} on ${branch}`);
161+
await notifyOneshotPickup(deps, issue, branch);
151162
} catch (err: unknown) {
152163
const msg = err instanceof Error ? err.message : String(err);
153164
log.error(`[linear-auto-create] failed to launch oneshot for ${issue.identifier}: ${msg}`);
@@ -172,6 +183,10 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
172183
});
173184
processedIssueIds.add(issue.id);
174185
log.info(`[linear-auto-create] created worktree for ${issue.identifier}`);
186+
// No Linear pickup comment for the regular `webmux` path — the user
187+
// triggered this themselves by labeling, so the comment would just be
188+
// noise. The oneshot bookend (above) is for the autonomous case where
189+
// there's no human in the loop.
175190
} catch (err: unknown) {
176191
const msg = err instanceof Error ? err.message : String(err);
177192
log.error(`[linear-auto-create] failed to create worktree for ${issue.identifier}: ${msg}`);
@@ -182,6 +197,20 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
182197
}
183198
}
184199

200+
async function notifyOneshotPickup(
201+
deps: LinearAutoCreateDependencies,
202+
issue: LinearIssue,
203+
branch: string,
204+
): Promise<void> {
205+
if (!deps.onOneshotPickedUp) return;
206+
try {
207+
await deps.onOneshotPickedUp({ issue, branch });
208+
} catch (err: unknown) {
209+
const msg = err instanceof Error ? err.message : String(err);
210+
log.warn(`[linear-auto-create] pickup notification failed for ${issue.identifier}: ${msg}`);
211+
}
212+
}
213+
185214
/** Start periodic polling for new Linear Todo issues and auto-create worktrees.
186215
* Returns a cleanup function that stops the monitor. */
187216
export function startLinearAutoCreateMonitor(

backend/src/services/linear-service.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,22 @@ export interface LinearSummaryInput {
810810
webmuxVersion?: string;
811811
}
812812

813+
export interface LinearPickupMarkdownInput {
814+
branch: string;
815+
pickedUpAt: Date;
816+
}
817+
818+
/** Build the structured pickup comment posted when the auto-create watcher picks up a
819+
* `webmux_oneshot`-labeled issue. The prefix (`**Webmux pickup — branch ...**`) is the
820+
* contract external automation greps on, so the format is fixed by tests. */
821+
export function buildLinearPickupMarkdown(input: LinearPickupMarkdownInput): string {
822+
return [
823+
`**Webmux pickup — branch \`${input.branch}\`**`,
824+
"",
825+
`- Picked up: ${input.pickedUpAt.toISOString()}`,
826+
].join("\n");
827+
}
828+
813829
export function buildLinearSummaryMarkdown(input: LinearSummaryInput): string {
814830
const lines: string[] = [
815831
`**Webmux session — branch \`${input.branch}\`**`,

0 commit comments

Comments
 (0)