Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Changed
- Sessions now work directly on a pull request referenced on the issue. If an issue links an open PR for the routed repository — as a link/attachment, a PR embed in the description, or a plain PR URL — the session checks out that PR's head branch instead of creating a new branch, so its pushes update the existing PR. Closed and cross-fork PRs are ignored; private repos need a `GH_TOKEN`/`GITHUB_TOKEN` in the environment for the lookup. ([#1457](https://github.com/cyrusagents/cyrus/pull/1457))
- Sessions now pick up existing remote branches. When a Linear issue's branch name matches a branch that already exists on the remote (for example a PR branch pushed outside Cyrus), the session checks out that branch and continues the work on it — instead of creating a fresh branch of the same name that shadowed the remote one and caused push conflicts. ([#1457](https://github.com/cyrusagents/cyrus/pull/1457))

### Added
- Every agent session now explains how to attach local images and videos to GitHub issues, pull requests, and comments with GitHub CLI v2.99.0 or newer, independently of optional browser tooling. ([CYPACK-1490](https://linear.app/ceedar/issue/CYPACK-1490/add-this-to-the-system-prompt), [#1453](https://github.com/cyrusagents/cyrus/pull/1453))
- Pull requests created by Cyrus now show up front and center in Linear, matching Linear's own coding agent: the PR is linked to the issue as a first-class attachment the moment it's created (giving the issue its Diff tab and a review page in Linear's Reviews, when the workspace's GitHub integration has code access), and the PR is pinned on the agent session header. In the session timeline, simple `git push` and `gh pr create` / `glab mr create` / `gt submit` commands now render as dedicated "Git Push \<branch\>" and "Create PR \<title\>" activity rows instead of generic Bash rows. See `docs/LINEAR_PR_REVIEWS.md` for the full mechanism and workspace setup. ([#1457](https://github.com/cyrusagents/cyrus/pull/1457))

### Fixed
- Orchestrator sessions are resumed again as soon as a delegated sub-issue's session completes. Since v0.2.69 the parent-to-child link was never created for sub-issues started through Linear delegation, so orchestrators only advanced when their own scheduled wake-up fired, paying the full timer interval on every hand-off. Cyrus now links a new session to the most recent session on its parent issue, whether or not that parent session is still running, so parent-child issue relationships carry child completion results in general. ([#1454](https://github.com/cyrusagents/cyrus/pull/1454))
Expand Down
197 changes: 197 additions & 0 deletions docs/LINEAR_PR_REVIEWS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# PRs front and center in Linear

*How Cyrus makes its pull requests appear in Linear exactly like Linear's own
coding agent does — attached to the issue, reviewable in Linear's diff view,
pinned on the agent session, and rendered as first-class "Git Push" /
"Create PR" rows in the session timeline.*

This document explains the whole mechanism from first principles so you can
teach it: what Linear provides, what Cyrus does with it, what code changed,
and what a workspace must have configured.

---

## 1. What you see, and where it comes from

When Linear's own coding agent works an issue, the session thread shows rows
like **"Git Push jake/spe-63-test-issue"** and **"Create PR Add
CONTRIBUTING.md"**, the issue gains a **Diff** tab, and the PR opens in a
full review page (`linear.app/<workspace>/review/<slug>`) with inline
comments, *Request review*, approve / request changes, and merge.

None of that is exclusive to Linear's agent. It decomposes into three
independent mechanisms, each with a public API surface:

| What you see | Mechanism | Who provides it |
|---|---|---|
| PR chip on the issue, Diff tab | An issue **attachment** of the GitHub-PR kind | Linear GitHub integration (automatic) or the `attachmentLinkGitHubPR` GraphQL mutation |
| Review page: diffs, inline comments, approve/merge | **Linear Reviews** ("Diffs") — enabled by granting the GitHub integration **code access** to the repo | Linear, for *any* PR in a code-access repo — human, Cursor, Cyrus, or Linear agent alike |
| "Git Push…" / "Create PR…" rows in the thread | Ordinary agent **`action` activities** (`{action, parameter, result}`) | Any agent via `agentActivityCreate` |
| PR link pinned on the session | `agentSessionUpdate` with `addedExternalUrls` | Only the OAuth app that owns the session — i.e. Cyrus's own token |

Two useful extras:

- **URL trick**: any GitHub PR URL maps to its Linear review page by swapping
the host — `github.com/owner/repo/pull/N` → `linear.review/owner/repo/pull/N`.
- **Re-sync trick**: Linear ingests PRs on webhook events. A PR that predates
code access won't have a review page until its next event — making a tiny
edit to the PR description (add/remove a space) triggers ingestion. We
verified this live: Cyrus's pre-existing PR #16 gained a full review page
seconds after a whitespace touch.

The only piece that is *not* publicly writable is Linear's internal
session↔PR anchor (`AgentSessionToPullRequest`, marked `[Internal]` in the
schema). It is not needed for any of the visible behavior above.

## 2. What Cyrus does now (the changes)

Four small pieces, layered so no single failure can interrupt a coding
session. All of them ride on infrastructure Cyrus already had.

### 2.1 Semantic timeline rows — `cyrus-claude-runner`

- **`packages/claude-runner/src/git-command-labels.ts`** (new): recognizes
Bash commands that are semantically a push or a PR/MR creation and returns
a `{action, parameter}` label:
- `git push …` (single-command only; a tolerated leading `cd <dir> &&` is
stripped) → `Git Push` + the pushed branch (refspec-aware).
- `gh pr create …` → `Create PR` + the `--title`/`-t` value.
- `glab mr create …` → `Create MR`; `gt submit` → `Create PR`.
- Deliberately conservative: compound commands like
`git add … && git commit … && git push` keep their generic `Bash` label,
because calling that row "Git Push" would hide the other side effects.
- **`packages/claude-runner/src/formatter.ts`**: `formatToolActionName` and
`formatToolParameter` consult `labelGitCommand()` first for Bash tools, so
the *existing* activity row is renamed — no duplicate rows are added. The
raw command and its output still appear in the row's expandable result.

> Scope note: this renders through the Claude runner's formatter. The Codex /
> Gemini / Cursor runner formatters were not touched and still show generic
> rows (their PRs are still attached + pinned — that half is runner-agnostic).

### 2.2 PR detection — `PrMarkerHook`

`packages/edge-worker/src/hooks/PrMarkerHook.ts` already intercepted every
`gh pr create`/`gh pr edit`/`gt submit`/`glab mr *` Bash call (PostToolUse
hook) to stamp the `<!-- generated-by-cyrus -->` marker into the PR body.
It now also:

- exposes `readPullRequest(cwd)` on the GitHub provider — one
`gh pr view --json number,title,url,isDraft,headRefName` call reading back
the PR for the session's branch;
- accepts an optional `onPullRequestDetected(pr, cwd)` callback and invokes
it after the marker step. Errors are logged and swallowed: presentation
plumbing must never break the coding session.

The GitLab provider does not implement `readPullRequest` yet (candidate
follow-up: `glab mr view` + Linear's `attachmentLinkGitLabMR` mutation).

### 2.3 Plumbing — `RunnerConfigBuilder`

`IssueRunnerConfigInput` gained `onPullRequestDetected?`, which
`buildIssueConfig` passes into `buildPrMarkerHook`. Nothing else in the hook
wiring changed.

### 2.4 Linking — `EdgeWorker` + issue-tracker service

`EdgeWorker.buildAgentRunnerConfig` supplies the callback. On first detection
of a given PR per session (deduped by `sessionId + PR URL`, with the key
released on failure so the next PR-mutating command retries):

1. **Attach the PR to the issue** —
`LinearIssueTrackerService.linkPullRequestToIssue(issueId, url, title)`
→ SDK `attachmentLinkGitHubPR`. This is what makes the PR appear on the
issue instantly (rather than waiting for Linear's branch-name sync) and,
with code access, gives the issue its Diff view.
2. **Pin the PR on the session** —
`addAgentSessionExternalUrl(externalSessionId, "PR #<n>", url)` →
`agentSessionUpdate` with `addedExternalUrls` via a **raw GraphQL
request**, because the pinned `@linear/sdk` (v64) predates that input
field. `addedExternalUrls` is additive (won't clobber other links), and
only the OAuth app that owns the session may call it — which is exactly
the Linear token Cyrus holds.

Both methods are new *optional* members of `IIssueTrackerService`
(`packages/core/src/issue-tracker/IIssueTrackerService.ts`), implemented for
Linear in
`packages/linear-event-transport/src/LinearIssueTrackerService.ts`. The CLI
tracker (F1 test harness) simply omits them.

### Files touched

| File | Change |
|---|---|
| `packages/claude-runner/src/git-command-labels.ts` | new — command recognition |
| `packages/claude-runner/src/formatter.ts` | semantic Bash action/parameter labels |
| `packages/claude-runner/test/git-command-labels.test.ts` | new — label tests |
| `packages/edge-worker/src/hooks/PrMarkerHook.ts` | `DetectedPullRequest`, `readPullRequest`, detection callback |
| `packages/edge-worker/src/RunnerConfigBuilder.ts` | `onPullRequestDetected` pass-through |
| `packages/edge-worker/src/EdgeWorker.ts` | attach + pin callback, dedupe set |
| `packages/core/src/issue-tracker/IIssueTrackerService.ts` | two optional interface methods |
| `packages/linear-event-transport/src/LinearIssueTrackerService.ts` | Linear implementations |
| `packages/edge-worker/test/PrMarkerHook.test.ts` | callback behavior tests |

## 3. One-time workspace setup (no code)

1. **GitHub integration** connected to the Linear workspace, installed on the
org/repos Cyrus works in (e.g. `specstoryai/adventure`).
2. **Code access** granted to those repos in the GitHub integration settings
— this is the switch that turns plain PR links into Linear Reviews with
diffs. (GitHub orgs with IP allow-lists must add Linear's documented IPs.)
3. Each teammate: **Settings → Account → Code & reviews → Enable code
reviews**, and a connected personal GitHub account (review actions are
performed as *you* on GitHub).
4. Branch names must contain the issue identifier for Linear's automatic
PR↔issue linking. Cyrus already branches from Linear's own
`issue.branchName`, so this holds by construction — and the explicit
attach in §2.4 covers any repo where it doesn't.
5. *(Optional, recommended)* Have Cyrus open **draft** PRs
(`gh pr create --draft` in your repo's agent guidance) — then *Request
review* in Linear flips them ready, matching Linear's agent flow.

## 4. Closing the loop: reviews that drive Cyrus

A review submitted in Linear syncs to GitHub as a real PR review. Cyrus
already listens for `pull_request_review` webhooks (via the Cyrus GitHub
App): a **changes-requested** review on a PR carrying the
`<!-- generated-by-cyrus -->` marker resumes the session with the review
body as the task (config: `prReviewTrigger`, on by default).

So with the GitHub App webhook setup enabled (`cyrus-setup-github`, the
optional @mentions/webhooks part), the full circle is:

```
delegate issue → Cyrus codes → Git Push row → Create PR row
→ PR attached to issue (+ pinned on session) → Diff tab / review page
→ you review in Linear → "Request changes" syncs to GitHub
→ GitHub webhook → Cyrus resumes, fixes, pushes → same review page updates
```

## 5. Verifying / troubleshooting

- **New PR doesn't show a review page**: confirm code access covers that
repo, and the viewer has the Code reviews toggle on.
- **Old PR (pre-code-access) has no review page**: make a trivial edit to the
PR description on GitHub — Linear ingests it on the next webhook event.
- **PR not attached to the issue**: check the branch contains the issue
identifier; with this change, also check cyrus logs for
`Failed to link PR` (the explicit attach path).
- **Session header shows no PR link**: `addedExternalUrls` requires the
session to belong to Cyrus's OAuth app — sessions created by other apps
(or personal API keys) will reject it; the failure is logged and harmless.
- **State automation surprise**: Linear's GitHub workflow automation may move
the issue (e.g. In Review → In Progress) when a PR links to it. That's
workspace automation settings, not Cyrus.

## 6. Known limitations

- The `[Internal]` session↔PR anchor (`AgentSessionToPullRequest`) can't be
set by third-party agents; everything user-visible works without it. The
review page can still be opened with the agent panel via
`?showAgent=true&agentSessionId=<id>` query params.
- Semantic rows are Claude-runner only (see §2.1).
- GitLab: marker works; `readPullRequest` / attach not yet implemented.
- If a future `@linear/sdk` upgrade adds `addedExternalUrls` to
`AgentSessionUpdateInput`, the raw GraphQL call in
`LinearIssueTrackerService.addAgentSessionExternalUrl` can become a typed
SDK call.
21 changes: 21 additions & 0 deletions packages/claude-runner/src/formatter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { labelGitCommand } from "./git-command-labels.js";

/**
* Message Formatter Interface
*
Expand Down Expand Up @@ -204,6 +206,12 @@ export class ClaudeMessageFormatter implements IMessageFormatter {
switch (toolName) {
case "Bash":
case "↪ Bash": {
// Semantic git/forge rows show the branch or PR title as the
// parameter (the raw command still appears in the result).
const gitLabel = labelGitCommand(toolInput.command);
if (gitLabel?.parameter) {
return gitLabel.parameter;
}
// Show command only - description goes in action field via formatToolActionName
return toolInput.command || JSON.stringify(toolInput);
}
Expand Down Expand Up @@ -390,6 +398,19 @@ export class ClaudeMessageFormatter implements IMessageFormatter {
): string {
// Handle Bash tool with description
if (toolName === "Bash" || toolName === "↪ Bash") {
// Semantic git/forge rows: pushes and PR creation render as
// "Git Push" / "Create PR" — matching how Linear's own coding
// agent labels these steps in the session timeline.
const gitLabel = labelGitCommand(
toolInput && typeof toolInput === "object"
? toolInput.command
: undefined,
);
if (gitLabel) {
const prefix = toolName === "↪ Bash" ? "↪ " : "";
const name = `${prefix}${gitLabel.action}`;
return isError ? `${name} (Error)` : name;
}
// Check if toolInput has a description field
if (
toolInput &&
Expand Down
113 changes: 113 additions & 0 deletions packages/claude-runner/src/git-command-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Semantic labels for git/forge Bash commands.
*
* Linear's own coding agent renders pushes and PR creation as dedicated
* timeline rows ("Git Push <branch>", "Create PR <title>") instead of raw
* shell commands. Cyrus runs those operations through the Bash tool, so by
* default they render as generic "Bash" action rows. This module recognizes
* the common forms and supplies the same semantic action/parameter pair,
* which the formatter uses in place of the generic Bash label.
*
* Deliberately conservative: when a command is compound (e.g. `git add …
* && git commit … && git push`) we do NOT relabel it — calling that row
* "Git Push" would misrepresent the other side effects. A leading
* `cd <dir> &&` prefix is the one tolerated compound form.
*/

export interface GitCommandLabel {
/** Semantic action name, e.g. "Git Push" or "Create PR". */
action: string;
/**
* Human-oriented parameter for the action row — the branch being pushed
* or the PR title. Empty string when nothing better than the raw command
* is known; callers should fall back to the command in that case.
*/
parameter: string;
}

/** Strip a single tolerated `cd <dir> &&` prefix. */
function stripCdPrefix(command: string): string {
return command.replace(/^\s*cd\s+(?:"[^"]+"|'[^']+'|\S+)\s*&&\s*/, "");
}

/** True when the string still contains shell chaining/sequencing operators. */
function isCompound(command: string): boolean {
return /&&|\|\||;|\n/.test(command);
}

/**
* Extract the value of a `--title`/`-t` flag from a `gh pr create` /
* `glab mr create` command line. Returns null when absent.
*/
export function extractTitleFlag(command: string): string | null {
const match = command.match(
/(?:--title|-t)(?:=|\s+)("([^"]*)"|'([^']*)'|(\S+))/,
);
if (!match) return null;
return match[2] ?? match[3] ?? match[4] ?? null;
}

/**
* Best-effort branch name for a simple `git push` command.
* Handles `git push`, `git push origin branch`, `git push -u origin branch`,
* and refspecs (`local:remote` → remote side). Returns "" when the branch
* is not stated on the command line (e.g. plain `git push`).
*/
export function extractPushBranch(command: string): string {
const afterPush = command.replace(/^\s*git\s+push(?=\s|$)/, "");
const tokens = afterPush.trim().split(/\s+/).filter(Boolean);
const positionals: string[] = [];
let skipNext = false;
for (const token of tokens) {
if (skipNext) {
skipNext = false;
continue;
}
if (token.startsWith("-")) {
// Flags that consume a value when space-separated.
if (/^(?:-o|--push-option|--receive-pack|--exec)$/.test(token)) {
skipNext = true;
}
continue;
}
positionals.push(token);
}
// [remote] [refspec…] — the branch is the last refspec when present.
if (positionals.length >= 2) {
const refspec = positionals[positionals.length - 1] ?? "";
const colon = refspec.indexOf(":");
return colon >= 0 ? refspec.slice(colon + 1) : refspec;
}
return "";
}

/**
* Recognize a Bash command as a semantic git/forge operation.
* Returns null when the command should keep its generic Bash label.
*/
export function labelGitCommand(
command: string | undefined | null,
): GitCommandLabel | null {
if (!command) return null;
const simple = stripCdPrefix(command);

// PR/MR creation — allow surrounding context (env vars, HEREDOC bodies)
// as long as the creating invocation is present. Mirrors the matching
// rule of the PR-marker hook.
if (/\bgh\s+pr\s+create\b/.test(simple)) {
return { action: "Create PR", parameter: extractTitleFlag(simple) ?? "" };
}
if (/\bglab\s+mr\s+create\b/.test(simple)) {
return { action: "Create MR", parameter: extractTitleFlag(simple) ?? "" };
}
if (/^\s*gt\s+submit\b/.test(simple) && !isCompound(simple)) {
return { action: "Create PR", parameter: extractTitleFlag(simple) ?? "" };
}

// Push — only when the whole (cd-stripped) command is a single push.
if (/^\s*git\s+push(?=\s|$)/.test(simple) && !isCompound(simple)) {
return { action: "Git Push", parameter: extractPushBranch(simple) };
}

return null;
}
Loading
Loading