chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A pull's merge box reads what the server holds, not every PDS again
crates/knotless/src/approvals.rs+3−3
| @@ -14,7 +14,7 @@ | ||
| ||
use crate::codeowners::{self, CodeOwners}; | ||
use crate::env::Ctx; | ||
use crate::forge::{Item, Target}; | ||
use crate::forge::{Facts, Item}; | ||
use crate::git::Git; | ||
use crate::identity; | ||
use crate::records::Reviews; | ||
| @@ -173,7 +173,7 @@ pub fn stand( | ||
/// none, when it asks nothing of them. | ||
pub async fn of( | ||
ctx: &Ctx, | ||
t: &Target, | ||
t: Facts<'_>, | ||
item: &Item, | ||
git: &Git, | ||
base: &str, | ||
| @@ -190,7 +190,7 @@ pub async fn of( | ||
), | ||
false => (None, Vec::new()), | ||
}; | ||
let owner = t.remote.did(); | ||
let owner = t.owner; | ||
let writer = |did: &str| did == owner || t.record.is_collaborator(did); | ||
let mut standing = stand( | ||
rule, | ||
crates/knotless/src/forge.rs+46−14
| @@ -667,6 +667,19 @@ async fn previous( | ||
Ok(agreed.then_some(from)) | ||
} | ||
| ||
/// A repository as what is decided about it goes by: where it is, its | ||
/// owner and key, its record, and whether it is private, however these | ||
/// were read: from its PDS as a [`Target`] opens it, or as a server | ||
/// holds them. | ||
#[derive(Clone, Copy)] | ||
pub struct Facts<'a> { | ||
pub url: &'a RepoUrl, | ||
pub owner: &'a str, | ||
pub key: &'a str, | ||
pub record: &'a RepoRecord, | ||
pub private: bool, | ||
} | ||
| ||
/// A repository, opened, with its record. | ||
pub struct Target { | ||
pub url: RepoUrl, | ||
| @@ -704,6 +717,17 @@ impl Target { | ||
}) | ||
} | ||
| ||
/// What is decided about it goes by, as it was opened. | ||
pub fn facts(&self) -> Facts<'_> { | ||
Facts { | ||
url: &self.url, | ||
owner: self.remote.did(), | ||
key: self.snap.key().expect("an opened repository has a key"), | ||
record: &self.record, | ||
private: self.remote.space.is_some(), | ||
} | ||
} | ||
| ||
/// The repository as forge records name it, under the name it has now. | ||
pub fn reference(&self) -> RepoRef { | ||
self.snap | ||
| @@ -1839,7 +1863,7 @@ pub async fn label( | ||
} | ||
let (mine, me) = t.mine(ctx).await?; | ||
let owner = t.remote.did(); | ||
crate::org::needs_public(ctx, &t, &me, "labelling").await?; | ||
crate::org::needs_public(ctx, t.facts(), &me, "labelling").await?; | ||
if let Some(label) = defined | ||
.iter() | ||
.find(|l| !t.record.may_label(owner, &me.did, l)) | ||
| @@ -1990,7 +2014,7 @@ pub async fn merge( | ||
} | ||
let sides = sides(ctx, &t, &item).await?; | ||
let (git, base, head) = (&sides.git, &sides.base, &sides.head); | ||
crate::org::needs_public(ctx, &t, &me, "merging").await?; | ||
crate::org::needs_public(ctx, t.facts(), &me, "merging").await?; | ||
let branch = sides.refname.trim_start_matches("refs/heads/"); | ||
let squash = t.record.rule(branch).is_some_and(records::Rule::linear); | ||
let fast = git.is_ancestor(base, head).await?; | ||
| @@ -2004,7 +2028,7 @@ pub async fn merge( | ||
} | ||
let reviewed = match gate.force { | ||
true => None, | ||
false => approvals::of(ctx, &t, &item, git, base, head).await?, | ||
false => approvals::of(ctx, t.facts(), &item, git, base, head).await?, | ||
}; | ||
if let Some(why) = reviewed.and_then(|s| approvals::refusal(target, pull, branch, &s)) { | ||
bail!("{why}"); | ||
| @@ -2029,7 +2053,7 @@ pub async fn merge( | ||
force: false, | ||
}]; | ||
let others = assemble(&t.record, t.remote.did(), &entries); | ||
let deleted = (!keep).then(|| spent(&t, &item, &others)).flatten(); | ||
let deleted = (!keep).then(|| spent(t.facts(), &item, &others)).flatten(); | ||
let kept = kept(&t, &item, &others, keep).filter(|_| deleted.is_none()); | ||
if let Some(gone) = &deleted { | ||
updates.push(Update { | ||
| @@ -2173,9 +2197,9 @@ pub(crate) fn refusal( | ||
/// The head branch of `item`, once merged, when nothing needs it any more: | ||
/// it is in the target itself, is neither the target branch nor the | ||
/// default one, and no other open pull brings it in or targets it. | ||
pub(crate) fn spent(t: &Target, item: &Item, items: &[Item]) -> Option<String> { | ||
pub(crate) fn spent(t: Facts, item: &Item, items: &[Item]) -> Option<String> { | ||
let head = item.head.as_ref()?; | ||
let here = t.reference().key; | ||
let here = t.key; | ||
let mine = head_here(t, item).is_some(); | ||
let default = t.record.default_branch.as_deref().unwrap_or("main"); | ||
let named = |other: &Item| { | ||
| @@ -2203,7 +2227,13 @@ pub(crate) fn kept(t: &Target, item: &Item, items: &[Item], keep: bool) -> Optio | ||
}; | ||
let named = items.iter().find(other).map(|o| o.uri.as_str()); | ||
let into = item.base.as_deref() == Some(branch) || branch == default; | ||
why_kept(head, keep, head_here(t, item).is_some(), into, named) | ||
why_kept( | ||
head, | ||
keep, | ||
head_here(t.facts(), item).is_some(), | ||
into, | ||
named, | ||
) | ||
} | ||
| ||
/// Why a merge keeps `head`: it was asked to, `keep`; it is in another | ||
| @@ -2229,19 +2259,21 @@ fn why_kept( | ||
} | ||
| ||
/// The branch `item`'s head is on, when it is in `t` itself. | ||
pub(crate) fn head_here<'a>(t: &Target, item: &'a Item) -> Option<&'a str> { | ||
pub(crate) fn head_here<'a>(t: Facts, item: &'a Item) -> Option<&'a str> { | ||
let head = item.head.as_ref()?; | ||
let owner = parse_uri(&head.repo.uri).ok()?.0; | ||
(head.repo.key == t.reference().key && owner == t.remote.did()).then_some(&head.branch) | ||
(head.repo.key == t.key && owner == t.owner).then_some(&head.branch) | ||
} | ||
| ||
/// A pull's two sides, in the target's mirror, with the head fetched from | ||
/// wherever it is. | ||
pub(crate) struct Sides { | ||
pub(crate) git: Git, | ||
pub(crate) refname: String, | ||
pub(crate) base: String, | ||
pub(crate) head: String, | ||
pub struct Sides { | ||
pub git: Git, | ||
/// The branch it merges into, in full: `refs/heads/main`. | ||
pub refname: String, | ||
/// The commits its base and its head are at. | ||
pub base: String, | ||
pub head: String, | ||
} | ||
| ||
pub(crate) async fn sides(ctx: &Ctx, t: &Target, item: &Item) -> Result<Sides> { | ||
crates/knotless/src/landing.rs+85−49
| @@ -7,7 +7,7 @@ | ||
use crate::approvals; | ||
use crate::checks::{self, Standing}; | ||
use crate::env::Ctx; | ||
use crate::forge::{self, Item, Target}; | ||
use crate::forge::{self, Facts, Item, Sides, Target}; | ||
use crate::git::Git; | ||
use crate::mirror::Mirror; | ||
use crate::records::Rule; | ||
| @@ -107,16 +107,16 @@ pub struct May { | ||
} | ||
| ||
/// What `who` may do to `item` on `t`. | ||
pub fn may(t: &Target, item: &Item, who: &str) -> May { | ||
let owner = t.remote.did(); | ||
pub fn may(t: Facts, item: &Item, who: &str) -> May { | ||
let owner = t.owner; | ||
let at = crate::keys::tid(); | ||
let maintainer = forge::allowed(&t.record, owner, &item.author, who, "merged", &at); | ||
let maintainer = forge::allowed(t.record, owner, &item.author, who, "merged", &at); | ||
let runners = !t.record.runners.is_empty(); | ||
May { | ||
merge: maintainer, | ||
force: who == owner, | ||
queue: maintainer && runners, | ||
base: forge::allowed(&t.record, owner, &item.author, who, "closed", &at), | ||
base: forge::allowed(t.record, owner, &item.author, who, "closed", &at), | ||
head: who == item.author, | ||
rerun: who == owner && runners, | ||
} | ||
| @@ -129,29 +129,30 @@ pub fn may(t: &Target, item: &Item, who: &str) -> May { | ||
pub async fn mergeable(ctx: &Ctx, target: &RepoUrl, pull: &str, who: &str) -> Result<Mergeable> { | ||
let t = Target::open(ctx, target).await?; | ||
let entries = t.reviewed(ctx, pull, who).await?; | ||
let item = forge::thread(&t.record, t.remote.did(), &entries, pull)?.item; | ||
let items = forge::assemble(&t.record, t.remote.did(), &entries); | ||
let item = forge::thread_in(&t.record, t.remote.did(), &entries, &items, pull)?.item; | ||
if item.state != "open" { | ||
bail!("{pull} is {}", item.state); | ||
} | ||
let mut found = gauged(ctx, &t, &entries, &item, who).await?; | ||
if item.draft { | ||
found.state = "draft"; | ||
found.reason = Some(forge::drafted(target, pull)); | ||
} | ||
if !found.may.merge { | ||
let not = not_theirs(ctx, &t, who).await; | ||
found.reason = Some(match found.reason { | ||
Some(why) => format!("{why}; and {not}"), | ||
None => not, | ||
}); | ||
} | ||
Ok(found) | ||
let sides = forge::sides(ctx, &t, &item).await?; | ||
let gauged = gauge(ctx, t.facts(), &items, &item, &sides, who).await?; | ||
let checks = match gauged.workflows.is_empty() { | ||
true => Vec::new(), | ||
false => { | ||
let filter = checks::Filter { | ||
pull: Some(pull), | ||
..Default::default() | ||
}; | ||
checks::list(ctx, &t.url, filter, false).await? | ||
} | ||
}; | ||
Ok(decide(ctx, t.facts(), &item, &sides, gauged, &checks, who).await) | ||
} | ||
| ||
/// Why `who` may not merge on `t`: a hidden member of the organization it | ||
/// is, that merging takes a public membership, in the words a merge would | ||
/// refuse with; anyone else, that only its owner and collaborators do. | ||
async fn not_theirs(ctx: &Ctx, t: &Target, who: &str) -> String { | ||
async fn not_theirs(ctx: &Ctx, t: Facts<'_>, who: &str) -> String { | ||
let me = crate::session::me(ctx) | ||
.await | ||
.ok() | ||
| @@ -161,21 +162,30 @@ async fn not_theirs(ctx: &Ctx, t: &Target, who: &str) -> String { | ||
{ | ||
return e.to_string(); | ||
} | ||
let target = &t.url; | ||
let target = t.url; | ||
format!("you may not merge it: only the owner and collaborators of {target} merge its pulls") | ||
} | ||
| ||
/// Whether the open pull `item` on `t` could merge now, were it ready, as | ||
/// [`mergeable`] says, with `entries`, what is said about it. | ||
async fn gauged( | ||
/// Where an open pull stands before its checks are read, as [`gauge`] | ||
/// finds it, and the workflows whose checks a merge of it waits for: none | ||
/// when it waits for none, as when it conflicts. | ||
pub struct Gauged { | ||
found: Mergeable, | ||
pub workflows: Vec<String>, | ||
} | ||
| ||
/// Whether the open pull `item` on `t`, among its repository's `items`, | ||
/// could merge now at `sides`, were it ready, as [`mergeable`] says: all | ||
/// but its checks, which [`decide`] reads. | ||
pub async fn gauge( | ||
ctx: &Ctx, | ||
t: &Target, | ||
entries: &[forge::Entry], | ||
t: Facts<'_>, | ||
items: &[Item], | ||
item: &Item, | ||
sides: &Sides, | ||
who: &str, | ||
) -> Result<Mergeable> { | ||
) -> Result<Gauged> { | ||
let pull = item.uri.as_str(); | ||
let sides = forge::sides(ctx, t, item).await?; | ||
let (git, base, head) = (&sides.git, &sides.base, &sides.head); | ||
let branch = sides.refname.trim_start_matches("refs/heads/"); | ||
let fast = git.is_ancestor(base, head).await?; | ||
| @@ -184,8 +194,6 @@ async fn gauged( | ||
// has not moved since the head branched off it. | ||
let methods = [["merge", "fastForward"], ["squash", "squash"]]; | ||
let method = methods[usize::from(squash)][usize::from(fast)]; | ||
let others = forge::assemble(&t.record, t.remote.did(), entries); | ||
let deletes = forge::spent(t, item, &others); | ||
let mut found = Mergeable { | ||
state: "clean", | ||
reason: None, | ||
| @@ -196,27 +204,31 @@ async fn gauged( | ||
reviews_missing: Vec::new(), | ||
reviews_uncounted: Vec::new(), | ||
code_owners_warnings: Vec::new(), | ||
deletes, | ||
deletes: forge::spent(t, item, items), | ||
base: base.clone(), | ||
head: head.clone(), | ||
may: may(t, item, who), | ||
queue_blocked: None, | ||
queue_only: t.record.queue_only(branch), | ||
}; | ||
let none = |found| Gauged { | ||
found, | ||
workflows: Vec::new(), | ||
}; | ||
let tree = match forge::merge_tree(git, base, head).await? { | ||
Ok(tree) => tree, | ||
Err(files) => { | ||
found.state = "conflicting"; | ||
found.reason = Some(format!("merging conflicts in {}", files.join(", "))); | ||
found.conflicts = files.into_iter().map(|path| Conflict { path }).collect(); | ||
return Ok(found); | ||
return Ok(none(found)); | ||
} | ||
}; | ||
let notes = | ||
|all: Vec<String>| -> Vec<Note> { all.into_iter().map(|text| Note { text }).collect() }; | ||
if let Some(s) = approvals::of(ctx, t, item, git, base, head).await? { | ||
found.reviews = if s.passed() { "passed" } else { "blocked" }; | ||
found.reason = approvals::refusal(&t.url, pull, branch, &s); | ||
found.reason = approvals::refusal(t.url, pull, branch, &s); | ||
found.reviews_missing = notes(s.missing()); | ||
found.reviews_uncounted = notes(s.uncounted()); | ||
found.code_owners_warnings = notes(s.warnings); | ||
| @@ -225,30 +237,54 @@ async fn gauged( | ||
found.state = "blocked"; | ||
} | ||
if t.record.runners.is_empty() { | ||
return Ok(found); | ||
return Ok(none(found)); | ||
} | ||
let at = match fast { | ||
true => head.clone(), | ||
false => forge::checked_merge(git, &tree, base, head).await?, | ||
}; | ||
let workflows = workflow::on_pull(git, &at, &sides.refname).await?; | ||
if workflows.is_empty() { | ||
return Ok(found); | ||
Ok(Gauged { found, workflows }) | ||
} | ||
| ||
/// What [`gauge`] found of the open pull `item` on `t`, with where `checks`, | ||
/// the pull's own newest first, leave it, and who may merge it: as | ||
/// [`mergeable`] says. | ||
pub async fn decide( | ||
ctx: &Ctx, | ||
t: Facts<'_>, | ||
item: &Item, | ||
sides: &Sides, | ||
gauged: Gauged, | ||
checks: &[checks::Check], | ||
who: &str, | ||
) -> Mergeable { | ||
let Gauged { | ||
mut found, | ||
workflows, | ||
} = gauged; | ||
if !workflows.is_empty() { | ||
let standing = checks::standing(&workflows, checks, &sides.head, &sides.base); | ||
found.checks = standing.name(); | ||
// A merge refuses for its reviews before it reads its checks. | ||
let checked = forge::refusal(t.url, &item.uri, sides, &standing, checks); | ||
found.reason = found.reason.or(checked); | ||
if standing != Standing::Passed { | ||
found.state = "blocked"; | ||
} | ||
} | ||
let filter = checks::Filter { | ||
pull: Some(pull), | ||
..Default::default() | ||
}; | ||
let checks = checks::list(ctx, &t.url, filter, false).await?; | ||
let standing = checks::standing(&workflows, &checks, head, base); | ||
found.checks = standing.name(); | ||
// A merge refuses for its reviews before it reads its checks. | ||
let checked = forge::refusal(&t.url, pull, &sides, &standing, &checks); | ||
found.reason = found.reason.or(checked); | ||
if standing != Standing::Passed { | ||
found.state = "blocked"; | ||
if item.draft { | ||
found.state = "draft"; | ||
found.reason = Some(forge::drafted(t.url, &item.uri)); | ||
} | ||
if !found.may.merge { | ||
let not = not_theirs(ctx, t, who).await; | ||
found.reason = Some(match found.reason { | ||
Some(why) => format!("{why}; and {not}"), | ||
None => not, | ||
}); | ||
} | ||
Ok(found) | ||
found | ||
} | ||
| ||
/// Why a gateway refuses `update`, `pusher`'s move of a branch of `t`, to | ||
crates/knotless/src/org.rs+4−4
| @@ -31,7 +31,7 @@ | ||
| ||
use crate::auth; | ||
use crate::env::Ctx; | ||
use crate::forge::Target; | ||
use crate::forge::Facts; | ||
use crate::gateway; | ||
use crate::identity::{self, Identity}; | ||
use crate::manage::{Created, NewRepo}; | ||
| @@ -457,12 +457,12 @@ fn pusher(did: String, shared: bool) -> OrgMemberRecord { | ||
/// there take a public membership, as those reading it cannot see a hidden | ||
/// one, and would pass over what they did. An appview that does not answer | ||
/// refuses nothing. | ||
pub async fn needs_public(ctx: &Ctx, t: &Target, me: &Session, what: &str) -> Result<()> { | ||
pub async fn needs_public(ctx: &Ctx, t: Facts<'_>, me: &Session, what: &str) -> Result<()> { | ||
let did = me.did.as_str(); | ||
if t.remote.space.is_some() || did == t.remote.did() || t.record.is_collaborator(did) { | ||
if t.private || did == t.owner || t.record.is_collaborator(did) { | ||
return Ok(()); | ||
} | ||
let roster = asked(ctx, t.remote.did(), Some(me)).await.ok(); | ||
let roster = asked(ctx, t.owner, Some(me)).await.ok(); | ||
let hidden = |r: &&Roster| r.entry(did).is_some_and(|e| e.standing == Standing::Hidden); | ||
if let Some(roster) = roster.as_ref().filter(hidden) { | ||
let name = &roster.name; | ||
crates/knotless/src/queue.rs+4−4
| @@ -77,7 +77,7 @@ fn place(items: &[Item], pull: &str) -> Option<Place> { | ||
/// collaborator, who may merge them. | ||
async fn maintainer(ctx: &Ctx, t: &Target) -> Result<Session> { | ||
let me = session::me(ctx).await?; | ||
crate::org::needs_public(ctx, t, &me, "queueing pulls").await?; | ||
crate::org::needs_public(ctx, t.facts(), &me, "queueing pulls").await?; | ||
if me.did != t.remote.did() && !t.record.is_collaborator(&me.did) { | ||
bail!( | ||
"only the owner and collaborators of {} put pulls in its merge queue, as they are who may merge them", | ||
| @@ -187,7 +187,7 @@ async fn admit(ctx: &Ctx, t: &Target, item: &Item) -> Result<()> { | ||
files.join(", ") | ||
); | ||
} | ||
let reviewed = approvals::of(ctx, t, item, &sides.git, base, head).await?; | ||
let reviewed = approvals::of(ctx, t.facts(), item, &sides.git, base, head).await?; | ||
if let Some(why) = reviewed.and_then(|s| approvals::refusal(&t.url, &item.uri, into, &s)) { | ||
bail!("{why}. Queue it again once it is"); | ||
} | ||
| @@ -723,7 +723,7 @@ pub async fn landing_refusal( | ||
} | ||
let spent: Vec<&str> = landed | ||
.iter() | ||
.filter_map(|i| forge::head_here(t, i)) | ||
.filter_map(|i| forge::head_here(t.facts(), i)) | ||
.collect(); | ||
for gone in deletes { | ||
let name = gone.dst.strip_prefix("refs/heads/").unwrap_or(&gone.dst); | ||
| @@ -766,7 +766,7 @@ pub async fn land(ctx: &Ctx, url: &RepoUrl, git: &Git, branch: &str, stack: &Sta | ||
let mut gone: Vec<String> = stack | ||
.stacked | ||
.iter() | ||
.filter_map(|s| forge::spent(&t, &s.pull, &items)) | ||
.filter_map(|s| forge::spent(t.facts(), &s.pull, &items)) | ||
.collect(); | ||
gone.sort(); | ||
gone.dedup(); | ||
crates/knotless-run/src/queue.rs+2−1
| @@ -620,7 +620,8 @@ impl Runner { | ||
reviewed.push(item); | ||
continue; | ||
}; | ||
let standing = approvals::of(&self.ctx, on.target, &item, on.git, tip, &sha).await?; | ||
let standing = | ||
approvals::of(&self.ctx, on.target.facts(), &item, on.git, tip, &sha).await?; | ||
let url = &on.watch.remote.url; | ||
let why = standing.and_then(|s| approvals::refusal(url, &item.uri, on.branch, &s)); | ||
let eject = why | ||
crates/knotless-serve/src/web/landing.rs+78−2
| @@ -13,6 +13,7 @@ use super::*; | ||
use crate::viewer::Viewer; | ||
use anyhow::Context; | ||
use axum::Extension; | ||
use knotless::checks::Check; | ||
use knotless::env::Ctx; | ||
use knotless::landing; | ||
use knotless::manage; | ||
| @@ -73,14 +74,89 @@ pub async fn get_merge_status( | ||
.0 | ||
.ok_or(Shown(StatusCode::UNAUTHORIZED, why.into()))?; | ||
let repo = found(&app, &p.repo).await?; | ||
let found = landing::mergeable(&repo.reader, &url_of(&repo), &p.pull, &did).await; | ||
let mut found = found.map_err(AppError::upstream)?; | ||
let mut found = mergeable(&app, &repo, &p.pull, &did).await?; | ||
if found.may.queue && undelegated(&app, &repo) { | ||
found.queue_blocked = Some("undelegated"); | ||
} | ||
Ok(answer(found)) | ||
} | ||
| ||
/// Whether the open pull `pull` on `repo` can merge now, as | ||
/// [`landing::mergeable`] decides it, read from what this server holds | ||
/// rather than from the PDSes again: the repository as its mirror last | ||
/// synced, the pull, its reviews and its checks as the index has them. | ||
async fn mergeable(app: &App, repo: &Repo, pull: &str, who: &str) -> Result<landing::Mergeable> { | ||
let mut made = assembled(app, repo).await?; | ||
if !made.items.iter().any(|i| i.uri == pull) { | ||
// Opened a moment ago, perhaps, before the index heard of it. | ||
super::threads::reindex(app, repo, pull).await?; | ||
made = assembled(app, repo).await?; | ||
} | ||
// As the mirror last synced, which a write from the pages syncs again. | ||
let record = repo.synced.clone(); | ||
let (entries, items) = (&made.entries, &made.items); | ||
let thread = forge::thread_in(&record, &repo.did, entries, items, pull); | ||
let item = thread.map_err(not_found)?.item; | ||
let (Some(base), Some(head), "open") = (&item.base, &item.head, item.state.as_str()) else { | ||
let why = format!("{pull} is {}", item.state); | ||
return Err(Shown(StatusCode::BAD_REQUEST, why)); | ||
}; | ||
let (git, base_sha, head_sha, _) = super::pull_range(app, repo, &item, base, head).await?; | ||
let sides = forge::Sides { | ||
git, | ||
refname: format!("refs/heads/{base}"), | ||
base: base_sha, | ||
head: head_sha, | ||
}; | ||
let url = url_of(repo); | ||
let facts = forge::Facts { | ||
url: &url, | ||
owner: &repo.did, | ||
key: &repo.key, | ||
record: &record, | ||
private: repo.in_space.is_some(), | ||
}; | ||
let ctx = &repo.reader; | ||
let gauged = landing::gauge(ctx, facts, items, &item, &sides, who).await; | ||
let gauged = gauged.map_err(AppError::upstream)?; | ||
let checks = match gauged.workflows.is_empty() { | ||
true => Vec::new(), | ||
false => checks_of(app, repo, &record, pull)?, | ||
}; | ||
Ok(landing::decide(ctx, facts, &item, &sides, gauged, &checks, who).await) | ||
} | ||
| ||
/// The checks `record`'s runners ran of `pull` on `repo`, newest first, as | ||
/// the index its pulls were read from has them: as many as a merge goes by, those of its head now | ||
/// and of the heads just before it. | ||
fn checks_of(app: &App, repo: &Repo, record: &RepoRecord, pull: &str) -> Result<Vec<Check>> { | ||
let index = super::indexed(app, repo).expect("its pulls were read from an index"); | ||
let runners: Vec<String> = record.runners.iter().map(|r| r.did.clone()).collect(); | ||
let filter = CheckFilter { | ||
pull: Some(pull), | ||
..CheckFilter::default() | ||
}; | ||
let found = index.checks(&repo.did, &repo.key, &runners, filter, CHECKS_READ, None); | ||
let (entries, _) = found | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let checks = entries.into_iter().filter_map(|e| { | ||
Some(Check { | ||
record: serde_json::from_value(e.value).ok()?, | ||
uri: e.uri, | ||
runner: e.author, | ||
log_text: None, | ||
space: repo.in_space.clone(), | ||
}) | ||
}); | ||
Ok(checks.collect()) | ||
} | ||
| ||
/// How many of a pull's checks, newest first, its merge status reads: a | ||
/// push runs one of each of its workflows, so these cover its head and | ||
/// the pushes before it many times over. | ||
const CHECKS_READ: usize = 200; | ||
| ||
/// Whether the merge queue of `repo` lands through this server's gateway, | ||
/// as no runner it names may push and it records no other gateway, while | ||
/// its owner has not delegated writes here: then it lands nothing. | ||
crates/knotless-serve/tests/forge.rs+3−0
| @@ -733,6 +733,9 @@ fn merges_wait_for_their_checks() { | ||
assert!(merged, "{err}"); | ||
assert_eq!(out, format!("{head}\ndeleted x")); | ||
}); | ||
// Merged, it can no longer say whether it merges. | ||
let (checked, _, err) = owner.knotless(&["merge", &url, &pull, "--check"], b""); | ||
assert!(!checked && err.contains("is merged"), "{err}"); | ||
| ||
// The base moved: what is checked is the merge of the two. | ||
// A workflow that does not load is waited for too, as its runner files | ||
crates/knotless-serve/tests/gateway.rs+32−1
| @@ -1783,6 +1783,9 @@ fn landing_pulls_from_the_pages() { | ||
// Only someone asks whether it can merge. It names no runners, so | ||
// nothing waits for its checks: it fast-forwards, and takes x along. | ||
assert_eq!(status(None).0, 401); | ||
// One there is none of, read again from its author's repo, is none. | ||
let nothing = format!("repo={repo}&pull={pull}x"); | ||
assert_eq!(ask("getMergeStatus", ¬hing, me).0, 404); | ||
let (code, _, said) = status(me); | ||
assert_eq!(code, 200, "{said}"); | ||
let may = json!({"merge": true, "force": true, "queue": false, "base": true, "head": true, "rerun": false}); | ||
| @@ -1866,6 +1869,28 @@ fn landing_pulls_from_the_pages() { | ||
); | ||
assert_eq!(write("requestRerun", them, again).0, 400); | ||
| ||
// Its runner files a check that passed at its head on main, which the | ||
// box reads from the index once it hears of it. | ||
let key = owner.record("dev.knotless.repo", "landed")["key"].clone(); | ||
let base = owner.git(&work, &["rev-parse", "main"]); | ||
let key_str = key.as_str().unwrap(); | ||
let rkey = knotless::keys::check_rkey( | ||
&owner.account.did, | ||
key_str, | ||
&pull, | ||
&head, | ||
Some(base.as_str()), | ||
"ci", | ||
); | ||
let check = json!({ | ||
"$type": "dev.knotless.check", "repo": {"uri": repo, "key": key}, "ref": "refs/heads/x", | ||
"pull": pull, "commit": head, "baseCommit": base, "workflow": "ci", "status": "success", | ||
"startedAt": "2026-10-31T00:00:00.000Z", | ||
}); | ||
ci.put_record("dev.knotless.check", &rkey, check); | ||
wait_until(|| status(me).2["checks"] == "passed"); | ||
assert_eq!(status(me).2["state"], "clean"); | ||
| ||
// Its runner may not push, so lands the queue through this server, | ||
// which does not write for its owner yet: queueing it would land | ||
// nothing, and is refused, until they delegate. | ||
| @@ -1959,7 +1984,13 @@ fn landing_pulls_from_the_pages() { | ||
code == 400 && again["message"].as_str().unwrap().contains("is merged"), | ||
"{again}" | ||
); | ||
assert_eq!(status(me).0, 502); | ||
// Nor can it say whether a merged pull merges. | ||
let (code, _, said) = status(me); | ||
assert_eq!(code, 400, "{said}"); | ||
assert!( | ||
said["message"].as_str().unwrap().ends_with("is merged"), | ||
"{said}" | ||
); | ||
// The pull it undoes names it once the revert merges. | ||
assert!(as_me.get("revertedBy").is_none(), "{as_me}"); | ||
let landing = json!({"repo": repo, "pull": uri}); | ||
docs/design/serve.md+9−7
| @@ -749,13 +749,15 @@ batch, which its `merged` state names as `batch`. Then the box asks the server, | ||
and only for someone logged in, whether the pull can merge now | ||
(`dev.knotless.getMergeStatus`), and says whether it conflicts with its | ||
base: the files that do, each linked to its place in the pull's diff, | ||
and the commands that bring the base into the branch to resolve them. The server answers through | ||
the same code as `knotless merge --check`, which reads what a merge reads: | ||
the pull from its author's repository, both sides in the server's mirror, | ||
and the pull's checks at its head merged with its base as it is now. So | ||
the box says what a merge would, without a merge's writes. | ||
It is asked only of someone logged in because it is costly, a mirror's | ||
sync and a merge of the two sides, and only they can act on it. It reads | ||
and the commands that bring the base into the branch to resolve them. The server decides through | ||
the same code as `knotless merge --check`, from what it holds rather | ||
than what a merge reads afresh: the repository as its mirror last synced, | ||
both sides in its mirrors, and the pull, its reviews and its checks as its | ||
index has them. So the box says what a merge would, without a merge's | ||
writes, and in a tenth of the time; a review or a check written a moment | ||
ago shows once the index hears of it, while a merge reads them at once. | ||
It is asked only of someone logged in, as it merges the two sides, and | ||
only they can act on it. It reads | ||
the repository as the pages read it for that viewer: a public one as the | ||
server, a private one as the member asking, so it works for a private | ||
repository's members. A pull into a repository whose runners have no | ||