chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A pull's page shows the stack it is part of
crates/knotless/src/forge.rs+112−1
| @@ -125,6 +125,9 @@ pub struct Item { | ||
/// pull's head: where this one's own changes start ([`since_of`]). | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub since: Option<String>, | ||
/// That pull: the one it was moved off, as it landed. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub below: Option<String>, | ||
/// For a pull the merge queue landed, the tip of its batch: the | ||
/// commit whose checks let it land. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
| @@ -441,6 +444,7 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item> | ||
merged: None, | ||
merged_from: None, | ||
since: None, | ||
below: None, | ||
merged_batch: None, | ||
closed_by: None, | ||
reverts, | ||
| @@ -498,6 +502,7 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item> | ||
if let (Some(base), Some(_)) = (&s.base, &item.base) { | ||
item.base = Some(base.clone()); | ||
item.since = s.since.clone(); | ||
item.below = s.pull.clone(); | ||
} | ||
} | ||
} | ||
| @@ -2563,7 +2568,7 @@ pub(crate) fn moved_on( | ||
batch: None, | ||
base: item.base.clone(), | ||
since: Some(head.to_string()), | ||
pull: None, | ||
pull: Some(item.uri.clone()), | ||
created_at: records::now(), | ||
}; | ||
let write = |o: &Item| { | ||
| @@ -2573,6 +2578,45 @@ pub(crate) fn moved_on( | ||
items.iter().filter(above).map(write).collect() | ||
} | ||
| ||
/// The stack the pull `uri` is part of on the repository `key` of `owner`, | ||
/// highest first: the pull it was moved off, or the open pull whose branch | ||
/// it targets, then the same of that, down; and up, whichever pull does so | ||
/// to it, the oldest if several. Empty when it stands alone. | ||
pub fn stack_of<'a>(owner: &str, key: &str, items: &'a [Item], uri: &str) -> Vec<&'a Item> { | ||
let Some(start) = items.iter().find(|i| i.uri == uri) else { | ||
return Vec::new(); | ||
}; | ||
let branch = |i: &'a Item| { | ||
let head = i.head.as_ref()?; | ||
let here = parse_uri(&head.repo.uri).ok()?.0 == owner && head.repo.key == key; | ||
here.then_some(head.branch.as_str()) | ||
}; | ||
let on = |upper: &Item, lower: &'a Item| { | ||
let held = branch(lower).filter(|_| lower.state == "open"); | ||
let targets = held.is_some() && upper.base.as_deref() == held; | ||
upper.state != "closed" && (upper.below.as_deref() == Some(&lower.uri) || targets) | ||
}; | ||
let mut chain = vec![start]; | ||
while let Some(lower) = items | ||
.iter() | ||
.find(|i| on(chain[chain.len() - 1], i) && !chain.iter().any(|c| c.uri == i.uri)) | ||
{ | ||
chain.push(lower); | ||
} | ||
// Items come newest first, so from the end is the oldest. | ||
while let Some(upper) = items | ||
.iter() | ||
.rev() | ||
.find(|i| on(i, chain[0]) && !chain.iter().any(|c| c.uri == i.uri)) | ||
{ | ||
chain.insert(0, upper); | ||
} | ||
match chain.len() { | ||
1 => Vec::new(), | ||
_ => chain, | ||
} | ||
} | ||
| ||
/// Where `item`'s own changes start, when it was moved onto its base as | ||
/// the pull below it landed: that pull's head, while its branch still | ||
/// holds it and has not taken in the base since. Then, or once rebased, | ||
| @@ -3559,6 +3603,73 @@ mod tests { | ||
json!({"uri": "at://did:o/dev.knotless.repo/r", "key": "k"}) | ||
} | ||
| ||
#[test] | ||
fn stacks_follow_branches_and_moves() { | ||
let pull = |rkey: &str, base: &str, head: &str| { | ||
let head = json!({"repo": r(), "branch": head}); | ||
let value = | ||
json!({"repo": r(), "base": base, "head": head, "title": rkey, "createdAt": rkey}); | ||
entry("did:a", nsid::PULL, rkey, value) | ||
}; | ||
let uri = |rkey: &str| format!("at://did:a/dev.knotless.pull/{rkey}"); | ||
let state = |rkey: &str, on: &str, more: Value| { | ||
let mut value = json!({"repo": r(), "subject": uri(on), "createdAt": "t"}); | ||
value | ||
.as_object_mut() | ||
.unwrap() | ||
.extend(more.as_object().unwrap().clone()); | ||
entry("did:o", nsid::STATE, rkey, value) | ||
}; | ||
let repo = RepoRecord::default(); | ||
let of = |entries: &[Entry], rkey: &str| -> Vec<String> { | ||
let items = assemble(&repo, "did:o", entries); | ||
let stack = stack_of("did:o", "k", &items, &uri(rkey)); | ||
stack.iter().map(|i| i.title.clone()).collect() | ||
}; | ||
let chain = vec![ | ||
pull("1", "main", "low"), | ||
pull("2", "low", "mid"), | ||
pull("3", "mid", "top"), | ||
pull("4", "mid", "beside"), | ||
pull("5", "main", "alone"), | ||
]; | ||
// The chain through it, whichever it is asked of; the oldest above. | ||
assert_eq!(of(&chain, "2"), ["3", "2", "1"]); | ||
assert_eq!(of(&chain, "1"), ["3", "2", "1"]); | ||
assert_eq!(of(&chain, "4"), ["4", "2", "1"]); | ||
assert!(of(&chain, "5").is_empty()); | ||
assert!(of(&chain, "nothing").is_empty()); | ||
// Moved off a pull that landed, it keeps that pull below it. | ||
let mut landed = chain.clone(); | ||
landed.push(state("6", "1", json!({"state": "merged", "commit": "c"}))); | ||
landed.push(state( | ||
"7", | ||
"2", | ||
json!({"base": "main", "since": "s", "pull": uri("1")}), | ||
)); | ||
assert_eq!(of(&landed, "2"), ["3", "2", "1"]); | ||
// Retargeted by hand, or closed, it is out of the stack. | ||
let mut apart = landed.clone(); | ||
apart.push(state("8", "2", json!({"base": "main"}))); | ||
assert_eq!(of(&apart, "3"), ["3", "2"]); | ||
let mut closed = chain.clone(); | ||
closed.push(state("9", "1", json!({"state": "closed"}))); | ||
assert_eq!(of(&closed, "1"), Vec::<String>::new()); | ||
// Branches that target each other end, rather than go round. | ||
let round = vec![pull("1", "b", "a"), pull("2", "a", "b")]; | ||
assert_eq!(of(&round, "1"), ["1", "2"]); | ||
// A head elsewhere is no branch here. | ||
let fork = json!({"uri": "at://did:b/dev.knotless.repo/r", "key": "k"}); | ||
let head = json!({"repo": fork, "branch": "low"}); | ||
let there = | ||
json!({"repo": r(), "base": "main", "head": head, "title": "1", "createdAt": "1"}); | ||
let away = vec![ | ||
entry("did:b", nsid::PULL, "1", there), | ||
pull("2", "low", "mid"), | ||
]; | ||
assert!(of(&away, "2").is_empty()); | ||
} | ||
| ||
fn entry(author: &str, collection: &str, rkey: &str, value: Value) -> Entry { | ||
Entry { | ||
uri: format!("at://{author}/{collection}/{rkey}"), | ||
crates/knotless-serve/src/web/items.rs+50−0
| @@ -135,6 +135,51 @@ struct MentionView { | ||
closes: bool, | ||
} | ||
| ||
/// `dev.knotless.defs#stackView`. | ||
#[derive(Serialize)] | ||
struct StackView { | ||
pulls: Vec<StackPullView>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
into: Option<String>, | ||
} | ||
| ||
/// `dev.knotless.defs#stackPullView`. | ||
#[derive(Serialize)] | ||
struct StackPullView { | ||
item: ItemView, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
branch: Option<String>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
merged: Option<String>, | ||
} | ||
| ||
/// The stack `uri` is part of among `items`, from what is already read. | ||
async fn stack_view( | ||
app: &App, | ||
repo: &Repo, | ||
record: &RepoRecord, | ||
items: &[Item], | ||
uri: &str, | ||
) -> Option<StackView> { | ||
let chain = forge::stack_of(&repo.did, &repo.key, items, uri); | ||
let lowest = chain.last()?; | ||
let row = |i: &'_ Item| { | ||
let (branch, merged) = (i.head.as_ref().map(|h| h.branch.clone()), i.merged.clone()); | ||
let i = i.clone(); | ||
async move { | ||
let item = item_view(app, record, &i).await; | ||
StackPullView { | ||
item, | ||
branch, | ||
merged, | ||
} | ||
} | ||
}; | ||
let into = lowest.base.clone(); | ||
let pulls = futures::future::join_all(chain.iter().map(|i| row(i))).await; | ||
Some(StackView { pulls, into }) | ||
} | ||
| ||
/// `dev.knotless.getThread`'s answer: a pull or an issue, and everything | ||
/// said on it. | ||
#[derive(Serialize)] | ||
| @@ -171,6 +216,9 @@ pub struct ThreadView { | ||
/// The revert whose merge undid a merged pull. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
reverted_by: Option<ItemViewBasic>, | ||
/// The chain of pulls a pull is part of, if any. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
stack: Option<StackView>, | ||
/// Where an open pull stands in its branch's merge queue, if it was | ||
/// queued or ejected from it. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
| @@ -338,6 +386,7 @@ pub async fn get_thread( | ||
} | ||
None => (None, Vec::new(), Vec::new()), | ||
}; | ||
let stack = stack_view(&app, &repo, &record, all, &item.uri).await; | ||
let base = repo.href(""); | ||
let shown_repo = repo.uri(); | ||
let named = named_items(&repo, all); | ||
| @@ -445,6 +494,7 @@ pub async fn get_thread( | ||
closed_by, | ||
mentioned_by, | ||
reverted_by, | ||
stack, | ||
queue, | ||
checks, | ||
minutes, | ||
crates/knotless-serve/tests/forge.rs+22−0
| @@ -465,6 +465,28 @@ fn forks_pulls_reviews_and_merges() { | ||
assert!(merged.ends_with("\ndeleted low"), "{merged}"); | ||
let diff = ok(&owner, &["pull", "diff", &url, &mid]); | ||
assert!(diff.contains("b/mid") && !diff.contains("b/low"), "{diff}"); | ||
// Its page shows the stack, highest first, with what has landed. | ||
let (who, name) = url | ||
.trim_start_matches("knotless://") | ||
.split_once('/') | ||
.unwrap(); | ||
let repo = format!("at://{who}/dev.knotless.repo/{name}"); | ||
let stack = || -> Value { | ||
let at = reqwest::Url::parse_with_params( | ||
&format!("{}/xrpc/dev.knotless.getThread", av.base), | ||
[("repo", repo.as_str()), ("uri", mid.as_str())], | ||
) | ||
.unwrap(); | ||
let thread: Value = reqwest::blocking::get(at).unwrap().json().unwrap(); | ||
thread["stack"].clone() | ||
}; | ||
wait_until(|| stack()["pulls"][2]["merged"].is_string()); | ||
let shown = stack(); | ||
assert_eq!(shown["into"], "trunk", "{shown}"); | ||
let titles: Vec<&str> = (0..3) | ||
.map(|i| shown["pulls"][i]["item"]["title"].as_str().unwrap()) | ||
.collect(); | ||
assert_eq!(titles, ["top", "mid", "low"], "{shown}"); | ||
// The base taken into its branch: the base is where it starts again. | ||
owner.git(&work, &["checkout", "-q", "mid"]); | ||
owner.git(&work, &["fetch", "-q", &url, "trunk"]); | ||
docs/design/collaboration.md+6−0
| @@ -822,6 +822,12 @@ its diff, commits and merge start. It stops counting once the branch is | ||
rebased or takes in its base, as the pull's own history then says where it | ||
starts. A merge asked to keep its branch moves nothing. | ||
| ||
These pulls make a stack. A pull's stack is followed down through the open | ||
pull whose branch it targets, or the pull it was moved off, which the moving | ||
record names in `pull`, and up the same way. Where several pulls target one | ||
branch, the oldest is the one followed. A retarget by hand takes a pull out. | ||
The pull's page shows the chain, and its files page the pulls either side. | ||
| ||
## Forks | ||
| ||
A fork is a repository in the forker's account whose record names its | ||
docs/reference/api.md+1−1
| @@ -575,7 +575,7 @@ name it has now. | ||
| `dev.knotless.listBranches?repo=&limit=&cursor=&commits=` | the branches, the default first, 100 a page by default; with `commits`, each with its tip and that commit's newest checks | | ||
| `dev.knotless.listTags?repo=&limit=&cursor=&commits=` | the tags that name a commit, the newest commit first, 100 a page by default; with `commits`, each with its commit and that commit's newest checks | | ||
| `dev.knotless.listItems?repo=&kind=&state=&q=&limit=&cursor=` | the open or closed pulls or issues that the filter `q` matches ([collaboration.md](../design/collaboration.md#filtering-pulls-and-issues)), `review-requested:@me` and `assignee:@me` the asker, each with `draft` for a draft pull, the `reviewers` asked, its base's required reviewers among them and named again in `requiredReviewers`, and the `assignees`, 50 a page by default, how many of each, and the repository's labels and the items' authors, for a filter to name | | ||
| `dev.knotless.getThread?repo=&uri=&filesOnly=&firstRows=` | a pull or an issue and everything said on it, each comment with its `uri`, Markdown `body` and, for a review, the `commit` it looked at; on a pull's lines, its `line` (`path`, `side`, `line`, `startLine`, the `lines` it names as they were, at most 20, and `outdated` once that side of the file has changed), the `review` it went with and `resolved`; a dismissal's `dismisses`, the review it dismissed; an answer's `reply`, the comment starting its thread; for a pull, `reviews`, where each reviewer stands (`outdated` once the open pull has moved past the head it looked at, `maintainer` for the owner or a collaborator, `gate` for an account the rule protecting its base waits for a review from, `dismissed` once the owner or a collaborator dismissed it), and in `changes` the `head` it is at; for a merged pull, `revertedBy` (`{uri, title}`), the revert whose merge undid it; for an issue, `mentionedBy`, the pulls on the repository whose title or body names it, oldest first, each its `item` and `closes` where it names it after a closing keyword; in each `html`, a bare `at://` URI of a pull or issue on the repository, or a link to one, as a link to its page, a bare one showing its title; for a pull of a public repository, the minutes each runner counts for its owner (`dev.knotless.minutes`); with an index, `reactions` on the item and on each comment, by content in GitHub's order, each naming the asker's own `reaction` when it has an asker; and with an asker, what it may do (`viewer`: `setState`, `hide`, `revert` for a merged pull, `assign`, `dismiss` for a pull, and the `labels` it may set); with `filesOnly`, a pull's changed files without their rows, and with `firstRows` too, the rows of the first files, up to 10 files or 1500 lines, passing over any that changes more than 400 | | ||
| `dev.knotless.getThread?repo=&uri=&filesOnly=&firstRows=` | a pull or an issue and everything said on it, with `stack`, the chain of pulls a pull is part of, highest first, and the branch it lands on (`into`), each comment with its `uri`, Markdown `body` and, for a review, the `commit` it looked at; on a pull's lines, its `line` (`path`, `side`, `line`, `startLine`, the `lines` it names as they were, at most 20, and `outdated` once that side of the file has changed), the `review` it went with and `resolved`; a dismissal's `dismisses`, the review it dismissed; an answer's `reply`, the comment starting its thread; for a pull, `reviews`, where each reviewer stands (`outdated` once the open pull has moved past the head it looked at, `maintainer` for the owner or a collaborator, `gate` for an account the rule protecting its base waits for a review from, `dismissed` once the owner or a collaborator dismissed it), and in `changes` the `head` it is at; for a merged pull, `revertedBy` (`{uri, title}`), the revert whose merge undid it; for an issue, `mentionedBy`, the pulls on the repository whose title or body names it, oldest first, each its `item` and `closes` where it names it after a closing keyword; in each `html`, a bare `at://` URI of a pull or issue on the repository, or a link to one, as a link to its page, a bare one showing its title; for a pull of a public repository, the minutes each runner counts for its owner (`dev.knotless.minutes`); with an index, `reactions` on the item and on each comment, by content in GitHub's order, each naming the asker's own `reaction` when it has an asker; and with an asker, what it may do (`viewer`: `setState`, `hide`, `revert` for a merged pull, `assign`, `dismiss` for a pull, and the `labels` it may set); with `filesOnly`, a pull's changed files without their rows, and with `firstRows` too, the rows of the first files, up to 10 files or 1500 lines, passing over any that changes more than 400 | | ||
| `dev.knotless.getPullDiff?repo=&uri=&paths=` | the files of a pull's diff that `paths` names (repeated, 1 to 50), rows and all: what the pull page fetches as the reader comes to each file | | ||
| `dev.knotless.getTemplate?repo=&kind=&base=` | the template a new pull (at `base`) or issue is asked to follow, `.knotless/<kind>.md`, as `knotless pull template` reads it; `{}` with none, 400 for a `kind` that is neither | | ||
| `dev.knotless.getCompare?repo=&base=&head=&branch=` | the repositories a pull can come from (`heads`: this one, then its forks), and with `branch` what it brings in from `head` (this repository if not given) into `base` (the default branch if not given), as a pull's `changes`: 404 for a branch on neither | | ||
lexicons/dev/knotless/defs.json+19−0
| @@ -633,6 +633,25 @@ | ||
"preflightSeconds": { "type": "integer", "minimum": 0, "description": "A pull's pre-flights at a head, as long as the longest of them that passed: the median, or a batch's with none to go by." } | ||
} | ||
}, | ||
"stackView": { | ||
"type": "object", | ||
"description": "The chain of pulls a pull is part of, each built on the one below it.", | ||
"required": ["pulls"], | ||
"properties": { | ||
"pulls": { "type": "array", "description": "Highest first: the lowest, which lands first, is last.", "items": { "type": "ref", "ref": "#stackPullView" } }, | ||
"into": { "type": "string", "description": "The branch the lowest pull targets." } | ||
} | ||
}, | ||
"stackPullView": { | ||
"type": "object", | ||
"description": "One pull of a stack.", | ||
"required": ["item"], | ||
"properties": { | ||
"item": { "type": "ref", "ref": "#itemView" }, | ||
"branch": { "type": "string", "description": "Its head branch." }, | ||
"merged": { "type": "string", "description": "The commit it landed as." } | ||
} | ||
}, | ||
"changesView": { | ||
"type": "object", | ||
"description": "What a pull brings in: for a merged one, what its merge brought into the base; otherwise what its branch has since it left the base.", | ||
lexicons/dev/knotless/getThread.json+1−0
| @@ -61,6 +61,7 @@ | ||
"description": "For a pull, the minutes each runner of the target counts for the target's owner, where it has counted any; none for a private repository.", | ||
"items": { "type": "ref", "ref": "dev.knotless.defs#minutesView" } | ||
}, | ||
"stack": { "type": "ref", "ref": "dev.knotless.defs#stackView", "description": "For a pull built on another or under one, the whole chain. Absent when it stands alone." }, | ||
"changes": { "type": "ref", "ref": "dev.knotless.defs#changesView", "description": "What a pull brings in; absent for an issue, or a pull whose branch is gone." }, | ||
"card": { "type": "string", "description": "The path of the picture its page unfurls into." }, | ||
"reactions": { | ||
lexicons/dev/knotless/state.json+1−1
| @@ -19,7 +19,7 @@ | ||
"batch": { "type": "string", "minLength": 40, "maxLength": 64, "description": "For `merged` by a merge queue, the hex object ID of the tip of the batch it landed in: the commit whose checks let it land. The pulls merged with the same `batch` were checked and landed together." }, | ||
"base": { "type": "string", "maxLength": 255, "description": "For a pull, the branch it targets from here on, without refs/heads/: set when the branch it targeted was renamed, or went as the pull with that head branch landed." }, | ||
"since": { "type": "string", "minLength": 40, "maxLength": 64, "description": "Beside `base`, for a pull moved there as the pull it was built on landed: that pull's head, the hex object ID its own changes start from." }, | ||
"pull": { "type": "string", "format": "at-uri", "description": "For `closed` on an issue, the dev.knotless.pull whose merge closed it, as its title or body named the issue after a closing keyword such as `Fixes`." }, | ||
"pull": { "type": "string", "format": "at-uri", "description": "For `closed` on an issue, the dev.knotless.pull whose merge closed it, as its title or body named the issue after a closing keyword such as `Fixes`. Beside `since`, the pull a pull was moved off as it landed." }, | ||
"createdAt": { "type": "string", "format": "datetime" } | ||
} | ||
} | ||
web/src/lib/organisms/MergeBox.svelte+8−3
| @@ -1,5 +1,5 @@ | ||
<script lang="ts"> | ||
import type { ActorView, CheckView, MinutesView, PullHeadView, QueueStateView, RepoViewDetailed, ReviewView } from '#lib/lexicons/dev/knotless/defs.ts'; | ||
import type { ActorView, CheckView, MinutesView, PullHeadView, QueueStateView, RepoViewDetailed, ReviewView, StackView } from '#lib/lexicons/dev/knotless/defs.ts'; | ||
import type * as editItem from '#lib/lexicons/dev/knotless/editItem.ts'; | ||
import type * as getMergeStatus from '#lib/lexicons/dev/knotless/getMergeStatus.ts'; | ||
import type * as leaveQueue from '#lib/lexicons/dev/knotless/leaveQueue.ts'; | ||
| @@ -11,12 +11,13 @@ | ||
import type { Snippet } from 'svelte'; | ||
import { invalidateAll } from '$app/navigation'; | ||
import { ask, call, type Answer } from '#lib/data.ts'; | ||
import { landsAt, landsIn } from '#lib/format.ts'; | ||
import { landsAt, landsIn, repoHref } from '#lib/format.ts'; | ||
import { zone } from '#lib/zone.svelte.ts'; | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
import Ref from '#lib/atoms/Ref.svelte'; | ||
import Command from '#lib/molecules/Command.svelte'; | ||
import MergeChecks from '#lib/organisms/MergeChecks.svelte'; | ||
import PullStack from '#lib/organisms/PullStack.svelte'; | ||
import Reviewers from '#lib/organisms/Reviewers.svelte'; | ||
import Dialog from '#lib/molecules/Dialog.svelte'; | ||
/** | ||
| @@ -38,7 +39,7 @@ | ||
* with `dismiss`, as the owner or a collaborator, each review can be | ||
* dismissed from there. | ||
*/ | ||
let { repo, pull, base, head, queue, reviews = [], requested = [], required = [], dismiss, draft = false, checks, minutes = [], rerun, in: logged }: { repo: RepoViewDetailed; pull: string; base: string; head?: PullHeadView; queue?: QueueStateView; reviews?: ReviewView[]; requested?: ActorView[]; required?: ActorView[]; dismiss?: (did: ActorView['did'], reason: string) => Promise<unknown>; draft?: boolean; checks: CheckView[]; minutes?: MinutesView[]; rerun?: Snippet; in: boolean } = $props(); | ||
let { repo, pull, base, head, queue, reviews = [], requested = [], required = [], dismiss, draft = false, checks, minutes = [], rerun, stack, in: logged }: { stack?: StackView; repo: RepoViewDetailed; pull: string; base: string; head?: PullHeadView; queue?: QueueStateView; reviews?: ReviewView[]; requested?: ActorView[]; required?: ActorView[]; dismiss?: (did: ActorView['did'], reason: string) => Promise<unknown>; draft?: boolean; checks: CheckView[]; minutes?: MinutesView[]; rerun?: Snippet; in: boolean } = $props(); | ||
type Status = Answer<typeof getMergeStatus.main>; | ||
let status = $state<Status | null>(null); | ||
let problem = $state(''); | ||
| @@ -140,6 +141,10 @@ | ||
{#if queued && queue}<div class="row running"><Icon name="queue" /><div class="grow"><b>{queue.said}</b><p class="mute">{landing === null ? 'A runner lands it once its batch passes its checks.' : `It ${landsIn(landing)}, once its batch passes its checks.`}</p></div></div>{/if} | ||
{#if checked}<MergeChecks {checks} {base} standing={status?.checks} {minutes} end={rerun} />{/if} | ||
{/if} | ||
{#if stack && !merged} | ||
<div class="row"><Icon name="stack" /><b>{`Part of a stack of ${stack.pulls.length}`}</b></div> | ||
<PullStack {stack} current={pull} base={repoHref(repo.owner.handle, repo.name)} /> | ||
{/if} | ||
{#if logged && !merged && status} | ||
{@const s = status} | ||
{#if s.state === 'conflicting'} | ||
web/src/lib/organisms/PullStack.sveltenew+23−0
| @@ -0,0 +1,23 @@ | ||
<script lang="ts"> | ||
import type { StackView } from '#lib/lexicons/dev/knotless/defs.ts'; | ||
import { stateIcon } from '#lib/icons.ts'; | ||
import { itemHref } from '#lib/format.ts'; | ||
import Icon from '#lib/atoms/Icon.svelte'; | ||
import Ref from '#lib/atoms/Ref.svelte'; | ||
import Sha from '#lib/atoms/Sha.svelte'; | ||
/** The pulls of `stack`, highest first, down to the branch they land on, with `current` marked, on the repository at `base`. */ | ||
let { stack, current, base }: { stack: StackView; current: string; base: string } = $props(); | ||
</script> | ||
| ||
<ol class="stack-map" aria-label="Pulls in this stack, highest first"> | ||
{#each stack.pulls as p (p.item.uri)} | ||
<li class:here={p.item.uri === current} aria-current={p.item.uri === current ? 'true' : undefined}> | ||
<span class="st {p.item.draft && p.item.state === 'open' ? 'draft' : p.item.state} pull"><Icon name={stateIcon('pull', p.item.state, p.item.draft)} /></span> | ||
<div class="grow"> | ||
{#if p.item.uri === current}<b class="title">{p.item.title}</b>{:else}<a class="title" href={itemHref(base, p.item.uri)}>{p.item.title}</a>{/if} | ||
{#if p.merged}<span class="meta">{'Landed as '}<Sha sha={p.merged} /></span>{:else if p.branch}<span class="meta">{p.branch}</span>{/if} | ||
</div> | ||
</li> | ||
{/each} | ||
{#if stack.into}<li class="root"><span class="stack-dot"></span><Ref name={stack.into} /></li>{/if} | ||
</ol> | ||