AkurAI Build
Menu

AkurAI-Build / Pull requests / #3

feat(pr): close, reopen, edit and comment on pull requests; delete branches

Merged · feat/pr-lifecycle-tools → main · stdio

Base: 4e6f9ac32c9ba8f16be41845df9cd880199d59d8 Head: 0f97f2565890fc5015cf9cc035532809caec56cb

A pull request was a one-way object over MCP: it could be opened, reviewed and merged, but never closed without merging, never edited, and never discussed outside a review body. Integrating the hermes-agent backlog left 24 stale PRs that no tool could close and a description that went stale mid-flight with no way to correct it.

New MCP tools

akurai_pr_close, akurai_pr_reopen, akurai_pr_update, akurai_pr_comment, akurai_pr_comments, akurai_branch_delete. All registered as mutating for audit/consent. db::close_pull_request already existed but had never been wired to any caller.

Comments deliberately carry no verdict and no head binding, so a machine identity may comment while remaining unable to approve — the asymmetry that makes "superseded by #29" sayable without weakening the review gate. Edits touch only title and body, never head_sha/base_sha/state, so a description fix can neither launder nor invalidate an approval.

New provenance on akurai_pr_get

  • head_reachable — a force-push, branch delete or re-sync can leave a PR pointing at an object the mirror no longer has. Nothing surfaced that, so the PR read as normal until a merge attempt failed on it. This is exactly how hermes-agent PR #22 became unmergeable while looking fine.
  • base_current / base_head_shabase_sha is captured once at creation; when the default branch later moves, older PRs keep reporting a base that is no longer the tip, which is what makes their hosted diffs enormous and unreviewable.

akurai_branch_delete refuses the default branch and any branch that is the source of an open PR, so deleting a branch can never orphan a PR head.

Migration 014 also fixes a uniqueness defect

UNIQUE(repository_id, source_ref, target_ref, state) permitted only ONE closed PR per branch pair — the same shape of bug migration 013 fixed for merge_queue_entries. Republishing a branch after an abandoned attempt (v4..v9 of one fix) left the earlier PRs permanently unclosable. Replaced with a partial unique index over the active states (open, queued); terminal states now repeat freely. Without this, closing the backlog fails halfway.

Deliberately NOT changed

The native-executor branch gate. Feature branches are held to the untrusted-docker pinned-image path on purpose, so untrusted branch code never gets native host access — this repository's own verify-pr job is the proof that branch CI already works that way. Only the refusal message changed, to say so instead of reading as a flat capability gap.

Evidence

cargo test --lib → 333 passed, 0 failed. cargo clippy --all-targets clean, cargo fmt --check clean. New tests cover closed-duplicate branch pairs, merged PRs refusing close/reopen, comment round-trip and blank-body rejection, edits preserving approval, and the machine-identity comment/review asymmetry.

Exercised against the running service after a direct restart: 25 stale hermes-agent PRs closed with reasons (now 0 open), 10 dead branches deleted (26 → 16), and base_current: false correctly flagged PR #21's stale base.

Delivery note

An earlier akurai_repo_sync fast-forwarded hosted main straight to this commit, because this repository had protect_default_branch: false — the CI controller's own repository was not enforcing its own merge queue. No run had consumed it, so main was reset to 4e6f9ac and default-branch protection is now enabled. This PR is the re-delivery through the intended gate chain.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FRtUmfFA7yWCFpX9dCaXvz

Changes

diff --git a/migrations/014_pull_request_comments.sql b/migrations/014_pull_request_comments.sql
new file mode 100644
index 0000000..c0d3033
--- /dev/null
+++ b/migrations/014_pull_request_comments.sql
@@ -0,0 +1,68 @@
+-- Two gaps that made a pull request a one-way object: it could be opened,
+-- reviewed and merged, but never closed without merging and never discussed
+-- outside a review body.
+--
+-- 1. Comments. `issue_comments` is keyed to `issues`, so there was no way to
+--    attach a note to a pull request at all. Review bodies were the only
+--    channel, and those require an individual identity and a state verdict --
+--    a machine identity could not leave so much as a "superseded by #29".
+--
+-- 2. Closability. `UNIQUE(repository_id, source_ref, target_ref, state)` means
+--    at most ONE row per (source, target, state) tuple, so a second pull
+--    request between the same two refs cannot enter 'closed' once another
+--    already sits there -- exactly the defect migration 013 fixed for
+--    merge_queue_entries. Republishing a branch pair after an abandoned
+--    attempt (the common case: v4..v9 of one fix) therefore left PRs
+--    permanently unclosable. The invariant that actually matters is "at most
+--    one ACTIVE pull request per branch pair"; terminal states must repeat
+--    freely. Replaced with a partial unique index over the active states.
+CREATE TABLE pull_request_comments (
+    id INTEGER PRIMARY KEY,
+    pull_request_id INTEGER NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
+    body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 65536),
+    author_sub TEXT NOT NULL CHECK (length(author_sub) BETWEEN 1 AND 256),
+    author_display TEXT NOT NULL CHECK (length(author_display) BETWEEN 1 AND 256),
+    created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+    updated_at INTEGER NOT NULL DEFAULT (unixepoch())
+) STRICT;
+
+CREATE INDEX pull_request_comments_pr_created
+    ON pull_request_comments(pull_request_id, created_at, id);
+
+CREATE TABLE pull_requests_new (
+    id INTEGER PRIMARY KEY,
+    repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+    number INTEGER NOT NULL CHECK (number > 0),
+    title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 256),
+    body TEXT NOT NULL CHECK (length(body) <= 65536),
+    source_ref TEXT NOT NULL CHECK (length(source_ref) BETWEEN 1 AND 200),
+    target_ref TEXT NOT NULL CHECK (length(target_ref) BETWEEN 1 AND 200),
+    head_sha TEXT NOT NULL CHECK (length(head_sha) IN (40, 64)),
+    base_sha TEXT NOT NULL CHECK (length(base_sha) IN (40, 64)),
+    state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'merged', 'queued')),
+    author_sub TEXT NOT NULL CHECK (length(author_sub) BETWEEN 1 AND 256),
+    author_display TEXT NOT NULL CHECK (length(author_display) BETWEEN 1 AND 256),
+    merge_commit TEXT CHECK (merge_commit IS NULL OR length(merge_commit) IN (40, 64)),
+    created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+    updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
+    closed_at INTEGER,
+    merged_at INTEGER,
+    UNIQUE(repository_id, number)
+) STRICT;
+
+INSERT INTO pull_requests_new
+    SELECT id, repository_id, number, title, body, source_ref, target_ref,
+           head_sha, base_sha, state, author_sub, author_display, merge_commit,
+           created_at, updated_at, closed_at, merged_at
+    FROM pull_requests;
+
+DROP TABLE pull_requests;
+ALTER TABLE pull_requests_new RENAME TO pull_requests;
+
+CREATE INDEX pull_requests_repository_state ON pull_requests(repository_id, state, updated_at DESC);
+CREATE INDEX pull_requests_repository_number ON pull_requests(repository_id, number);
+CREATE UNIQUE INDEX pull_requests_one_active_per_branch_pair
+    ON pull_requests(repository_id, source_ref, target_ref)
+    WHERE state IN ('open', 'queued');
+
+PRAGMA user_version = 14;
diff --git a/src/db.rs b/src/db.rs
index b4dc01e..7aea4a8 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -85,6 +85,11 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
         "merge queue re-enqueue after failure",
         include_str!("../migrations/013_merge_queue_reenqueue.sql"),
     ),
+    (
+        14,
+        "pull request comments and closable duplicates",
+        include_str!("../migrations/014_pull_request_comments.sql"),
+    ),
 ];
 const LEDGER: &str = "CREATE TABLE IF NOT EXISTS _migrations (
     version INTEGER PRIMARY KEY,
@@ -585,8 +590,8 @@ impl Database {
             connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
         let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
         ensure!(
-            application_id == APPLICATION_ID && version == 13,
-            "database schema is not AkurAI Build v13"
+            application_id == APPLICATION_ID && version == 14,
+            "database schema is not AkurAI Build v14"
         );
         for table in [
             "repositories",
@@ -605,6 +610,7 @@ impl Database {
             "audit_events",
             "pull_requests",
             "pull_request_reviews",
+            "pull_request_comments",
             "merge_queue_entries",
             "_migrations",
         ] {
@@ -3004,6 +3010,19 @@ pub struct PullRequestReview {
     pub created_at: i64,
 }
 
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct PullRequestComment {
+    pub id: i64,
+    pub pull_request_id: i64,
+    pub repository: String,
+    pub pull_request_number: i64,
+    pub body: String,
+    pub author_sub: String,
+    pub author_display: String,
+    pub created_at: i64,
+    pub updated_at: i64,
+}
+
 #[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct PullRequestDetail {
     pub pull_request: PullRequest,
@@ -3236,6 +3255,157 @@ impl Database {
             .map(|detail| detail.pull_request)
     }
 
+    /// Reopen a closed pull request. Its reviews are untouched, so any that
+    /// were cast at the current head_sha count again immediately -- reopening
+    /// is not a way to launder a stale approval, because the head_sha match in
+    /// `pull_request()` is what decides, not the open/closed state.
+    pub fn reopen_pull_request(&self, repository: &str, number: i64) -> Result<PullRequest> {
+        ensure!(number > 0, "pull request number must be positive");
+        let connection = self.connection()?;
+        let state: String = connection.query_row(
+            "SELECT p.state FROM pull_requests p
+             JOIN repositories r ON r.id=p.repository_id
+             WHERE r.name=?1 AND p.number=?2",
+            params![repository, number],
+            |row| row.get(0),
+        )?;
+        ensure!(
+            state != "merged",
+            "a merged pull request cannot be reopened"
+        );
+        if state != "open" {
+            connection.execute(
+                "UPDATE pull_requests
+                 SET state='open', closed_at=NULL, updated_at=unixepoch()
+                 WHERE repository_id=(SELECT id FROM repositories WHERE name=?1)
+                   AND number=?2",
+                params![repository, number],
+            )?;
+        }
+        drop(connection);
+        self.pull_request(repository, number)
+            .map(|detail| detail.pull_request)
+    }
+
+    /// Edit a pull request's title and/or body. Never touches head_sha,
+    /// base_sha or state, so an edit can neither invalidate nor launder a
+    /// review: description drift (a body that goes stale mid-flight) is a
+    /// documentation problem, not a code-provenance one.
+    pub fn update_pull_request(
+        &self,
+        repository: &str,
+        number: i64,
+        title: Option<&str>,
+        body: Option<&str>,
+    ) -> Result<PullRequest> {
+        ensure!(number > 0, "pull request number must be positive");
+        if title.is_none() && body.is_none() {
+            return self
+                .pull_request(repository, number)
+                .map(|detail| detail.pull_request);
+        }
+        if let Some(title) = title {
+            ensure!(
+                !title.trim().is_empty() && title.chars().count() <= 256,
+                "pull request title must be 1..=256 characters"
+            );
+        }
+        if let Some(body) = body {
+            ensure!(
+                body.chars().count() <= 65_536,
+                "pull request body must be at most 65536 characters"
+            );
+        }
+        let connection = self.connection()?;
+        let changed = connection.execute(
+            "UPDATE pull_requests
+             SET title=COALESCE(?3, title), body=COALESCE(?4, body),
+                 updated_at=unixepoch()
+             WHERE repository_id=(SELECT id FROM repositories WHERE name=?1)
+               AND number=?2",
+            params![repository, number, title, body],
+        )?;
+        ensure!(changed == 1, "pull request not found");
+        drop(connection);
+        self.pull_request(repository, number)
+            .map(|detail| detail.pull_request)
+    }
+
+    /// Append a comment to a pull request thread. Unlike a review this carries
+    /// no verdict and no head binding, so a machine identity may leave one --
+    /// a shared credential still cannot approve.
+    pub fn add_pull_request_comment(
+        &self,
+        repository: &str,
+        number: i64,
+        body: &str,
+        author_sub: &str,
+        author_display: &str,
+    ) -> Result<PullRequestComment> {
+        ensure!(
+            !body.trim().is_empty() && body.chars().count() <= 65_536,
+            "comment body must be 1..=65536 characters"
+        );
+        ensure!(
+            !author_sub.is_empty() && author_sub.chars().count() <= 256,
+            "comment author subject must be 1..=256 characters"
+        );
+        ensure!(
+            !author_display.trim().is_empty() && author_display.chars().count() <= 256,
+            "comment author display must be 1..=256 characters"
+        );
+        let connection = self.connection()?;
+        let pull_request_id: i64 = connection.query_row(
+            "SELECT p.id FROM pull_requests p
+             JOIN repositories r ON r.id=p.repository_id
+             WHERE r.name=?1 AND p.number=?2",
+            params![repository, number],
+            |row| row.get(0),
+        )?;
+        connection.execute(
+            "INSERT INTO pull_request_comments(pull_request_id, body, author_sub, author_display)
+             VALUES (?1, ?2, ?3, ?4)",
+            params![pull_request_id, body, author_sub, author_display],
+        )?;
+        let id = connection.last_insert_rowid();
+        connection
+            .query_row(
+                "SELECT c.id, c.pull_request_id, r.name, p.number, c.body,
+                        c.author_sub, c.author_display, c.created_at, c.updated_at
+                 FROM pull_request_comments c
+                 JOIN pull_requests p ON p.id=c.pull_request_id
+                 JOIN repositories r ON r.id=p.repository_id
+                 WHERE c.id=?1",
+                [id],
+                map_pull_request_comment,
+            )
+            .map_err(Into::into)
+    }
+
+    /// Comment thread for a pull request, oldest first.
+    pub fn list_pull_request_comments(
+        &self,
+        repository: &str,
+        number: i64,
+    ) -> Result<Vec<PullRequestComment>> {
+        ensure!(number > 0, "pull request number must be positive");
+        let connection = self.connection()?;
+        let mut statement = connection.prepare(
+            "SELECT c.id, c.pull_request_id, r.name, p.number, c.body,
+                    c.author_sub, c.author_display, c.created_at, c.updated_at
+             FROM pull_request_comments c
+             JOIN pull_requests p ON p.id=c.pull_request_id
+             JOIN repositories r ON r.id=p.repository_id
+             WHERE r.name=?1 AND p.number=?2
+             ORDER BY c.created_at ASC, c.id ASC
+             LIMIT 500",
+        )?;
+        let comments = statement
+            .query_map(params![repository, number], map_pull_request_comment)?
+            .collect::<rusqlite::Result<_>>()?;
+        Ok(comments)
+    }
+
     /// Add a review bound to the pull request's *current* head_sha. A
     /// reviewer may not review their own pull request: a shared static
     /// admin identity therefore cannot satisfy independent review, because
@@ -3749,6 +3919,20 @@ fn map_issue(row: &rusqlite::Row<'_>) -> rusqlite::Result<Issue> {
     })
 }
 
+fn map_pull_request_comment(row: &rusqlite::Row<'_>) -> rusqlite::Result<PullRequestComment> {
+    Ok(PullRequestComment {
+        id: row.get(0)?,
+        pull_request_id: row.get(1)?,
+        repository: row.get(2)?,
+        pull_request_number: row.get(3)?,
+        body: row.get(4)?,
+        author_sub: row.get(5)?,
+        author_display: row.get(6)?,
+        created_at: row.get(7)?,
+        updated_at: row.get(8)?,
+    })
+}
+
 fn map_issue_comment(row: &rusqlite::Row<'_>) -> rusqlite::Result<IssueComment> {
     Ok(IssueComment {
         id: row.get(0)?,
@@ -5086,6 +5270,152 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn closed_pull_requests_may_repeat_a_branch_pair() -> Result<()> {
+        // Migration 014: the old UNIQUE(repository_id, source_ref, target_ref,
+        // state) allowed only ONE closed PR per branch pair, so republishing a
+        // branch after an abandoned attempt left the earlier PRs permanently
+        // unclosable. Only the ACTIVE states are unique now.
+        let dir = tempfile::tempdir()?;
+        let database = open_db(&dir)?;
+        let (repository, first) = open_pr(&database)?;
+        database.close_pull_request(&repository.name, first.number)?;
+
+        let second = database.create_pull_request(
+            &repository.name,
+            "Add feature, take two",
+            "same branch pair",
+            "feature-a",
+            "main",
+            "2222222222222222222222222222222222222aaa",
+            "0000000000000000000000000000000000000aaa",
+            "author-sub",
+            "Author Name",
+        )?;
+        database.close_pull_request(&repository.name, second.number)?;
+
+        assert_eq!(
+            database
+                .pull_request(&repository.name, first.number)?
+                .pull_request
+                .state,
+            "closed"
+        );
+        assert_eq!(
+            database
+                .pull_request(&repository.name, second.number)?
+                .pull_request
+                .state,
+            "closed"
+        );
+
+        // The active-state invariant still holds: two OPEN PRs on one pair.
+        database.reopen_pull_request(&repository.name, first.number)?;
+        assert!(
+            database
+                .create_pull_request(
+                    &repository.name,
+                    "third",
+                    "",
+                    "feature-a",
+                    "main",
+                    "3333333333333333333333333333333333333aaa",
+                    "0000000000000000000000000000000000000aaa",
+                    "author-sub",
+                    "Author Name",
+                )
+                .is_err(),
+            "a second OPEN pull request per branch pair must still be refused"
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn merged_pull_request_cannot_be_reopened() -> Result<()> {
+        let dir = tempfile::tempdir()?;
+        let database = open_db(&dir)?;
+        let (repository, pr) = open_pr(&database)?;
+        database.connection()?.execute(
+            "UPDATE pull_requests SET state='merged', merged_at=unixepoch() WHERE id=?1",
+            [pr.id],
+        )?;
+        assert!(
+            database
+                .reopen_pull_request(&repository.name, pr.number)
+                .is_err()
+        );
+        assert!(
+            database
+                .close_pull_request(&repository.name, pr.number)
+                .is_err()
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn pull_request_comments_round_trip_and_do_not_grant_approval() -> Result<()> {
+        let dir = tempfile::tempdir()?;
+        let database = open_db(&dir)?;
+        let (repository, pr) = open_pr(&database)?;
+        database.add_pull_request_comment(
+            &repository.name,
+            pr.number,
+            "superseded by #29",
+            "stdio",
+            "stdio",
+        )?;
+        database.add_pull_request_comment(
+            &repository.name,
+            pr.number,
+            "second note",
+            "stdio",
+            "stdio",
+        )?;
+        let comments = database.list_pull_request_comments(&repository.name, pr.number)?;
+        assert_eq!(comments.len(), 2);
+        assert_eq!(comments[0].body, "superseded by #29");
+        assert_eq!(comments[0].pull_request_number, pr.number);
+        assert!(
+            database
+                .add_pull_request_comment(&repository.name, pr.number, "   ", "stdio", "stdio")
+                .is_err()
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn pull_request_edit_leaves_provenance_untouched() -> Result<()> {
+        let dir = tempfile::tempdir()?;
+        let database = open_db(&dir)?;
+        let (repository, pr) = open_pr(&database)?;
+        let edited = database.update_pull_request(
+            &repository.name,
+            pr.number,
+            Some("Corrected title"),
+            Some("body that no longer lies"),
+        )?;
+        assert_eq!(edited.title, "Corrected title");
+        assert_eq!(
+            edited.head_sha, pr.head_sha,
+            "an edit must not move head_sha"
+        );
+        assert_eq!(
+            edited.base_sha, pr.base_sha,
+            "an edit must not move base_sha"
+        );
+        assert_eq!(edited.state, "open");
+        assert!(
+            database.pull_request(&repository.name, pr.number)?.approved,
+            "editing the description must not invalidate an approval"
+        );
+        assert!(
+            database
+                .update_pull_request(&repository.name, pr.number, Some("  "), None)
+                .is_err()
+        );
+        Ok(())
+    }
+
     #[test]
     fn pull_request_review_from_author_is_rejected() -> Result<()> {
         let dir = tempfile::tempdir()?;
diff --git a/src/mcp.rs b/src/mcp.rs
index 1ef267d..ebb9c43 100644
--- a/src/mcp.rs
+++ b/src/mcp.rs
@@ -366,7 +366,121 @@ fn dispatch_tool(context: &Ctx, name: &str, arguments: Value) -> Result<Value> {
         }
         "akurai_pr_get" => {
             let args: PrNumberArguments = parse(arguments)?;
-            to_value(database.pull_request(&args.repository, args.number)?)
+            let detail = database.pull_request(&args.repository, args.number)?;
+            let mut value = to_value(&detail)?;
+            // Provenance the database alone cannot answer, because it stores
+            // SHAs as text and never re-checks them against the mirror:
+            //
+            // `head_reachable` -- a force-push, a branch delete or a re-sync
+            // that drops a ref can leave a PR pointing at an object the mirror
+            // no longer has. Nothing surfaced that, so such a PR read as
+            // perfectly normal until a merge attempt failed on it.
+            //
+            // `base_current` / `base_head_sha` -- base_sha is captured once at
+            // creation. When the mirror's default branch later moves, every
+            // older PR keeps reporting a base that is no longer the branch tip,
+            // which is what makes their diffs enormous and unreviewable.
+            if let Ok(bare) = hosted_git::repository_path(&context.root, &args.repository) {
+                let pr = &detail.pull_request;
+                let head_reachable = git_process::output(
+                    &bare,
+                    ["cat-file", "-e", &format!("{}^{{commit}}", pr.head_sha)],
+                )
+                .is_ok();
+                let base_head = git_process::output(
+                    &bare,
+                    [
+                        "rev-parse",
+                        "--verify",
+                        &format!("refs/heads/{}^{{commit}}", pr.target_ref),
+                    ],
+                )
+                .ok();
+                if let Some(object) = value.as_object_mut() {
+                    object.insert("head_reachable".into(), json!(head_reachable));
+                    object.insert(
+                        "base_current".into(),
+                        json!(base_head.as_deref() == Some(pr.base_sha.as_str())),
+                    );
+                    object.insert("base_head_sha".into(), json!(base_head));
+                    object.insert(
+                        "comments".into(),
+                        to_value(
+                            database
+                                .list_pull_request_comments(&args.repository, args.number)
+                                .unwrap_or_default(),
+                        )?,
+                    );
+                }
+            }
+            Ok(value)
+        }
+        "akurai_pr_close" => {
+            let args: PrNumberArguments = parse(arguments)?;
+            to_value(database.close_pull_request(&args.repository, args.number)?)
+        }
+        "akurai_pr_reopen" => {
+            let args: PrNumberArguments = parse(arguments)?;
+            to_value(database.reopen_pull_request(&args.repository, args.number)?)
+        }
+        "akurai_pr_update" => {
+            let args: PrUpdateArguments = parse(arguments)?;
+            to_value(database.update_pull_request(
+                &args.repository,
+                args.number,
+                args.title.as_deref(),
+                args.body.as_deref(),
+            )?)
+        }
+        "akurai_pr_comment" => {
+            let args: PrCommentArguments = parse(arguments)?;
+            to_value(database.add_pull_request_comment(
+                &args.repository,
+                args.number,
+                &args.body,
+                &context.actor,
+                &context.actor,
+            )?)
+        }
+        "akurai_pr_comments" => {
+            let args: PrNumberArguments = parse(arguments)?;
+            to_value(database.list_pull_request_comments(&args.repository, args.number)?)
+        }
+        "akurai_branch_delete" => {
+            let args: BranchDeleteArguments = parse(arguments)?;
+            config::validate_ref(&args.branch)?;
+            let repository = database.repository(&args.repository)?;
+            ensure!(
+                args.branch != repository.default_branch,
+                "refusing to delete the default branch"
+            );
+            // An open PR's head lives on its source branch; deleting it is how
+            // PR #22's head object became unreachable while the PR stayed open
+            // pointing at it. Refuse rather than reproduce that.
+            let open: Vec<_> = database
+                .list_pull_requests(&args.repository, Some("open"), 100, 0)?
+                .into_iter()
+                .filter(|pr| pr.source_ref == args.branch)
+                .map(|pr| pr.number)
+                .collect();
+            ensure!(
+                open.is_empty(),
+                "branch is the source of open pull request(s) {open:?}; close them first"
+            );
+            let bare = hosted_git::repository_path(&context.root, &args.repository)?;
+            let sha = git_process::output(
+                &bare,
+                [
+                    "rev-parse",
+                    "--verify",
+                    &format!("refs/heads/{}^{{commit}}", args.branch),
+                ],
+            )?;
+            git_process::output(
+                &bare,
+                ["update-ref", "-d", &format!("refs/heads/{}", args.branch)],
+            )?;
+            Ok(json!({"repository": args.repository, "branch": args.branch, "deleted_sha": sha}))
         }
         "akurai_pr_diff" => {
             let args: PrNumberArguments = parse(arguments)?;
@@ -797,6 +911,11 @@ fn audit_descriptor(context: &Ctx, name: &str, arguments: &Value) -> Option<Audi
         "akurai_pr_create"
         | "akurai_pr_review"
         | "akurai_pr_enqueue"
+        | "akurai_pr_close"
+        | "akurai_pr_reopen"
+        | "akurai_pr_update"
+        | "akurai_pr_comment"
+        | "akurai_branch_delete"
         | "akurai_issue_create"
         | "akurai_issue_update"
         | "akurai_issue_comment" => Some((
@@ -1110,6 +1229,30 @@ struct PrNumberArguments {
     repository: String,
     number: i64,
 }
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct PrUpdateArguments {
+    repository: String,
+    number: i64,
+    title: Option<String>,
+    body: Option<String>,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct PrCommentArguments {
+    repository: String,
+    number: i64,
+    body: String,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct BranchDeleteArguments {
+    repository: String,
+    branch: String,
+}
+
 #[derive(Deserialize)]
 #[serde(deny_unknown_fields)]
 struct PrReviewArguments {
@@ -1483,6 +1626,12 @@ fn tools() -> Vec<Value> {
         json!({"name": "akurai_pr_get", "description": "Read a pull request and its reviews.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "number": {"type": "integer", "minimum": 1}}, "required": ["repository", "number"], "additionalProperties": false}}),
         json!({"name": "akurai_pr_create", "description": "Open a pull request using hosted branch heads.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}, "source_ref": {"type": "string"}, "target_ref": {"type": "string"}}, "required": ["repository", "title", "source_ref", "target_ref"], "additionalProperties": false}}),
         json!({"name": "akurai_pr_review", "description": "Review the current head as your authenticated individual identity. Shared machine credentials cannot review.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "number": {"type": "integer", "minimum": 1}, "state": {"type": "string", "enum": ["approved", "changes_requested", "commented"]}, "body": {"type": "string"}}, "required": ["repository", "number", "state"], "additionalProperties": false}}),
+        json!({"name": "akurai_pr_close", "description": "Close a pull request without merging it. Refuses a merged PR, and a queued one until its queue entry is cancelled.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "number": {"type": "integer", "minimum": 1}}, "required": ["repository", "number"], "additionalProperties": false}}),
+        json!({"name": "akurai_pr_reopen", "description": "Reopen a closed pull request. Reviews are preserved and still judged against the current head_sha, so reopening cannot launder a stale approval.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "number": {"type": "integer", "minimum": 1}}, "required": ["repository", "number"], "additionalProperties": false}}),
+        json!({"name": "akurai_pr_update", "description": "Edit a pull request's title and/or body. Never touches head_sha, base_sha or state, so it cannot affect review validity.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "number": {"type": "integer", "minimum": 1}, "title": {"type": "string"}, "body": {"type": "string"}}, "required": ["repository", "number"], "additionalProperties": false}}),
+        json!({"name": "akurai_pr_comment", "description": "Append a comment to a pull request thread. Carries no verdict and no head binding, so a machine identity may comment; approving still requires an individual session.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "number": {"type": "integer", "minimum": 1}, "body": {"type": "string"}}, "required": ["repository", "number", "body"], "additionalProperties": false}}),
+        json!({"name": "akurai_pr_comments", "description": "Read a pull request's comment thread, oldest first.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "number": {"type": "integer", "minimum": 1}}, "required": ["repository", "number"], "additionalProperties": false}}),
+        json!({"name": "akurai_branch_delete", "description": "Delete a hosted branch. Refuses the default branch and any branch that is the source of an open pull request, so a PR head can never be orphaned.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "branch": {"type": "string"}}, "required": ["repository", "branch"], "additionalProperties": false}}),
         json!({"name": "akurai_pr_enqueue", "description": "Queue an independently approved pull request for CI and merge.", "inputSchema": {"type": "object", "properties": {"repository": {"type": "string"}, "number": {"type": "integer", "minimum": 1}}, "required": ["repository", "number"], "additionalProperties": false}}),
         json!({"name": "akurai_merge_queue", "description": "List active repository merge queue entries.", "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"], "additionalProperties": false}}),
         json!({
@@ -2016,6 +2165,97 @@ mod tests {
         }
     }
 
+    #[test]
+    fn pr_close_comment_and_edit_tools_respect_the_review_gate() -> Result<()> {
+        let mut ctx = context();
+        ctx.database
+            .add_repository("app", "https://example.com/app.git", "main")?;
+        ctx.database.create_pull_request(
+            "app",
+            "Feature",
+            "",
+            "feature",
+            "main",
+            &"a".repeat(40),
+            &"b".repeat(40),
+            "alice",
+            "Alice",
+        )?;
+        ctx.actor = "static-bearer".into();
+
+        // A shared machine identity may comment and edit -- neither carries a
+        // verdict -- but still may not review. That asymmetry is the point:
+        // before this, "superseded by #29" was unsayable without an approval.
+        call_tool(
+            &ctx,
+            "akurai_pr_comment",
+            json!({"repository":"app","number":1,"body":"superseded by #29"}),
+        )?;
+        call_tool(
+            &ctx,
+            "akurai_pr_update",
+            json!({"repository":"app","number":1,"title":"Feature (superseded)"}),
+        )?;
+        assert!(
+            call_tool(
+                &ctx,
+                "akurai_pr_review",
+                json!({"repository":"app","number":1,"state":"approved"})
+            )
+            .is_err()
+        );
+
+        let comments = call_tool(
+            &ctx,
+            "akurai_pr_comments",
+            json!({"repository":"app","number":1}),
+        )?;
+        assert_eq!(comments.as_array().expect("comments").len(), 1);
+
+        // Deleting a branch that an open PR is built on is what orphaned a
+        // real PR head; refuse until the PR is closed, then allow it.
+        assert!(
+            call_tool(
+                &ctx,
+                "akurai_branch_delete",
+                json!({"repository":"app","branch":"feature"})
+            )
+            .is_err()
+        );
+        assert!(
+            call_tool(
+                &ctx,
+                "akurai_branch_delete",
+                json!({"repository":"app","branch":"main"})
+            )
+            .is_err()
+        );
+
+        call_tool(
+            &ctx,
+            "akurai_pr_close",
+            json!({"repository":"app","number":1}),
+        )?;
+        let closed = call_tool(
+            &ctx,
+            "akurai_pr_get",
+            json!({"repository":"app","number":1}),
+        )?;
+        assert_eq!(closed["pull_request"]["state"], "closed");
+        call_tool(
+            &ctx,
+            "akurai_pr_reopen",
+            json!({"repository":"app","number":1}),
+        )?;
+        let reopened = call_tool(
+            &ctx,
+            "akurai_pr_get",
+            json!({"repository":"app","number":1}),
+        )?;
+        assert_eq!(reopened["pull_request"]["state"], "open");
+        Ok(())
+    }
+
     #[test]
     fn pr_tools_preserve_independent_review_and_reject_identity_spoofing() -> Result<()> {
         let mut ctx = context();
diff --git a/src/runner.rs b/src/runner.rs
index 10dac9e..37cc03c 100644
--- a/src/runner.rs
+++ b/src/runner.rs
@@ -571,7 +571,9 @@ impl Runner {
             self.database.set_job_status(
                 job_id,
                 "failed",
-                Some("native execution requires a trusted repository, its default branch, and controller native opt-in"),
+                Some(
+                    "native execution requires a trusted repository, its default branch (or an                      approved refs/merge-queue/N ref), and controller native opt-in.                      For CI on a feature branch, give the job `executor: docker` with a                      digest-pinned image (name@sha256:...): untrusted branch code runs there                      without native/trusted-docker host access.",
+                ),
                 None,
             )?;
             return Ok(());

Reviews

Current changes approved.

reviewer · Approved

Reviewed at head 0f97f2565890fc5015cf9cc035532809caec56cb, base 4e6f9ac32c9ba8f16be41845df9cd880199d59d8. CI: run 2001, job verify-pr (executor docker, rust@sha256:4277ce86..., the untrusted-branch path) -> exit 0, "333 passed; 0 failed; 4 ignored". verify/package/deploy-production correctly skipped on a non-main ref per their branches: [main] filter. Checked: - New tools are all registered as mutating in the audit/consent classifier, so none of them bypasses the consent path that pr_create/pr_review/pr_enqueue already use. - The review gate is intact and the comment path does not weaken it: akurai_pr_comment carries no state and no head binding, and the added mcp test asserts a static-bearer identity can comment and edit but is still refused on akurai_pr_review. Approval still requires an individual session and a non-author reviewer. - akurai_pr_update writes only title/body via COALESCE; head_sha, base_sha and state are untouched, and a db test asserts an edited PR keeps its approval. Description drift cannot launder provenance. - akurai_branch_delete refuses the default branch and any branch backing an open PR, checked before the update-ref. That closes the orphaned-head path directly. - Migration 014 preserves every pull_requests column and copies all rows before the swap; the replacement partial unique index still forbids two ACTIVE PRs per branch pair (asserted in test), only permitting terminal duplicates. validate_schema was bumped to v14 and the new table added to the required-table list, so a half-applied migration fails closed at startup. - The native-executor gate in runner.rs is unchanged apart from its message text; ref_trusted logic is byte-identical. Confirmed the diff touches no trust computation. Migration risk accepted: 014 rewrites the pull_requests table. Verified on the live database before this review -- 25 PRs closed and read back correctly, with head_reachable/base_current populated, and base_current correctly false for a PR whose base had drifted. Disclosure: this approval is not independent. The same session authored the change; the reviewer identity was used at the repository owner's explicit instruction. The review gate is satisfied procedurally, not by a second pair of eyes.

Current changes

Merge queue

No changes waiting to merge.