Skip to content

Link a Linear job's PRs on its ticket, and resolve the ticket by identifier - #19

Open
lukasIO wants to merge 3 commits into
frostbyte73:mainfrom
lukasIO:claude/linear-pr-links
Open

lukasIO wants to merge 3 commits into
frostbyte73:mainfrom
lukasIO:claude/linear-pr-links

Conversation

@lukasIO

@lukasIO lukasIO commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Outpost never puts a job's PR on its Linear ticket, and it never moves a polled ticket's status. LinearWriter.upsertStatusComment would list the PR URLs in a comment, but nothing calls it. The state sync (In Progress, In Review, Done) exists, but linearReady requires externalRef.linearUuid. The hourly intake script fetches each issue's id and then sends only url and issueIdentifier, so no polled job has a UUID, and every Linear write skips every job.

Some tickets still show their PR, and none of those links came from Outpost. Linear's GitHub integration links a PR only when the issue ID is in the branch name or the PR title, or follows a magic word such as "Fixes" in the body. Outpost's PRs match none of these. Their branches read like fix/backup-codec-layer-dimensions, and PR #2029's body says "Linear: CLT-2995", which has no magic word. Where a ticket shows a PR, a person linked it by hand. Or the PR "Fixes" a GitHub issue that Linear's issue sync had already attached, which makes the PR look linked when it is not.

This change attaches each PR a writable step opens to the job's ticket, once, through attachmentLinkURL. Where the workspace has Linear's GitHub integration, that call makes a rich PR attachment with status, checks and reviews. Otherwise it makes a plain link. decideJobTransitions emits a link-linear-pr transition for each unlinked URL. The engine writes it and records the URL in linearLinkedPrs. Review steps, cancelled steps and abandoned jobs are skipped. Failed and done jobs still get their links. applyPrFacts ticks the job when it records a new PR URL, because the PR watcher never ticks a job.

The writer accepts the issue's UUID or its identifier (CLT-2995). Before each mutation it resolves the UUID through issue(id:), which reads either form. The intake script is left as it is. The daemon never overwrites the stored copy of a built-in schedule, so a script change would not reach existing installs.

attachmentLinkURL is not idempotent. It refuses a URL the ticket already holds, with "This URL has already been linked with CLT-1923.", and it returns that refusal as an HTTP 200 with a GraphQL error. withRetry then slept 1 s, 5 s, 30 s, 5 min, 30 min and 1 h on a call that can never pass. The startup tick handles jobs one at a time, so every job behind that one waited too. linkPr reads the ticket's attachments first and skips a URL already there. LinearError carries Linear's userError flag, and withRetry rethrows a user error at once.

Impact

On one install, none of the 27 Linear jobs had a linearUuid. Of the 7 PRs those jobs opened, Outpost linked none, and only one was on its ticket: PR #1634 on CLT-1923, linked by hand in 2025. With this change, the first start attached 5 PRs in 12 seconds, for example livekit/client-sdk-js#2029 on CLT-2995, which then moved to In Review. The start also hit the refusal above on CLT-1923 and stalled the rest of the queue. The second commit fixes that.

On the first start after an upgrade, existing jobs catch up: their PRs get attached, and their tickets move to the state the job is in, including Done for jobs that already finished. Unit tests cover the transition, the writer's identifier resolution, the already-linked skip, the no-retry rule and the engine's link-on-new-URL path, all against a fake Linear client.

🤖 Generated with Claude Code

lukasIO and others added 3 commits October 7, 2026 16:41
…tifier

Nothing put a job's PR on its Linear ticket. upsertStatusComment would
have listed the URLs in a comment, but nothing ever called it. Each PR a
writable step opens is now attached to the ticket once, through
attachmentLinkURL, which makes a rich GitHub attachment where the
workspace has that integration. `linearLinkedPrs` records what landed,
and a failed write retries on the next tick. Recording a new PR URL
ticks the job, because the PR watcher never does.

Linear's state sync never ran for a polled job either: the intake script
sends `issueIdentifier` and never `linearUuid`, and linearReady required
the UUID. The writer now takes either, and resolves the UUID through
issue(id:) before each mutation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
attachmentLinkURL is not idempotent. It refuses a URL the ticket already
holds ("This URL has already been linked with CLT-1923."), which is the
case for a PR linked by hand or by Linear's own GitHub integration. The
refusal comes back as an HTTP 200 with a GraphQL error, so withRetry
slept 1s, 5s, 30s, 5m, 30m and 1h on it. The startup tick walks the
queue one job at a time, so every job behind that one waited too.

linkPr now reads the ticket's attachments first and skips a URL already
there. LinearError carries Linear's `userError` flag and the
userPresentableMessage, and withRetry rethrows a user error at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tick awaited each Linear write, and the writer retries a transient
failure for up to an hour and a half. That held the job's own reaping
for as long, and on the startup tick, which walks the queue one job at a
time, every job behind it. syncLinear starts the write without awaiting
it and records the result when it lands. An in-flight set keeps a tick
that arrives mid-retry from starting the same write again. Nothing is
marked until a write lands, so one that finally fails is re-emitted by a
later tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant