~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Pulls can be set to merge by themselves once their checks pass

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:pull/merge-when-checks-pass
.changeset/merge-when-checks-pass.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# Merge a pull by itself once its checks pass
`knotless pull merge <url> <pull> --auto` sets a pull to merge by itself once its checks pass, on a branch with no merge queue. The runner that lands the repository's merge queues merges it then. A push by anyone who may not merge it, or retargeting it, cancels it, and `--auto --undo` cancels it, as the pull's author may too. `pull show` says where it stands. The MCP tool is `pull_merge` with `auto`, and `undo`. Logging in again gives an existing OAuth login the permission to do it.
crates/knotless/src/auto_merge.rsnew+291−0
@@ -0,0 +1,291 @@
//! Merge when checks pass: a pull set to merge by itself once its checks
//! pass at its head merged with its base, for a branch with no merge queue.
//!
//! It is set by a `dev.knotless.autoMerge` record in the account of whoever
//! set it, the owner or a collaborator, as merging takes, and cancelled by
//! another, from them or the pull's author ([`forge::assemble`]). The record
//! names the head and the branch it was set at. A later head still counts
//! when the pull's head repository belongs to someone who may merge it, so
//! its author's fix carries on; anyone else's push cancels it, as one could
//! otherwise wait for it to be set and then push something else. A pull
//! retargeted since is cancelled too. Nothing is written for either: every
//! reader works it out from the record and the pull as it is ([`standing`]).
//!
//! The runner that lands the repository's merge queues merges it, as
//! [`forge::merge`] does, once that would pass: through the gateway, when it
//! may not push, which takes from it only such a merge
//! ([`crate::queue::landing_refusal`]).
use crate::env::Ctx;
use crate::forge::{self, AutoMerge, Item, Target};
use crate::git::Git;
use crate::push::Update;
use crate::records::{self, AutoMergeRecord, RepoRecord, auto_merge::*, nsid};
use crate::session;
use crate::url::RepoUrl;
use anyhow::{Result, bail};
/// Whether a pull's merge when checks pass stands, at its head as it is.
#[derive(Debug, PartialEq)]
pub enum Standing<'a> {
/// Never set, or cancelled by someone.
Off,
/// Set, and still standing.
Set(&'a AutoMerge),
/// Set, then cancelled by a push from someone who may not merge it, the
/// owner of its head repository, by DID.
Pushed { set: &'a AutoMerge, by: String },
/// Set, then cancelled by the pull moving to another branch.
Retargeted(&'a AutoMerge),
}
impl Standing<'_> {
/// Whether it merges by itself once its checks pass.
pub fn set(&self) -> bool {
matches!(self, Standing::Set(_))
}
}
/// Where `item`'s merge when checks pass stands, its head being at `head`:
/// see the module's documentation.
pub fn standing<'a>(repo: &RepoRecord, owner: &str, item: &'a Item, head: &str) -> Standing<'a> {
let Some(set) = item.auto_merge.as_ref().filter(|a| a.state == SET) else {
return Standing::Off;
};
if set.base.is_some() && set.base != item.base {
return Standing::Retargeted(set);
}
if set.commit.as_deref() == Some(head) {
return Standing::Set(set);
}
let pushed_by = item.head.as_ref().map(|h| h.repo.uri.as_str());
let pushed_by = pushed_by.and_then(|uri| forge::parse_uri(uri).ok());
let pushed_by = pushed_by
.map(|(did, ..)| did.to_string())
.unwrap_or_default();
match forge::maintains(repo, owner, &pushed_by, &set.at) {
true => Standing::Set(set),
false => Standing::Pushed { set, by: pushed_by },
}
}
/// A record setting or cancelling `pull`'s merge when checks pass, in `t`.
fn record(t: &Target, pull: &str, state: &str, item: Option<(&Item, &str)>) -> AutoMergeRecord {
AutoMergeRecord {
repo: t.reference(),
subject: pull.to_string(),
state: state.to_string(),
commit: item.map(|(_, head)| head.to_string()),
base: item.and_then(|(i, _)| i.base.clone()),
created_at: records::now(),
}
}
/// Sets `pull` to merge by itself once its checks pass, at its head as it
/// is now, returning that head. The owner and collaborators may, on a
/// branch with no merge queue, in a repository naming a runner to merge it.
pub async fn set(ctx: &Ctx, url: &RepoUrl, pull: &str) -> Result<String> {
let t = Target::open(ctx, url).await?;
let pull = t.named(pull);
let pull = pull.as_str();
let me = session::me(ctx).await?;
crate::org::needs_public(ctx, t.facts(), &me, "merging").await?;
if me.did != t.remote.did() && !t.record.is_collaborator(&me.did) {
bail!(
"only the owner and collaborators of {url} merge its pulls, and set them to merge when their checks pass"
);
}
if t.record.runners.is_empty() {
bail!(
"{url} names no runners, and a runner merges a pull once its checks pass: merge it with `knotless pull merge`, or name one with `knotless runner add {url} <handle>`"
);
}
let (_, item) = crate::queue::open_pull(ctx, &t, pull).await?;
if item.draft {
bail!("{}", forge::drafted(url, pull));
}
let base = item.base.as_deref().unwrap_or_default();
if t.record.queue_only(base) {
bail!(
"{base} takes pulls through the merge queue, which holds one until its checks pass: queue it with `knotless pull merge {url} {pull} --queue`"
);
}
let sides = forge::sides(ctx, &t, &item).await?;
let value = record(&t, pull, SET, Some((&item, &sides.head)));
t.write(
ctx,
nsid::AUTO_MERGE,
records::typed(nsid::AUTO_MERGE, &value),
)
.await?;
Ok(sides.head)
}
/// Cancels `pull`'s merge when checks pass. The owner, collaborators and
/// the pull's author may.
pub async fn cancel(ctx: &Ctx, url: &RepoUrl, pull: &str) -> Result<()> {
let t = Target::open(ctx, url).await?;
let pull = t.named(pull);
let pull = pull.as_str();
let me = session::me(ctx).await?;
let (_, item) = crate::queue::open_pull(ctx, &t, pull).await?;
let maintains = me.did == t.remote.did() || t.record.is_collaborator(&me.did);
if !maintains && me.did != item.author {
bail!(
"only the owner and collaborators of {url}, and the pull's author, cancel its merge when checks pass"
);
}
if item.auto_merge.as_ref().is_none_or(|a| a.state != SET) {
bail!("{pull} is not set to merge when its checks pass");
}
let value = record(&t, pull, CANCELLED, None);
t.write(
ctx,
nsid::AUTO_MERGE,
records::typed(nsid::AUTO_MERGE, &value),
)
.await?;
Ok(())
}
/// Whether `updates` to `t` from a runner the repository names, which may
/// not otherwise push, are the merge of one open pull set to merge when its
/// checks pass, as [`forge::merge`] makes it: its branch moved forward,
/// without force, by one commit on the branch's tip, with that head as a
/// second parent or none, holding the tree the two make merged. Its set must stand at that head. `git` holds the pushed commits.
pub async fn lands(ctx: &Ctx, t: &Target, git: &Git, updates: &[Update]) -> Result<bool> {
let [moved] = updates else {
return Ok(false);
};
let (Some(to), false) = (&moved.src, moved.force) else {
return Ok(false);
};
let Some(from) = t.snap.find_ref(&moved.dst) else {
return Ok(false);
};
let from = from.value.target.clone().unwrap_or_default();
let branch = moved.dst.strip_prefix("refs/heads/").unwrap_or(&moved.dst);
let to = git.rev_parse(to).await?;
let parents = git
.text(&["rev-list", "--parents", "-n", "1", &to], None)
.await?;
let parents: Vec<&str> = parents.split(' ').skip(1).collect();
let tree = git.rev_parse(&format!("{to}^{{tree}}")).await?;
let items = forge::assemble(&t.record, t.remote.did(), &t.queue_entries(ctx).await?);
let set = items.iter().filter(|i| {
let into = i.base.as_deref() == Some(branch);
into && i.state == "open" && i.auto_merge.is_some()
});
for item in set {
let pull = item.head.as_ref().expect("a pull has a head");
let head = forge::head_of(ctx, git, pull).await?.ok();
let stands = |h: &&String| standing(&t.record, t.remote.did(), item, h).set();
if let Some(head) = head.as_ref().filter(stands) {
let since = forge::since_of(git, item, &from, head).await?;
let made = forge::merge_tree_from(git, since.as_deref(), &from, head).await?;
let shaped = parents == [from.as_str()] || parents == [from.as_str(), head.as_str()];
if shaped && made.as_ref() == Ok(&tree) {
return Ok(true);
}
}
}
Ok(false)
}
/// What `knotless pull show` says of `item`'s merge when checks pass, if
/// it was ever set: whether it stands at its head as it is now, and who set
/// it, or why it no longer does.
pub async fn said(ctx: &Ctx, url: &RepoUrl, item: &Item) -> Result<Option<String>> {
if item.state != "open" || item.auto_merge.is_none() {
return Ok(None);
}
let t = Target::open(ctx, url).await?;
let sides = forge::sides(ctx, &t, item).await?;
let named = async |did: &str| {
let handle = crate::identity::handle(ctx, did).await;
handle.unwrap_or(did.to_string())
};
let said = match standing(&t.record, t.remote.did(), item, &sides.head) {
Standing::Off => return Ok(None),
Standing::Set(set) => format!("merge when checks pass: set by {}", named(&set.by).await),
Standing::Pushed { set, by } => format!(
"merge when checks pass: cancelled, as {} pushed after {} set it",
named(&by).await,
named(&set.by).await
),
Standing::Retargeted(set) => format!(
"merge when checks pass: cancelled, as the pull moved off {}",
set.base.as_deref().unwrap_or_default()
),
};
Ok(Some(said))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::records::{PullHead, RepoRef};
use serde_json::json;
fn repo() -> RepoRecord {
serde_json::from_value(json!({
"name": "r", "key": "k", "createdAt": "2026-01-01T00:00:00Z",
"collaborators": [{"did": "did:c"}],
}))
.unwrap()
}
fn item(head_owner: &str, auto: Option<AutoMerge>) -> Item {
let head = PullHead {
repo: RepoRef::new(head_owner, "r", "k"),
branch: "b".into(),
};
let mut item: Item = serde_json::from_value(json!({
"uri": "at://did:a/dev.knotless.pull/p", "author": "did:a", "kind": "pull",
"title": "t", "body": "", "state": "open", "comments": 0,
"createdAt": "2026-01-01T00:00:00Z", "base": "main",
}))
.unwrap();
item.head = Some(head);
item.auto_merge = auto;
item
}
fn set(state: &str, base: &str) -> AutoMerge {
AutoMerge {
state: state.into(),
commit: Some("h1".into()),
base: Some(base.into()),
by: "did:o".into(),
at: "3aaaaaaaaaaaa".into(),
created_at: "2026-01-01T00:00:00Z".into(),
}
}
#[test]
fn stands_until_someone_who_may_not_merge_pushes() {
let r = repo();
let never = item("did:a", None);
assert_eq!(standing(&r, "did:o", &never, "h1"), Standing::Off);
let cancelled = item("did:a", Some(set(CANCELLED, "main")));
assert_eq!(standing(&r, "did:o", &cancelled, "h1"), Standing::Off);
// At the head it was set at, whoever's fork it is.
let theirs = item("did:a", Some(set(SET, "main")));
assert!(standing(&r, "did:o", &theirs, "h1").set());
// A stranger's push cancels it; the owner's or a collaborator's
// does not.
let pushed = standing(&r, "did:o", &theirs, "h2");
assert!(matches!(pushed, Standing::Pushed { ref by, .. } if by == "did:a"));
assert!(!pushed.set());
for owner in ["did:o", "did:c"] {
let ours = item(owner, Some(set(SET, "main")));
assert!(standing(&r, "did:o", &ours, "h2").set());
}
// Retargeted, it is cancelled whatever its head.
let moved = item("did:o", Some(set(SET, "release")));
assert!(matches!(
standing(&r, "did:o", &moved, "h1"),
Standing::Retargeted(_)
));
}
}
crates/knotless/src/cli.rs+36−1
@@ -296,6 +296,13 @@ pull
merges past them. Into a branch
protected with --queue, it queues the
pull instead, unless forced
pull merge <url> <at-uri> --auto [--undo]
set a pull to merge by itself once its
checks pass, on a branch with no merge
queue: a runner merges it then. A push
by anyone who may not merge it, or
retargeting it, cancels it; --undo
cancels it, as the pull's author may too
pull merge <url> <at-uri> --check whether it would merge now, and why
not, and what you may do to it
pull revert <url> <at-uri> [-m <reason>]
@@ -2013,6 +2020,9 @@ async fn command(
writeln!(out, "logs: {logs}")?;
}
}
if let Some(said) = crate::auto_merge::said(ctx, &url, i).await? {
writeln!(out, "{said}")?;
}
if let Some(body) = &t.item.body {
writeln!(out, "\n{body}")?;
}
@@ -2120,10 +2130,12 @@ async fn command(
let url = args.url()?;
let pull = args.next("<at-uri>")?;
let (mut keep, mut gate, mut queue) = (false, forge::Gate::default(), false);
let mut check = false;
let (mut check, mut auto, mut undo) = (false, false, false);
while let Some(flag) = args.rest.next() {
match flag.as_str() {
"--queue" => queue = true,
"--auto" => auto = true,
"--undo" => undo = true,
"--check" => check = true,
"--keep-branch" => keep = true,
"--force" => gate.force = true,
@@ -2134,6 +2146,29 @@ async fn command(
other => bail!("unexpected {other}"),
}
}
if undo && !auto {
bail!("--undo cancels --auto: `knotless pull merge {url} {pull} --auto --undo`");
}
if auto {
if queue || check || keep || gate.force || !gate.wait.is_zero() {
bail!(
"--auto takes none of --queue, --check, --keep-branch, --wait and --force"
);
}
if undo {
crate::auto_merge::cancel(ctx, &url, pull).await?;
writeln!(out, "cancelled: it merges only when someone merges it")?;
return Ok(());
}
let head = crate::auto_merge::set(ctx, &url, pull).await?;
let at = head.get(..12).unwrap_or(&head);
writeln!(out, "set to merge when its checks pass, at {at}")?;
let next = format!(
"A runner merges it once its checks pass at its head merged with its base. A push by anyone who may not merge it cancels it. `knotless pull show {url} {pull}` says where it stands."
);
writeln!(out, "{next}")?;
return Ok(());
}
if check {
if queue || keep || gate.force || !gate.wait.is_zero() {
bail!("--check takes none of --queue, --keep-branch, --wait and --force");
crates/knotless/src/forge.rs+64−11
@@ -30,9 +30,9 @@ use crate::picture;
use crate::push::{self, Update};
use crate::read;
use crate::records::{
self, AssignmentRecord, Attachment, BlobRef, BlockRecord, CommentRecord, Facet, FailedCheck,
HideRecord, ImageAttachment, IssueRecord, LabelRecord, LineRef, PullHead, PullRecord,
QueueRecord, RepoRecord, RepoRef, Reviewer, Role, StateRecord, nsid,
self, AssignmentRecord, Attachment, AutoMergeRecord, BlobRef, BlockRecord, CommentRecord,
Facet, FailedCheck, HideRecord, ImageAttachment, IssueRecord, LabelRecord, LineRef, PullHead,
PullRecord, QueueRecord, RepoRecord, RepoRef, Reviewer, Role, StateRecord, nsid,
};
use crate::remote::{self, Remote, Snapshot, Stored};
use crate::reviews::{self, Review};
@@ -77,7 +77,7 @@ pub fn is_about(value: &Value, did: &str, key: &str) -> bool {
/// The collections [`Target::fresh_entries`] reads, the newest so many
/// of each, each read given up after so long: older ones the appview has.
const FRESH: [&str; 2] = [nsid::QUEUE, nsid::STATE];
const FRESH: [&str; 3] = [nsid::QUEUE, nsid::STATE, nsid::AUTO_MERGE];
const FRESH_PAGE: usize = 25;
const FRESH_WAIT: Duration = Duration::from_secs(5);
@@ -160,6 +160,11 @@ pub struct Item {
/// ever put in it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue: Option<Queued>,
/// Whether a pull is set to merge by itself once its checks pass: the
/// latest `dev.knotless.autoMerge` record about it that counts
/// ([`crate::auto_merge`] says whether it still stands).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_merge: Option<AutoMerge>,
/// The labels it carries, by name, in the order the repository
/// defines them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -228,6 +233,27 @@ pub struct Queued {
pub created_at: String,
}
/// A pull set to merge by itself once its checks pass, or no longer: the
/// latest `dev.knotless.autoMerge` record about it from someone who may
/// merge it, or, cancelling it, from its author.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AutoMerge {
/// `set` or `cancelled`.
pub state: String,
/// The head it was set at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
/// The branch it targeted as it was set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
/// Who wrote it.
pub by: String,
/// Its record key, a TID.
pub at: String,
pub created_at: String,
}
/// An item and everything said about it, oldest first.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Thread {
@@ -402,7 +428,7 @@ pub fn allowed(
/// Whether `actor` is the owner or a collaborator, or the owner before the
/// repository moved here, for what they did then: `at` is the record's key.
fn maintains(repo: &RepoRecord, owner: &str, actor: &str, at: &str) -> bool {
pub(crate) fn maintains(repo: &RepoRecord, owner: &str, actor: &str, at: &str) -> bool {
let moved_in = micros(&repo.created_at);
let before = repo.transferred_from.as_ref().is_some_and(|f| {
f.did() == actor
@@ -425,11 +451,16 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item>
};
let pull: Option<PullRecord> = serde_json::from_value(e.value.clone()).ok();
let issue: Option<IssueRecord> = serde_json::from_value(e.value.clone()).ok();
let (title, body, created_at, attachments, facets) = match (&pull, &issue) {
(Some(p), _) => (&p.title, &p.body, &p.created_at, &p.attachments, &p.facets),
(None, Some(i)) => (&i.title, &i.body, &i.created_at, &i.attachments, &i.facets),
(None, None) => continue,
};
let of_pull = pull
.as_ref()
.map(|p| (&p.title, &p.body, &p.created_at, &p.attachments, &p.facets));
let of_issue = issue
.as_ref()
.map(|i| (&i.title, &i.body, &i.created_at, &i.attachments, &i.facets));
let fields = of_pull.or(of_issue);
// Neither a pull nor an issue: left out.
let Some(f) = fields else { continue };
let (title, body, created_at, attachments, facets) = f;
let facets = facets.clone();
let (title, body, created_at) = (title.clone(), body.clone(), created_at.clone());
let (pictures, images) = (pictures(attachments), images(attachments));
@@ -465,6 +496,7 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item>
created_at,
updated_at: e.value["updatedAt"].as_str().map(str::to_string),
queue: None,
auto_merge: None,
labels: Vec::new(),
pictures,
images,
@@ -477,6 +509,7 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item>
// States in the order they were made: record keys are TIDs.
let states: Vec<(&str, &Entry, StateRecord)> = in_order(entries, nsid::STATE);
let queues: Vec<(&str, &Entry, QueueRecord)> = in_order(entries, nsid::QUEUE);
let autos: Vec<(&str, &Entry, AutoMergeRecord)> = in_order(entries, nsid::AUTO_MERGE);
let labels: Vec<(&str, &Entry, LabelRecord)> = in_order(entries, nsid::LABEL);
let assignments: Vec<(&str, &Entry, AssignmentRecord)> = in_order(entries, nsid::ASSIGNMENT);
// Comments by what they are on, in the order they were made.
@@ -531,6 +564,20 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item>
});
}
}
// Set by those who may merge; cancelled by them, or its author.
for (at, e, a) in autos.iter().filter(|(_, _, a)| a.subject == item.uri) {
let cancels = a.state == records::auto_merge::CANCELLED && e.author == item.author;
if cancels || maintains(repo, owner, &e.author, at) {
item.auto_merge = Some(AutoMerge {
state: a.state.clone(),
commit: a.commit.clone(),
base: a.base.clone(),
by: e.author.clone(),
at: at.to_string(),
created_at: a.created_at.clone(),
});
}
}
let on = said
.get(item.uri.as_str())
.map(Vec::as_slice)
@@ -2718,7 +2765,7 @@ pub struct Sides {
pub head: String,
}
pub(crate) async fn sides(ctx: &Ctx, t: &Target, item: &Item) -> Result<Sides> {
pub async fn sides(ctx: &Ctx, t: &Target, item: &Item) -> Result<Sides> {
let (Some(base), Some(head)) = (&item.base, &item.head) else {
bail!("{} is an issue", item.uri);
};
@@ -3751,6 +3798,12 @@ mod tests {
}
}
#[test]
fn a_record_neither_pull_nor_issue_is_left_out() {
let unread = entry("did:a", nsid::PULL, "1", json!({"not": "a pull"}));
assert!(assemble(&RepoRecord::default(), "did:o", &[unread]).is_empty());
}
#[test]
fn merged_reverts_name_what_they_undid() {
let repo = RepoRecord::default();
crates/knotless/src/lib.rs+1−0
@@ -14,6 +14,7 @@ pub mod account;
pub mod approvals;
pub mod aturi;
pub mod auth;
pub mod auto_merge;
pub mod base32;
pub mod branch_update;
pub mod checks;
crates/knotless/src/queue.rs+6−2
@@ -151,7 +151,7 @@ fn record(t: &Target, pull: &str, state: &str, ejected: Option<Ejected>) -> Valu
/// The open pull `pull` on `t`, and what is said about it, with it as its
/// author's repo has it now, and the queue records as the merge queue
/// reads them ([`Target::fresh_entries`]): one just left is out.
async fn open_pull(ctx: &Ctx, t: &Target, pull: &str) -> Result<(Vec<Entry>, Item)> {
pub(crate) async fn open_pull(ctx: &Ctx, t: &Target, pull: &str) -> Result<(Vec<Entry>, Item)> {
let (around, fresh) = tokio::join!(t.around(ctx, pull), t.fresh_entries(ctx));
let entries = forge::freshened(around?.0, fresh);
let item = forge::thread(&t.record, t.remote.did(), &entries, pull)?.item;
@@ -1035,7 +1035,8 @@ pub async fn stack(git: &Git, base: &str, pulls: &[(Item, String)], me: &Session
/// Why the gateway refuses `updates` to `t` from a runner the repository
/// names that may not otherwise push, if it does: such a runner lands the
/// merge queue and nothing else. Its push moves one branch forward, without
/// merge queue, and merges pulls set to merge when their checks pass
/// ([`crate::auto_merge::lands`]), and nothing else. Its push moves one branch forward, without
/// force, along a line of commits each with one parent, each the tree a
/// pull queued into that branch makes squashed onto the one before, as
/// [`stack`] builds them, no pull twice; and it deletes only those pulls'
@@ -1046,6 +1047,9 @@ pub async fn landing_refusal(
git: &Git,
updates: &[Update],
) -> Result<Option<String>> {
if crate::auto_merge::lands(ctx, t, git, updates).await? {
return Ok(None);
}
let (moves, deletes): (Vec<&Update>, Vec<&Update>) =
updates.iter().partition(|u| u.src.is_some());
let ([moved], false) = (&moves[..], updates.iter().any(|u| u.force)) else {
crates/knotless/src/records.rs+28−2
@@ -25,6 +25,8 @@ pub mod nsid {
/// A pull put in its target branch's merge queue, taken out of it, or
/// turned away by the runner that lands it.
pub const QUEUE: &str = "dev.knotless.queue";
/// A pull set to merge by itself once its checks pass, or no longer.
pub const AUTO_MERGE: &str = "dev.knotless.autoMerge";
/// The labels one account puts on a pull or issue.
pub const LABEL: &str = "dev.knotless.label";
/// Whom one of a repository's maintainers assigns a pull or issue to,
@@ -33,8 +35,8 @@ pub mod nsid {
/// What people write about a repository, in their own accounts, and
/// what its owner hides of it and whom they keep out. A block names no
/// repository when it keeps someone out of all of them.
pub const FORGE: [&str; 9] = [
PULL, ISSUE, COMMENT, STATE, HIDE, BLOCK, QUEUE, LABEL, ASSIGNMENT,
pub const FORGE: [&str; 10] = [
PULL, ISSUE, COMMENT, STATE, HIDE, BLOCK, QUEUE, LABEL, ASSIGNMENT, AUTO_MERGE,
];
/// The email addresses an account's commits are authored as.
pub const EMAILS: &str = "dev.knotless.emails";
@@ -739,6 +741,30 @@ pub struct QueueRecord {
pub created_at: String,
}
/// A pull set to merge by itself once its checks pass, or that setting
/// cancelled (`lexicons/dev/knotless/autoMerge.json`).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AutoMergeRecord {
pub repo: RepoRef,
pub subject: String,
/// One of [`auto_merge`].
pub state: String,
/// The head it was set at, for `set`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
/// The branch the pull targeted as it was set, for `set`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
pub created_at: String,
}
/// The states of a [`AutoMergeRecord`].
pub mod auto_merge {
pub const SET: &str = "set";
pub const CANCELLED: &str = "cancelled";
}
/// A check of a merge queue's batch that did not pass, as the pull it
/// ejected names it.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
crates/knotless-mcp/src/lib.rs+20−2
@@ -1207,6 +1207,13 @@ pub struct MergePull {
/// to it, without merging.
#[serde(default)]
pub check: bool,
/// Set it to merge by itself once its checks pass, on a branch with no
/// merge queue, and return at once: a runner merges it then.
#[serde(default)]
pub auto: bool,
/// With auto, cancel that instead.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
@@ -2445,11 +2452,14 @@ impl Server {
"batch": w.and_then(|w| w.batch),
});
}
if let Some(said) = knotless::auto_merge::said(&self.ctx, &url, item).await? {
out["autoMerge"] = json!(said);
}
Ok(out)
}
#[tool(
description = "A pull request or issue with its comments and reviews, oldest first. Each comment has its author's DID and handle, and `facets`, the accounts its body mentions, by where. A comment on a pull's lines has `line` ({path, side, line, startLine}), the `commit` they are in, and the `review` it was sent with: a review's own comment, the one with its verdict or body, names itself there. A comment in a thread on a pull's lines, the first or an answer, has `resolved`, true once the thread is (other comments have none); an answer has `reply`, the comment starting it. A review that its reviewer's later one stands in place of has `superseded`, and a comment that dismissed a review names it as `dismisses`. `item.assignees` are those the owner or a collaborator assigns it to ({did, handle}). A pull's `item.reviewers` are those asked to review it, by its author or by the owner or a collaborator ({did, handle, pending}: pending while the pull is open and they have not reviewed it, false once it is merged or closed), and `item.reviews` where each reviewer stands: by (DID), handle, verdict, the commit it looked at, uri, `outdated` once the open pull has moved past that commit, `maintainer` for the owner or a collaborator, `dismissed`, the comment that dismissed it (pull_dismiss_review), when one did, and `uncounted`, why an approval does not count toward what its target branch's rule asks (only the owner's and collaborators' count; with dismiss_stale, only those of its head as it is now). For a pull, `compare` says what it brings in: the base and head commits, the commits the base lacks, and whether it merges (merged, fast-forward, clean or conflict, with the conflicting paths); for a merged one, what its merge brought in, from where the base was before it to the merge, though its branch is gone. A merged pull that a merged revert undid names it as `item.revertedBy`, and the revert names the pull as `item.reverts`. An open pull in its branch's merge queue, or ejected from it, has `standing`: its place, the batch it is in while that is checked or landing, and `said`, where it stands in words."
description = "A pull request or issue with its comments and reviews, oldest first. Each comment has its author's DID and handle, and `facets`, the accounts its body mentions, by where. A comment on a pull's lines has `line` ({path, side, line, startLine}), the `commit` they are in, and the `review` it was sent with: a review's own comment, the one with its verdict or body, names itself there. A comment in a thread on a pull's lines, the first or an answer, has `resolved`, true once the thread is (other comments have none); an answer has `reply`, the comment starting it. A review that its reviewer's later one stands in place of has `superseded`, and a comment that dismissed a review names it as `dismisses`. `item.assignees` are those the owner or a collaborator assigns it to ({did, handle}). A pull's `item.reviewers` are those asked to review it, by its author or by the owner or a collaborator ({did, handle, pending}: pending while the pull is open and they have not reviewed it, false once it is merged or closed), and `item.reviews` where each reviewer stands: by (DID), handle, verdict, the commit it looked at, uri, `outdated` once the open pull has moved past that commit, `maintainer` for the owner or a collaborator, `dismissed`, the comment that dismissed it (pull_dismiss_review), when one did, and `uncounted`, why an approval does not count toward what its target branch's rule asks (only the owner's and collaborators' count; with dismiss_stale, only those of its head as it is now). For a pull, `compare` says what it brings in: the base and head commits, the commits the base lacks, and whether it merges (merged, fast-forward, clean or conflict, with the conflicting paths); for a merged one, what its merge brought in, from where the base was before it to the merge, though its branch is gone. A merged pull that a merged revert undid names it as `item.revertedBy`, and the revert names the pull as `item.reverts`. An open pull in its branch's merge queue, or ejected from it, has `standing`: its place, the batch it is in while that is checked or landing, and `said`, where it stands in words. An open pull ever set to merge when its checks pass (pull_merge with auto) has `autoMerge`, in words: set and by whom, or cancelled and why, as by a push from someone who may not merge it."
)]
async fn pull_show(
&self,
@@ -2889,7 +2899,7 @@ impl Server {
}
#[tool(
description = "Merge an open pull request: a merge commit naming the pull, even where a fast-forward could have been; into a branch protected as linear, one squashed commit. One whose head is already on the branch, pushed there by hand, is marked merged as it stands. Fails on conflicts. In a repository that names runners, only once every workflow that runs on the pull has passed at its head merged with the target branch as it is now: it refuses, naming what failed, what is still running, or the older head or base its checks are of, and run_list with pull and log shows why. With wait, it waits up to that many seconds for checks running or not yet run, and the owner's merge asks the runners once to run a head and base none has; force, the owner's alone, merges whatever they say. Into a branch protected with queue (branch_rules), it queues the pull instead, as queue does, and answers queued with why, unless the owner forces it. Its head branch, when in this repository, is deleted with it unless keep_branch or another open pull names it. The owner and collaborators may. Into the repository's default branch, it closes each open issue on it that the pull's title or body names right after fixes, closes or resolves (or fix, fixed, close, closed, resolve, resolved), by its page URL (https://<appview>/<owner>/<repo>/issue/<did>/<rkey>) or at:// URI, returning them as closed; the merge queue does the same as it lands. With queue, it puts the pull in its branch's merge queue instead and returns at once with its place (queued: position of of), and, queued again at the very head the queue last ejected, why it was (queued.ejectedBefore), as it will be again unless that was not the pull's doing: the quickest way to land, as a runner checks queued pulls together a batch at a time, and there is no need to wait. Queue a pull once it builds, though: one that does not fails its whole batch, and every pull in it waits while the queue halves the batch to find it. Merge the target branch into the pull's branch just before queueing: one that conflicts with it is refused. Ejected, read why with pull_show and the failed checks' logs with run_list (pull and log), fix it, and queue it again; queue the same head again only when the log shows the failure is not the pull's, such as a test it does not touch that fails other pulls too. One that conflicts only with a pull ahead of it waits for that pull to land or leave, and is ejected then if it conflicts with the branch: merge the branch into it. Ejected again for the same reason, stop and say so rather than queue it once more. queue_list shows where it is, and pull_show whether it merged, or where it stands (standing.said: Queued, 3rd of 5, running its checks, ready for the next batch or waiting for its review, from a gate its branch names; In a batch of 4 being checked; Landing; or why it was ejected and which checks failed, also under item.queue.reason and item.queue.checks). Into a branch protected with reviews, it refuses until the pull is reviewed as the rule asks (see branch_protect), naming what is missing. With check, it merges nothing and says whether it would now: state clean, conflicting (conflicts, the paths) or blocked by its checks (checks: passed, failed, running, pending, or none when the repository names no runners) or its reviews (reviews: passed, blocked, or none when its branch asks for none; reviewsMissing, what they lack, as reviews 1 of 2, owners of web/: @alice or @bob, or a review of its head from @bot; reviewsUncounted, the approvals that do not count and why; codeOwnersWarnings, the CODEOWNERS lines skipped and why); reason, why not, in the words a merge would refuse with, and that you may not merge it when you may not; method (merge or squash); deletes, the branch it would delete; and may, what your part in the repository lets you do to it (merge, force, queue, base and head as pull_retarget changes them, rerun): not whether it would work now, which state and reason say. A blocked pull's merge is refused, the owner's too, unless the owner forces it. queueOnly, when its branch takes pulls only through the merge queue. Merged, it answers the commit its branch is at now (sha), how (method: merge or squash), the branch it deleted (deleted) or why it kept the head branch (kept: asked to, in another repository such as a fork, the branch it merges into or the default branch, or named by another open pull), and the issues it closed."
description = "Merge an open pull request: a merge commit naming the pull, even where a fast-forward could have been; into a branch protected as linear, one squashed commit. One whose head is already on the branch, pushed there by hand, is marked merged as it stands. Fails on conflicts. In a repository that names runners, only once every workflow that runs on the pull has passed at its head merged with the target branch as it is now: it refuses, naming what failed, what is still running, or the older head or base its checks are of, and run_list with pull and log shows why. With wait, it waits up to that many seconds for checks running or not yet run, and the owner's merge asks the runners once to run a head and base none has; force, the owner's alone, merges whatever they say. Into a branch protected with queue (branch_rules), it queues the pull instead, as queue does, and answers queued with why, unless the owner forces it. Its head branch, when in this repository, is deleted with it unless keep_branch or another open pull names it. The owner and collaborators may. Into the repository's default branch, it closes each open issue on it that the pull's title or body names right after fixes, closes or resolves (or fix, fixed, close, closed, resolve, resolved), by its page URL (https://<appview>/<owner>/<repo>/issue/<did>/<rkey>) or at:// URI, returning them as closed; the merge queue does the same as it lands. With queue, it puts the pull in its branch's merge queue instead and returns at once with its place (queued: position of of), and, queued again at the very head the queue last ejected, why it was (queued.ejectedBefore), as it will be again unless that was not the pull's doing: the quickest way to land, as a runner checks queued pulls together a batch at a time, and there is no need to wait. Queue a pull once it builds, though: one that does not fails its whole batch, and every pull in it waits while the queue halves the batch to find it. Merge the target branch into the pull's branch just before queueing: one that conflicts with it is refused. Ejected, read why with pull_show and the failed checks' logs with run_list (pull and log), fix it, and queue it again; queue the same head again only when the log shows the failure is not the pull's, such as a test it does not touch that fails other pulls too. One that conflicts only with a pull ahead of it waits for that pull to land or leave, and is ejected then if it conflicts with the branch: merge the branch into it. Ejected again for the same reason, stop and say so rather than queue it once more. queue_list shows where it is, and pull_show whether it merged, or where it stands (standing.said: Queued, 3rd of 5, running its checks, ready for the next batch or waiting for its review, from a gate its branch names; In a batch of 4 being checked; Landing; or why it was ejected and which checks failed, also under item.queue.reason and item.queue.checks). Into a branch protected with reviews, it refuses until the pull is reviewed as the rule asks (see branch_protect), naming what is missing. With check, it merges nothing and says whether it would now: state clean, conflicting (conflicts, the paths) or blocked by its checks (checks: passed, failed, running, pending, or none when the repository names no runners) or its reviews (reviews: passed, blocked, or none when its branch asks for none; reviewsMissing, what they lack, as reviews 1 of 2, owners of web/: @alice or @bob, or a review of its head from @bot; reviewsUncounted, the approvals that do not count and why; codeOwnersWarnings, the CODEOWNERS lines skipped and why); reason, why not, in the words a merge would refuse with, and that you may not merge it when you may not; method (merge or squash); deletes, the branch it would delete; and may, what your part in the repository lets you do to it (merge, force, queue, base and head as pull_retarget changes them, rerun): not whether it would work now, which state and reason say. A blocked pull's merge is refused, the owner's too, unless the owner forces it. queueOnly, when its branch takes pulls only through the merge queue. Merged, it answers the commit its branch is at now (sha), how (method: merge or squash), the branch it deleted (deleted) or why it kept the head branch (kept: asked to, in another repository such as a fork, the branch it merges into or the default branch, or named by another open pull), and the issues it closed. With auto, on a branch with no merge queue, it sets the pull to merge by itself once its checks pass at its head merged with the branch, reviewed as the branch asks, and returns at once (autoMerge: set, commit: the head it was set at): the runner that lands the repository's merge queues merges it then, keeping its branch. It stays set while a check fails or it conflicts, until a push fixes that; a push by anyone who may not merge it, or retargeting it, cancels it. pull_show says where it stands (autoMerge). With auto and undo, it cancels that (autoMerge: cancelled): the owner, collaborators and the pull's author may. undo without auto is ignored."
)]
async fn pull_merge(
&self,
@@ -2898,6 +2908,14 @@ impl Server {
answer(
async {
let url = repo_url(&p.repo)?;
if p.auto && p.undo {
knotless::auto_merge::cancel(&self.ctx, &url, &p.uri).await?;
return Ok(json!({"autoMerge": "cancelled"}));
}
if p.auto {
let head = knotless::auto_merge::set(&self.ctx, &url, &p.uri).await?;
return Ok(json!({"autoMerge": "set", "commit": head}));
}
if p.check {
let me = knotless::session::me(&self.ctx).await?;
let mut found = landing::mergeable(&self.ctx, &url, &p.uri, &me.did).await?;
crates/knotless-mcp/tests/mcp.rs+12−0
@@ -1572,6 +1572,18 @@ fn agents_fork_review_and_merge() {
err && text.contains("does not use the merge queue"),
"{text}"
);
// Set to merge when its checks pass it says so, until cancelled.
let auto = json!({"repo": repo, "uri": aside, "auto": true});
let set = o.json("pull_merge", auto.clone());
assert_eq!(set["autoMerge"], "set", "{set}");
// Once the index has the record.
wait_until(|| {
let shown = o.json("pull_show", json!({"repo": repo, "uri": aside}));
let said = shown["autoMerge"].as_str().unwrap_or_default();
said.starts_with("merge when checks pass: set by")
});
let undo = json!({"repo": repo, "uri": aside, "auto": true, "undo": true});
assert_eq!(o.json("pull_merge", undo)["autoMerge"], "cancelled");
let on = json!({"repo": repo, "branch": "main", "merges": true, "queue": true});
o.json("branch_protect", on);
// Asked first, it would merge: it has no workflows to wait for. Its
crates/knotless-run/src/queue.rs+33−0
@@ -49,6 +49,7 @@ use crate::{
};
use anyhow::{Context, Result, bail};
use knotless::approvals;
use knotless::auto_merge;
use knotless::checks::{self, Filter};
use knotless::forge::{self, Item, Target};
use knotless::git::Git;
@@ -294,6 +295,7 @@ impl Runner {
let me = session::me(&self.ctx).await?;
let entries = target.queue_entries(&self.ctx).await?;
let items = forge::assemble(&target.record, watch.remote.did(), &entries);
self.auto_merges(watch, &target, &items).await;
let lines = queue::by_branch(&items);
let prefix = format!("{}\n", watch.id());
let mut branches: BTreeSet<String> = lines.keys().cloned().collect();
@@ -322,6 +324,37 @@ impl Runner {
Ok(())
}
/// Merges each open pull of `items` set to merge when its checks pass
/// whose set still stands, once it would merge, as [`forge::merge`]
/// does: its checks passed at its head merged with its base as it is
/// now, reviewed as its branch asks, with no conflicts. One that would
/// not yet stays set, and is tried again on the next pass. Its branch
/// is kept: the gateway takes from a runner that may not push only the
/// merge ([`queue::landing_refusal`]).
async fn auto_merges(&self, watch: &Watch, target: &Target, items: &[Item]) {
let set = |i: &&Item| i.state == "open" && i.auto_merge.is_some();
for item in items.iter().filter(set) {
let tried = self.auto_merge(watch, target, item).await;
if let Some(not_yet) = tried.err().map(|e| format!("{e:#}")) {
telemetry::auto_merged(&watch.label, &item.uri, Some(&not_yet));
}
}
}
/// Merges `item`, if its merge when checks pass stands and it would merge.
async fn auto_merge(&self, watch: &Watch, target: &Target, item: &Item) -> Result<()> {
let sides = forge::sides(&self.ctx, target, item).await?;
let owner = watch.remote.did();
if !auto_merge::standing(&target.record, owner, item, &sides.head).set() {
return Ok(());
}
watch.at("merging a pull set to merge when its checks pass");
let url = &watch.remote.url;
forge::merge(&self.ctx, url, &item.uri, true, forge::Gate::default()).await?;
telemetry::auto_merged(&watch.label, &item.uri, None);
Ok(())
}
/// Lands, as the runner stops, the batches of `watch` whose runs have
/// all passed, and does nothing else: no batch is started or ejected,
/// and what failed is checked again on the next start.
crates/knotless-run/src/telemetry.rs+15−0
crates/knotless-run/tests/run.rs+36−2
crates/knotless-serve/src/gateway.rs+1−1
crates/knotless-serve/src/spaces.rs+2−1
crates/knotless-serve/tests/forge.rs+104−0
crates/knotless-serve/tests/gateway.rs+31−11
docs/design/collaboration.md+31−0
docs/design/gateway.md+9−1
docs/design/security.md+7−4
docs/reference/cli.md+3−1
docs/reference/mcp.md+6−0
harness/quarantine.json+18−2
lexicons/dev/knotless/autoMerge.jsonnew+38−0
lexicons/dev/knotless/space.json+1−0