~ 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

A pull's page shows the stack it is part of

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:stack/map
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>
web/src/lib/organisms/StackScope.sveltenew+28−0
web/src/lib/icons.ts+3−2
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/files/+page.svelte+2−0
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/+page.svelte+3−1
web/src/app.css+25−0