Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .webmux.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ integrations:
# Enables or disables Linear issue fetching in the dashboard.
enabled: true

# When the auto-create watcher picks up an issue (creates a worktree or
# launches a oneshot), post a structured comment on the Linear issue —
# prefix `**Webmux pickup — branch \`<branch>\`**` — so external automation
# can detect that webmux has started working on it.
# postCommentOnPickup: true

lifecycleHooks:
# Shell command run after a managed worktree is created and its session exists.
# Runs with the worktree as cwd and receives startupEnvs, allocated ports,
Expand Down
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.

**Pickup notifications.** Set `integrations.linear.postCommentOnPickup: true` in `.webmux.yaml` and webmux will post a structured comment on the Linear issue when the auto-create watcher picks it up (prefix `**Webmux pickup — branch \`<branch>\`**`), so external automation can track when webmux starts working on it.

## Quick Start

```bash
Expand Down
2 changes: 1 addition & 1 deletion backend/src/__tests__/agent-chat-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const TEST_CONFIG: ProjectConfig = {
startupEnvs: {},
integrations: {
github: { linkedRepos: [], autoRemoveOnMerge: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false, postCommentOnPickup: false },
},
lifecycleHooks: {},
autoName: null,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/__tests__/agent-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const TEST_CONFIG: ProjectConfig = {
startupEnvs: {},
integrations: {
github: { linkedRepos: [], autoRemoveOnMerge: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false, postCommentOnPickup: false },
},
lifecycleHooks: {},
autoName: null,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/__tests__/lifecycle-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ const TEST_CONFIG: ProjectConfig = {
},
integrations: {
github: { linkedRepos: [], autoRemoveOnMerge: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false, postCommentOnPickup: false },
},
lifecycleHooks: {
postCreate: "scripts/post-create.sh",
Expand Down
74 changes: 74 additions & 0 deletions backend/src/__tests__/linear-auto-create-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function createDeps(input: {
fetchResult?: FetchIssuesResult;
onFetch?: (options: { skipCache?: boolean } | undefined) => void;
runOneshotForIssue?: (issueId: string) => Promise<void>;
onIssuePickedUp?: (issue: LinearIssue, kind: "create" | "oneshot") => Promise<void>;
} = {}): {
deps: LinearAutoCreateDependencies;
created: CreateLifecycleWorktreeInput[];
Expand Down Expand Up @@ -116,6 +117,7 @@ function createDeps(input: {
};
},
...(input.runOneshotForIssue ? { runOneshotForIssue: input.runOneshotForIssue } : {}),
...(input.onIssuePickedUp ? { onIssuePickedUp: input.onIssuePickedUp } : {}),
},
};
}
Expand Down Expand Up @@ -369,6 +371,78 @@ describe("runLinearAutoCreateOnce", () => {
expect(created.map((c) => c.branch)).toEqual(["eng-201-create"]);
});

it("notifies onIssuePickedUp after a successful create pickup", async () => {
const issue = createIssue();
const pickups: Array<{ identifier: string; kind: "create" | "oneshot" }> = [];
const { deps } = createDeps({
issues: [issue],
onIssuePickedUp: async (picked, kind) => {
pickups.push({ identifier: picked.identifier, kind });
},
});

await runLinearAutoCreateOnce(deps);

expect(pickups).toEqual([{ identifier: "ENG-123", kind: "create" }]);
});

it("notifies onIssuePickedUp after a successful oneshot pickup", async () => {
const oneshotIssue = createIssue({
id: "issue-oneshot", identifier: "ENG-200", branchName: "eng-200-oneshot",
labels: [{ name: "webmux_oneshot", color: "#fff" }],
});
const pickups: Array<{ identifier: string; kind: "create" | "oneshot" }> = [];
const { deps } = createDeps({
issues: [oneshotIssue],
runOneshotForIssue: async () => {},
onIssuePickedUp: async (picked, kind) => {
pickups.push({ identifier: picked.identifier, kind });
},
});

await runLinearAutoCreateOnce(deps);

expect(pickups).toEqual([{ identifier: "ENG-200", kind: "oneshot" }]);
});

it("does not notify onIssuePickedUp when the pickup itself fails", async () => {
const issue = createIssue();
const pickups: string[] = [];
const deps: LinearAutoCreateDependencies = {
lifecycleService: {
async createWorktree(): Promise<{ branch: string; worktreeId: string }> {
throw new Error("Branch already exists");
},
},
git: { listWorktrees: () => [] },
projectRoot: "/repo",
fetchIssues: async () => ({ ok: true, data: [issue] }),
onIssuePickedUp: async (picked) => {
pickups.push(picked.identifier);
},
};

await runLinearAutoCreateOnce(deps);

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

it("swallows onIssuePickedUp failures so the pickup still completes", async () => {
const issue = createIssue();
const { deps, created } = createDeps({
issues: [issue],
onIssuePickedUp: async () => {
throw new Error("Linear comment failed");
},
});

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

expect(created.length).toBe(1);
});

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
2 changes: 1 addition & 1 deletion backend/src/__tests__/reconciliation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ const TEST_CONFIG: ProjectConfig = {
startupEnvs: {},
integrations: {
github: { linkedRepos: [], autoRemoveOnMerge: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false, postCommentOnPickup: false },
},
lifecycleHooks: {},
autoName: null,
Expand Down
6 changes: 5 additions & 1 deletion backend/src/adapters/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const DEFAULT_CONFIG: ProjectConfig = {
startupEnvs: {},
integrations: {
github: { linkedRepos: [], autoRemoveOnMerge: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false },
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false, postCommentOnPickup: false },
},
lifecycleHooks: {},
autoName: null,
Expand Down Expand Up @@ -442,6 +442,9 @@ function parseLinearIntegration(parsed: Record<string, unknown>): LinearIntegrat
createTicketOption: typeof linear.createTicketOption === "boolean"
? linear.createTicketOption
: defaults.createTicketOption,
postCommentOnPickup: typeof linear.postCommentOnPickup === "boolean"
? linear.postCommentOnPickup
: defaults.postCommentOnPickup,
...(watchTeams ? { watchTeams } : {}),
};
}
Expand All @@ -455,6 +458,7 @@ function parseLocalLinearOverlay(parsed: Record<string, unknown>): Partial<Linea
if (typeof linear.enabled === "boolean") overlay.enabled = linear.enabled;
if (typeof linear.autoCreateWorktrees === "boolean") overlay.autoCreateWorktrees = linear.autoCreateWorktrees;
if (typeof linear.createTicketOption === "boolean") overlay.createTicketOption = linear.createTicketOption;
if (typeof linear.postCommentOnPickup === "boolean") overlay.postCommentOnPickup = linear.postCommentOnPickup;
const watchTeams = parseTeamKeyList(linear.watchTeams);
if (watchTeams) overlay.watchTeams = watchTeams;
return Object.keys(overlay).length > 0 ? overlay : null;
Expand Down
4 changes: 4 additions & 0 deletions backend/src/domain/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export interface LinearIntegrationConfig {
/** Restrict the auto-create watcher to issues from these team keys (e.g. ["ENG", "OPS"]).
* When unset, all teams the authenticated user is assigned in are watched. */
watchTeams?: string[];
/** When the auto-create watcher picks up an issue (creates a worktree or launches a
* oneshot), post a comment on the Linear issue announcing the pickup so external
* automation can track it. Defaults to false. */
postCommentOnPickup: boolean;
}

export interface IntegrationConfig {
Expand Down
26 changes: 26 additions & 0 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,41 @@ async function runOneshotForIssue(issueId: string): Promise<void> {
function startLinearAutoCreate(): void {
if (stopLinearAutoCreate) return;
const watchTeamKeys = config.integrations.linear.watchTeams;
const postCommentOnPickup = config.integrations.linear.postCommentOnPickup;
stopLinearAutoCreate = startLinearAutoCreateMonitor({
lifecycleService,
git,
projectRoot: PROJECT_DIR,
runOneshotForIssue,
...(watchTeamKeys && watchTeamKeys.length > 0 ? { watchTeamKeys } : {}),
...(postCommentOnPickup ? { onIssuePickedUp: postLinearPickupComment } : {}),
});
}

/** Post a "webmux picked this up" comment on the Linear issue so external automation can
* see when an issue moves into active work. Failures are logged and swallowed by the
* caller — pickup itself must not depend on this. The structured prefix matches the
* "done" comment in `buildLinearSummaryMarkdown` so both ends of the lifecycle can be
* grepped by external systems. */
async function postLinearPickupComment(
issue: { id: string; identifier: string; branchName: string },
kind: "create" | "oneshot",
): Promise<void> {
const mode = kind === "oneshot" ? "oneshot" : "worktree";
const body = [
`**Webmux pickup — branch \`${issue.branchName}\`**`,
"",
`- Mode: ${mode}`,
`- Started: ${new Date().toISOString()}`,
].join("\n");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider extracting a pure buildLinearPickupMarkdown function. backend/CLAUDE.md calls for "pure functions for logic, side-effectful functions clearly separated" and the sibling "done" comment is built by the pure, exported, unit-tested buildLinearSummaryMarkdown in linear-service.ts. Here the markdown construction is inline in an I/O function, so the grep-able prefix format — the whole point of this feature — has no test pinning it.

Suggested shape: a pure buildLinearPickupMarkdown({ branch, kind, startedAt }) in linear-service.ts next to buildLinearSummaryMarkdown, with postLinearPickupComment just passing new Date() and calling createIssueComment. Then a unit test can assert the exact body for both create and oneshot.

Fix this →

const result = await createIssueComment({ issueId: issue.id, body });
if (!result.ok) {
log.warn(`[linear-auto-create] failed to post pickup comment for ${issue.identifier}: ${result.error}`);
return;
}
log.info(`[linear-auto-create] posted pickup comment for ${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
20 changes: 20 additions & 0 deletions backend/src/services/linear-auto-create-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export interface LinearAutoCreateDependencies {
/** 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 pickup (create or oneshot) so external
* automation can be notified. Failures are logged and swallowed — they must not
* block the pickup itself. */
onIssuePickedUp?: (issue: LinearIssue, kind: "create" | "oneshot") => Promise<void>;
}

export interface LinearAutoCreateMonitorOptions {
Expand Down Expand Up @@ -148,6 +152,7 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
await deps.runOneshotForIssue!(issue.identifier);
processedIssueIds.add(issue.id);
log.info(`[linear-auto-create] launched oneshot for ${issue.identifier}`);
await notifyPickup(deps, issue, "oneshot");
} 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 +177,7 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
});
processedIssueIds.add(issue.id);
log.info(`[linear-auto-create] created worktree for ${issue.identifier}`);
await notifyPickup(deps, issue, "create");
} 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 +188,20 @@ export async function runLinearAutoCreateOnce(deps: LinearAutoCreateDependencies
}
}

async function notifyPickup(
deps: LinearAutoCreateDependencies,
issue: LinearIssue,
kind: "create" | "oneshot",
): Promise<void> {
if (!deps.onIssuePickedUp) return;
try {
await deps.onIssuePickedUp(issue, kind);
} 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
4 changes: 4 additions & 0 deletions bin/src/init-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,10 @@ integrations:
# the authenticated Linear user is in multiple teams or when running webmux
# in several projects on the same machine that share a Linear account.
# watchTeams: [ENG, OPS]
# Post a structured comment on the Linear issue when the auto-create
# watcher picks it up (prefix `**Webmux pickup — branch \`<branch>\`**`),
# so external automation can track when webmux starts working on it.
# postCommentOnPickup: true

# startupEnvs become runtime env vars for panes, agents, and hooks.
startupEnvs:
Expand Down
9 changes: 9 additions & 0 deletions site/src/lib/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ integrations:
enabled: true
autoCreateWorktrees: true
createTicketOption: true
postCommentOnPickup: true
teamId: eng

lifecycleHooks:
Expand Down Expand Up @@ -682,6 +683,14 @@ export const configGroups: ConfigGroup[] = [
defaultValue: "false",
description: "Shows the create-ticket action in the dashboard when Linear integration is enabled.",
},
{
key: "integrations.linear.postCommentOnPickup",
type: "boolean",
required: "no",
defaultValue: "false",
description:
"Posts a structured comment (prefix `**Webmux pickup — branch \\`<branch>\\`**`) on the Linear issue when the auto-create watcher picks it up, so external automation can track when webmux starts working on it.",
},
{
key: "integrations.linear.teamId",
type: "string",
Expand Down
Loading