Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line renders broken on GitHub. The inline code span `**Webmux pickup — branch \`<branch>\`**` uses \`` to try to escape backticks — but backslash escaping does **not** work inside CommonMark code spans. The parser closes the span at the first inner backtick, so it renders as two separate code fragments with in between (and` gets stripped as an unknown HTML tag).

To embed literal backticks, delimit the span with double backticks (and drop the \ escapes):

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