Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ See your assigned Linear issues alongside your worktrees. Webmux matches branche

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

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.)

## Quick Start

```bash
Expand Down
96 changes: 95 additions & 1 deletion backend/src/__tests__/linear-auto-create-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ function createDeps(input: {
existingBranches?: string[];
fetchResult?: FetchIssuesResult;
onFetch?: (options: { skipCache?: boolean } | undefined) => void;
runOneshotForIssue?: (issueId: string) => Promise<void>;
runOneshotForIssue?: (issueId: string) => Promise<{ branch: string }>;
onOneshotPickedUp?: (input: { issue: LinearIssue; branch: string }) => Promise<void>;
} = {}): {
deps: LinearAutoCreateDependencies;
created: CreateLifecycleWorktreeInput[];
Expand Down Expand Up @@ -116,6 +117,7 @@ function createDeps(input: {
};
},
...(input.runOneshotForIssue ? { runOneshotForIssue: input.runOneshotForIssue } : {}),
...(input.onOneshotPickedUp ? { onOneshotPickedUp: input.onOneshotPickedUp } : {}),
},
};
}
Expand Down Expand Up @@ -360,6 +362,7 @@ describe("runLinearAutoCreateOnce", () => {
issues: [oneshotIssue, createIssue1],
runOneshotForIssue: async (id) => {
triggered.push(id);
return { branch: "eng-200-oneshot" };
},
});

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

it("does not notify onOneshotPickedUp for the regular `webmux` (create) path", async () => {
// Regular pickups are user-driven (the user added the label themselves);
// the comment would be noise. Only the autonomous oneshot path gets it.
const issue = createIssue();
const pickups: string[] = [];
const { deps } = createDeps({
issues: [issue],
onOneshotPickedUp: async ({ issue: picked }) => {
pickups.push(picked.identifier);
},
});

await runLinearAutoCreateOnce(deps);

expect(pickups).toEqual([]);
});

it("notifies onOneshotPickedUp with the branch resolved by runOneshotForIssue, not the issue's branchName", async () => {
// Regression guard: `buildSeedFromLinear` may resolve to an attachment-payload
// or PR branch instead of `issue.branchName`. The pickup-comment contract
// requires the *actual* working branch.
const oneshotIssue = createIssue({
id: "issue-oneshot", identifier: "ENG-200", branchName: "eng-200-original",
labels: [{ name: "webmux_oneshot", color: "#fff" }],
});
const pickups: Array<{ identifier: string; branch: string }> = [];
const { deps } = createDeps({
issues: [oneshotIssue],
runOneshotForIssue: async () => ({ branch: "eng-200-resumed-from-attachment" }),
onOneshotPickedUp: async ({ issue: picked, branch }) => {
pickups.push({ identifier: picked.identifier, branch });
},
});

await runLinearAutoCreateOnce(deps);

expect(pickups).toEqual([
{ identifier: "ENG-200", branch: "eng-200-resumed-from-attachment" },
]);
});

it("does not notify onOneshotPickedUp when the oneshot launch itself fails", async () => {
const oneshotIssue = createIssue({
id: "issue-oneshot", identifier: "ENG-200", branchName: "eng-200-oneshot",
labels: [{ name: "webmux_oneshot", color: "#fff" }],
});
const pickups: string[] = [];
const deps: LinearAutoCreateDependencies = {
lifecycleService: {
async createWorktree(): Promise<{ branch: string; worktreeId: string }> {
throw new Error("should not be called");
},
},
git: { listWorktrees: () => [] },
projectRoot: "/repo",
fetchIssues: async () => ({ ok: true, data: [oneshotIssue] }),
runOneshotForIssue: async () => { throw new Error("server unreachable"); },
onOneshotPickedUp: async ({ issue: picked }) => {
pickups.push(picked.identifier);
},
};

await runLinearAutoCreateOnce(deps);

expect(pickups).toEqual([]);
});

it("swallows onOneshotPickedUp failures so the pickup still completes", async () => {
const oneshotIssue = createIssue({
id: "issue-oneshot", identifier: "ENG-200", branchName: "eng-200-oneshot",
labels: [{ name: "webmux_oneshot", color: "#fff" }],
});
const oneshotCalls: string[] = [];
const { deps } = createDeps({
issues: [oneshotIssue],
runOneshotForIssue: async (id) => {
oneshotCalls.push(id);
return { branch: "eng-200-oneshot" };
},
onOneshotPickedUp: async () => {
throw new Error("Linear comment failed");
},
});

await runLinearAutoCreateOnce(deps);
// Pickup still happened and is deduped on the next pass.
await runLinearAutoCreateOnce(deps);

expect(oneshotCalls).toEqual(["ENG-200"]);
});

it("skips webmux_oneshot issues when no runOneshotForIssue dep is provided", async () => {
const oneshotIssue = createIssue({
id: "issue-oneshot", identifier: "ENG-200", branchName: "eng-200-oneshot",
Expand Down
17 changes: 17 additions & 0 deletions backend/src/__tests__/linear-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ describe("Linear issue creation", () => {
});

import {
buildLinearPickupMarkdown,
buildLinearSummaryMarkdown,
buildWebmuxAttachmentTitle,
findLinkedGitHubPr,
Expand Down Expand Up @@ -477,6 +478,22 @@ describe("findLinkedGitHubPr", () => {
});
});

describe("buildLinearPickupMarkdown", () => {
it("renders the pickup comment verbatim", () => {
// Pins the exact wire format because external automation greps the
// prefix — changing it silently would break those integrations.
const md = buildLinearPickupMarkdown({
branch: "eng-200-oneshot",
pickedUpAt: new Date("2026-05-20T12:34:56.789Z"),
});
expect(md).toBe(
"**Webmux pickup — branch `eng-200-oneshot`**\n" +
"\n" +
"- Picked up: 2026-05-20T12:34:56.789Z",
);
});
});

describe("buildLinearSummaryMarkdown", () => {
it("includes turns and the attachment title", () => {
const md = buildLinearSummaryMarkdown({
Expand Down
33 changes: 31 additions & 2 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
attachToIssue,
branchMatchesIssue,
buildLinearIssuesResponse,
buildLinearPickupMarkdown,
createIssueComment,
createLinearIssue,
deriveLinearIssueTitle,
Expand Down Expand Up @@ -136,8 +137,11 @@ let stopLinearAutoCreate: (() => void) | null = null;
let autoRemoveOnMergeEnabled = config.integrations.github.autoRemoveOnMerge;

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

/** Safe to call multiple times — the guard prevents duplicate monitors. */
Expand All @@ -171,10 +176,34 @@ function startLinearAutoCreate(): void {
git,
projectRoot: PROJECT_DIR,
runOneshotForIssue,
onOneshotPickedUp: postLinearOneshotPickupComment,
...(watchTeamKeys && watchTeamKeys.length > 0 ? { watchTeamKeys } : {}),
});
}

/** Post the structured pickup comment on the Linear issue when the auto-create watcher
* picks up a `webmux_oneshot` issue, so external automation can see the autonomous run
* started. `branch` is the *actual* working branch (which can differ from
* `issue.branchName` — see `runOneshotForIssue`). Failures are logged and swallowed —
* pickup itself must not depend on this. Markdown is built by the pure
* `buildLinearPickupMarkdown` in `linear-service.ts` so the grep-able prefix has a
* unit-test contract. */
async function postLinearOneshotPickupComment(input: {
issue: { id: string; identifier: string };
branch: string;
}): Promise<void> {
const body = buildLinearPickupMarkdown({
branch: input.branch,
pickedUpAt: new Date(),
});
const result = await createIssueComment({ issueId: input.issue.id, body });
if (!result.ok) {
log.warn(`[linear-auto-create] failed to post pickup comment for ${input.issue.identifier}: ${result.error}`);
return;
}
log.info(`[linear-auto-create] posted pickup comment for ${input.issue.identifier}: ${result.data.url}`);
}

/** Map the wire-side `OneshotConfig` (all-optional fields) to the persisted
* `OneshotMeta` shape (autoCloseOnDone has a definite boolean). Default is
* `true` — callers must opt out explicitly. */
Expand Down
37 changes: 33 additions & 4 deletions backend/src/services/linear-auto-create-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,21 @@ export interface LinearAutoCreateDependencies {
git: Pick<GitGateway, "listWorktrees">;
projectRoot: string;
fetchIssues?: typeof fetchAssignedIssues;
/** Optional handler for the `webmux_oneshot` label variant. When omitted, oneshot triggering is skipped. */
runOneshotForIssue?: (issueId: string) => Promise<void>;
/** Optional handler for the `webmux_oneshot` label variant. Must return the actual
* working branch — the oneshot seed may resolve to `attachmentPayload.branch ??
* pr.branch ?? issue.branchName`, which is not always `issue.branchName`. When
* omitted, oneshot triggering is skipped. */
runOneshotForIssue?: (issueId: string) => Promise<{ branch: string }>;
/** Restrict triggering to issues whose team.key is in this list (uppercase).
* Undefined or empty → no team filter (all teams). */
watchTeamKeys?: string[];
/** Optional callback invoked after a successful oneshot pickup so external automation
* can be notified. `branch` is the actual working branch (can differ from
* `issue.branchName` — see `runOneshotForIssue`). Failures are logged and
* swallowed — they must not block the pickup itself.
* Only fires for the `webmux_oneshot` path: regular `webmux` pickups are
* user-driven and don't need a Linear-side bookend. */
onOneshotPickedUp?: (input: { issue: LinearIssue; branch: string }) => Promise<void>;
}

export interface LinearAutoCreateMonitorOptions {
Expand Down Expand Up @@ -145,9 +155,10 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
for (const issue of oneshotIssues) {
try {
log.info(`[linear-auto-create] launching oneshot for ${issue.identifier}: ${issue.title}`);
await deps.runOneshotForIssue!(issue.identifier);
const { branch } = await deps.runOneshotForIssue!(issue.identifier);
processedIssueIds.add(issue.id);
log.info(`[linear-auto-create] launched oneshot for ${issue.identifier}`);
log.info(`[linear-auto-create] launched oneshot for ${issue.identifier} on ${branch}`);
await notifyOneshotPickup(deps, issue, branch);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
log.error(`[linear-auto-create] failed to launch oneshot for ${issue.identifier}: ${msg}`);
Expand All @@ -172,6 +183,10 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
});
processedIssueIds.add(issue.id);
log.info(`[linear-auto-create] created worktree for ${issue.identifier}`);
// No Linear pickup comment for the regular `webmux` path — the user
// triggered this themselves by labeling, so the comment would just be
// noise. The oneshot bookend (above) is for the autonomous case where
// there's no human in the loop.
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
log.error(`[linear-auto-create] failed to create worktree for ${issue.identifier}: ${msg}`);
Expand All @@ -182,6 +197,20 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
}
}

async function notifyOneshotPickup(
deps: LinearAutoCreateDependencies,
issue: LinearIssue,
branch: string,
): Promise<void> {
if (!deps.onOneshotPickedUp) return;
try {
await deps.onOneshotPickedUp({ issue, branch });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
log.warn(`[linear-auto-create] pickup notification failed for ${issue.identifier}: ${msg}`);
}
}

/** Start periodic polling for new Linear Todo issues and auto-create worktrees.
* Returns a cleanup function that stops the monitor. */
export function startLinearAutoCreateMonitor(
Expand Down
16 changes: 16 additions & 0 deletions backend/src/services/linear-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,22 @@ export interface LinearSummaryInput {
webmuxVersion?: string;
}

export interface LinearPickupMarkdownInput {
branch: string;
pickedUpAt: Date;
}

/** Build the structured pickup comment posted when the auto-create watcher picks up a
* `webmux_oneshot`-labeled issue. The prefix (`**Webmux pickup — branch ...**`) is the
* contract external automation greps on, so the format is fixed by tests. */
export function buildLinearPickupMarkdown(input: LinearPickupMarkdownInput): string {
return [
`**Webmux pickup — branch \`${input.branch}\`**`,
"",
`- Picked up: ${input.pickedUpAt.toISOString()}`,
].join("\n");
}

export function buildLinearSummaryMarkdown(input: LinearSummaryInput): string {
const lines: string[] = [
`**Webmux session — branch \`${input.branch}\`**`,
Expand Down
Loading