~ 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

Labels and reviewers are chosen as a pull or issue opens, and a pull compares first

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/open-sidebar
crates/knotless-serve/src/web/opening.rs+59−2
@@ -2,13 +2,14 @@
//! what a repository asks them to follow; `getCompare`, what a pull from a
//! branch would bring in; and `createIssue` and `createPull`, as the
//! account logged in, through the same code as `knotless template`,
//! `issue` and `pull`.
//! `issue` and `pull`, with the labels and reviewers chosen beside them.
use super::repo::found;
use super::threads::{Written, acting, blobs, refused, reindex};
use super::threads::{Written, acting, blobs, labelled, refused, reindex};
use super::*;
use crate::viewer::Viewer;
use axum::Extension;
use knotless::env::Ctx;
use knotless::records::ImageAttachment;
/// The queries and writes the pages open pulls and issues with.
@@ -119,12 +120,57 @@ async fn compared(
pull_changes(app, repo, &Item::default(), base, &head, Rows::All).await
}
/// A label chosen to go on what is opened, by name.
#[derive(Deserialize)]
pub struct Named {
name: String,
}
/// An account asked to review a pull, by DID.
#[derive(Deserialize)]
pub struct Asked {
did: String,
}
#[derive(Deserialize)]
pub struct IssueInput {
repo: String,
title: String,
body: Option<String>,
images: Option<Vec<ImageAttachment>>,
#[serde(default)]
labels: Vec<Named>,
}
/// Refuses, before anything is written, a label of `names` that `repo`
/// does not define or that the account `ctx` acts as may not put on.
fn may_put(app: &App, repo: &Repo, ctx: &Ctx, names: &[&str]) -> Result<()> {
let did = ctx.env.identifier.as_deref().unwrap_or_default();
let record = repo.record(app).unwrap_or_default();
let may = |name: &&str| {
let defined = record.label(name);
defined.is_some_and(|l| record.may_label(&repo.did, did, l))
};
let why = |name: &&str| format!("{name}: not a label you may put on it");
match names.iter().find(|name| !may(name)) {
Some(name) => Err(Shown(StatusCode::BAD_REQUEST, why(name))),
None => Ok(()),
}
}
/// Puts `names` on what was just opened at `uri`, if any were chosen.
async fn label_opened(
app: &App,
ctx: &Ctx,
repo: &Repo,
url: &RepoUrl,
uri: &str,
names: &[&str],
) -> Result<()> {
if !names.is_empty() {
labelled(app, ctx, repo, url, uri, names, false).await?;
}
Ok(())
}
/// `dev.knotless.createIssue`.
@@ -135,10 +181,13 @@ pub async fn create_issue(
axum::Json(input): axum::Json<IssueInput>,
) -> Result<Response> {
let (ctx, repo, url) = acting(&app, viewer, &headers, "open an issue", &input.repo).await?;
let names: Vec<&str> = input.labels.iter().map(|l| l.name.as_str()).collect();
may_put(&app, &repo, &ctx, &names)?;
let images = blobs(input.images);
let opened = forge::open_issue(&ctx, &url, &input.title, input.body.as_deref(), &images);
let uri = opened.await.map_err(refused)?;
reindex(&app, &repo, &uri).await?;
label_opened(&app, &ctx, &repo, &url, &uri, &names).await?;
Ok(answer(Written { uri }))
}
@@ -153,6 +202,10 @@ pub struct PullInput {
images: Option<Vec<ImageAttachment>>,
#[serde(default)]
draft: bool,
#[serde(default)]
labels: Vec<Named>,
#[serde(default)]
reviewers: Vec<Asked>,
}
/// `dev.knotless.createPull`.
@@ -163,16 +216,20 @@ pub async fn create_pull(
axum::Json(input): axum::Json<PullInput>,
) -> Result<Response> {
let (ctx, repo, url) = acting(&app, viewer, &headers, "open a pull", &input.repo).await?;
let names: Vec<&str> = input.labels.iter().map(|l| l.name.as_str()).collect();
may_put(&app, &repo, &ctx, &names)?;
let from = url_of(&found(&app, &input.head).await?);
let (title, body) = (&input.title, input.body.as_deref());
let images = blobs(input.images);
let (base, branch) = (&input.base, &input.branch);
let asks = forge::PullAsks {
draft: Some(input.draft),
add: input.reviewers.iter().map(|r| r.did.clone()).collect(),
..Default::default()
};
let opened = forge::open_pull(&ctx, &url, base, &from, branch, title, body, &images, &asks);
let uri = opened.await.map_err(refused)?;
reindex(&app, &repo, &uri).await?;
label_opened(&app, &ctx, &repo, &url, &uri, &names).await?;
Ok(answer(Written { uri }))
}
crates/knotless-serve/src/web/threads.rs+24−6
@@ -334,12 +334,9 @@ pub async fn label_item(
) -> Result<Response> {
let (ctx, repo, url) = acting(&app, viewer, &headers, "label", &input.repo).await?;
let names = [input.label.as_str()];
let labelled = forge::label(&ctx, &url, &input.subject, &names, input.removed);
let now = labelled.await.map_err(refused)?;
// Each labeller keeps one record about it, at a key of its own.
let did = ctx.env.identifier.clone().unwrap_or_default();
let rkey = knotless::keys::label_rkey(&repo.key, &input.subject);
reindex(&app, &repo, &format!("at://{did}/{}/{rkey}", nsid::LABEL)).await?;
let (subject, removed) = (&input.subject, input.removed);
let labelling = labelled(&app, &ctx, &repo, &url, subject, &names, removed);
let now = labelling.await?;
let record = repo.record(&app).unwrap_or_default();
let labels = now.iter().filter_map(|name| record.label(name));
Ok(answer(Labelled {
@@ -347,6 +344,27 @@ pub async fn label_item(
}))
}
/// Puts `names` on `subject`, or with `removed` takes them off, as the
/// account `ctx` acts as, and indexes the record that says so: the labels
/// it carries now.
pub(super) async fn labelled(
app: &App,
ctx: &Ctx,
repo: &Repo,
url: &RepoUrl,
subject: &str,
names: &[&str],
removed: bool,
) -> Result<Vec<String>> {
let now = forge::label(ctx, url, subject, names, removed);
let now = now.await.map_err(refused)?;
// Each labeller keeps one record about it, at a key of its own.
let did = ctx.env.identifier.clone().unwrap_or_default();
let rkey = knotless::keys::label_rkey(&repo.key, subject);
reindex(app, repo, &format!("at://{did}/{}/{rkey}", nsid::LABEL)).await?;
Ok(now)
}
#[derive(Deserialize)]
pub struct HideInput {
repo: String,
crates/knotless-serve/src/web/views.rs+3−0
@@ -60,6 +60,9 @@ pub struct RepoViewerState {
/// Where the owner offered to move it, when that is to the asker.
#[serde(skip_serializing_if = "Option::is_none")]
pub transfer: Option<RepoViewBasic>,
/// The labels it defines that the asker may put on its pulls and issues.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<LabelView>,
}
/// `dev.knotless.defs#commitView`.
crates/knotless-serve/src/web.rs+15−3
@@ -1344,7 +1344,10 @@ impl Repo {
let open = self.open_items(app).await.unwrap_or_default();
let offer = record.as_ref().and_then(|r| r.transfer.clone());
let viewer = match viewer {
Some(v) => Some(self.viewer_state(app, v, offer.as_ref()).await),
Some(v) => Some(
self.viewer_state(app, v, record.as_ref(), offer.as_ref())
.await,
),
None => None,
};
let avatar = avatar(app, &self.did).await;
@@ -1398,12 +1401,13 @@ impl Repo {
index.star_count(&self.did, &self.key).ok()
}
/// What `viewer` has to do with it, `offer` naming where its owner
/// offered to move it, if anywhere.
/// What `viewer` has to do with it, as its `record` says, `offer`
/// naming where its owner offered to move it, if anywhere.
async fn viewer_state(
&self,
app: &App,
viewer: &str,
record: Option<&RepoRecord>,
offer: Option<&RepoRef>,
) -> RepoViewerState {
let star = app
@@ -1418,9 +1422,17 @@ impl Repo {
}),
None => None,
};
let labels = record.into_iter().flat_map(|r| {
let may = r
.labels
.iter()
.filter(|l| r.may_label(&self.did, viewer, l));
may.map(label_view)
});
RepoViewerState {
star: star.and_then(|s| s.ok().flatten()),
transfer,
labels: labels.collect(),
}
}
crates/knotless-serve/tests/gateway/opening.rs+42−0
@@ -108,4 +108,46 @@ fn opening_pulls_and_issues_from_the_pages() {
assert_eq!(write("createPull", me, pull(&forked, "nope")).0, 400);
let nowhere = format!("at://{}/dev.knotless.repo/nothing", fan.account.did);
assert_eq!(write("createPull", me, pull(&nowhere, "fix")).0, 404);
// Reviewers asked for as it opens.
let mut asking = pull(&forked, "fix");
asking["reviewers"] = json!([{"did": owner.account.did}]);
let (status, _, opened) = write("createPull", me, asking);
assert_eq!(status, 200, "{opened}");
let shown = thread(opened["uri"].as_str().unwrap());
assert_eq!(shown["item"]["reviewers"][0]["did"], owner.account.did);
// Labels put on as it opens, by those who may put them on: the
// owner, whose pages offer them, and not the fan, who is refused
// before anything is written.
let add = ["labels", &url, "add", "kind/bug"];
assert_eq!(owner.cli(&add, b"").0, 0);
let set = log_in(&server, &owner.account, "");
let found = set.iter().find(|c| c.starts_with("knotless_session="));
let owns = pair(found.unwrap()).to_string();
let offered = |cookie: &str| {
let url = format!("{}?repo={repo}", xrpc("getRepo"));
browse("GET", &url, Some(cookie), None, None).2["viewer"]["labels"].clone()
};
wait_until(|| offered(&owns)[0]["name"] == "kind/bug");
assert_eq!(offered(&cookie), Value::Null);
let labels = json!([{"name": "kind/bug"}]);
let body = "## What\n\nStill broken.";
let labelled = json!({"repo": repo, "title": "Broken", "body": body, "labels": labels});
let (status, _, refused) = write("createIssue", me, labelled.clone());
assert_eq!(status, 400);
assert!(
refused["message"].as_str().unwrap().contains("kind/bug"),
"{refused}"
);
let (status, _, opened) = write("createIssue", Some(&owns), labelled);
assert_eq!(status, 200, "{opened}");
let shown = thread(opened["uri"].as_str().unwrap());
assert_eq!(shown["item"]["labels"][0]["name"], "kind/bug");
let mut onto = pull(&forked, "fix");
onto["labels"] = labels;
let (status, _, opened) = write("createPull", Some(&owns), onto);
assert_eq!(status, 200, "{opened}");
let shown = thread(opened["uri"].as_str().unwrap());
assert_eq!(shown["item"]["labels"][0]["name"], "kind/bug");
}
docs/design/serve.md+11−4
@@ -855,10 +855,17 @@ body started from the repository's issue template, and a pull from the
Pulls list's New pull, or Compare & pull on a branch's files: as GitHub's
compare page does, it chooses the branch to merge into, then the branch to
bring in, from the repository or a fork of it, shows the commits and diff
it would bring (`getCompare`), and starts the body from the pull template
at that base (`getTemplate`). Opening (`createIssue`, `createPull`) goes
through the same code as `knotless issue` and `knotless pull`, so a body
that follows none of the template is refused there too.
it would bring (`getCompare`), with a button that unfolds the form to open
it, its body started from the pull template at that base (`getTemplate`).
Compare & pull links with `expand=1`, which shows the form at once, as
GitHub's banner does. Beside the form, as GitHub's is, a sidebar
chooses the labels to put on it, those the repository's viewer state says
the account may (`getRepo`'s `viewer.labels`), and for a pull the
reviewers to ask, from the owner and collaborators. Opening (`createIssue`,
`createPull`) goes through the same code as `knotless issue`, `knotless
pull` and `knotless label`, so a body that follows none of the template is
refused there too, and a label the account may not put on is refused
before anything is written.
A pull's or an issue's page is written on as the account logged in, as
GitHub's is: a comment at the end of the thread, and on someone else's
docs/reference/api.md+3−3
@@ -536,7 +536,7 @@ name it has now.
| `dev.knotless.listRepos?q=` | repositories matching `q` (above) |
| `dev.knotless.searchOwners?q=` | the owners of repositories whose handle holds `q` (above) |
| `dev.knotless.getActorRepos?actor=` | an account's public repositories, each with the `language` most of it is written in: from the index while a feed is followed, else as its PDS lists them; to the account itself, asking, its private ones too, each `private`, and to a member asking those it was proven a member of; asked as the account itself, apart, as `shared`, the private repositories of others it was proven a member of |
| `dev.knotless.getRepo?repo=` | a repository: its owner, description, `links` (only http and https ones), `topics`, default branch, counts, languages, `collaborators` (the first twenty its record names, besides its owner) and `collaboratorCount`, `private` if it is, and `archivedAt` if its owner archived it |
| `dev.knotless.getRepo?repo=` | a repository: its owner, description, `links` (only http and https ones), `topics`, default branch, counts, languages, `collaborators` (the first twenty its record names, besides its owner) and `collaboratorCount`, `private` if it is, and `archivedAt` if its owner archived it; with an asker, `viewer`: its `star`, a `transfer` offered to it, and the `labels` it may put on the repository's pulls and issues |
| `dev.knotless.listReleases?repo=&package=&limit=&cursor=` | its releases ([packages.md](../design/packages.md#on-the-pages)), newest first, 30 a page by default, of one `package` if given: each with `latest` when it is its package's latest, `image` when its files are an OCI layout's, its notes as Markdown and as `html`, and its files' names, sizes and SHA-256; and every package it publishes, by name, with its latest version, how many releases it has and whether it is an image. None for a private repository or without an index |
| `dev.knotless.getTree?repo=&ref=&path=` | a folder, its README and the newest checks of its last commit |
| `dev.knotless.getLastCommits?repo=&ref=&path=` | the newest commit to change each entry of a folder, and at the root, how many commits the ref's history holds |
@@ -648,8 +648,8 @@ from this site` unless its `Origin` is the site's own.
| `POST /xrpc/dev.knotless.removeTriager` | `{repo, who}`: as `knotless triagers remove`, answering `{triagers}` |
| `POST /xrpc/dev.knotless.forkRepo` | `{repo, name?}`: forks it into the account logged in, under its own name or `name`, as `knotless fork` does, answering `{uri, name}`; 404 for no such repository, 400 for a name the account has already |
| `POST /xrpc/dev.knotless.uploadImage?repo=` | a picture as the whole body, a PNG, JPEG, WebP or GIF: uploads it to the account logged in, made to fit, to show in a pull, issue or comment on `repo`, answering `{blob, url, width, height}`, the last two the size it is drawn at in CSS pixels ([collaboration.md](../design/collaboration.md#pictures)); 400 for anything else. Its PDS keeps it once a write below attaches it |
| `POST /xrpc/dev.knotless.createIssue` | `{repo, title, body?, images?}`: opens an issue as the account logged in, answering `{uri}`; 400, with the template, for a body that follows none of the repository's |
| `POST /xrpc/dev.knotless.createPull` | `{repo, base, head, branch, title, body?, images?, draft?}`: opens a pull to merge `branch` of `head`, this repository or a fork, into `base`, as a draft with `draft`, answering `{uri}`; 400 for a branch on neither, or a body that follows none of the template |
| `POST /xrpc/dev.knotless.createIssue` | `{repo, title, body?, images?, labels?}`: opens an issue as the account logged in, answering `{uri}`, with `labels` (each `{name}`) put on as `labelItem` would; 400, with the template, for a body that follows none of the repository's, and, before anything is written, for a label the account may not put on |
| `POST /xrpc/dev.knotless.createPull` | `{repo, base, head, branch, title, body?, images?, draft?, labels?, reviewers?}`: opens a pull to merge `branch` of `head`, this repository or a fork, into `base`, as a draft with `draft`, answering `{uri}`, with `labels` put on as `createIssue` does and `reviewers` (each `{did}`) asked to review it; 400 for a branch on neither, a body that follows none of the template, or a label the account may not put on |
| `POST /xrpc/dev.knotless.createComment` | `{repo, subject, body, verdict?, images?, lines?}`: comments on a pull or issue as the account logged in, or with `verdict` (`approve`, `changes`) reviews a pull; `lines`, each `{path, side?, line, startLine?, body}`, comment on the pull's diff, as the review's when it has a body or verdict. A comment as `subject` answers it in its thread. Answers the first it wrote's `{uri}`; 400 for a comment that says nothing |
| `POST /xrpc/dev.knotless.editItem` | `{repo, subject, title?, body?, images?, draft?}`: changes the account's own pull, issue or comment, answering `{uri}`; `draft`, for a pull alone, makes it a draft or with `false` marks it ready. `images`, each `{image, width?, height?}` with a blob and size `uploadImage` gave, are attached to what the body links to ([collaboration.md](../design/collaboration.md#pictures)). It, `createIssue`, `createPull` and `createComment` write each `@handle` of the title and body that resolves as a facet naming its DID ([collaboration.md](../design/collaboration.md#mentions)) |
| `POST /xrpc/dev.knotless.deleteComment` | `{repo, subject}`: deletes the account's own comment or review, answering `{uri}` |
harness/e2e/flows.spec.mjs+25−1
@@ -160,12 +160,22 @@ test('opening a pull from a fork', async () => {
await page.getByLabel('From').selectOption({ label: 'bob.test/tally (yours)' })
await page.getByLabel('Branch').selectOption('main')
await expect(page.locator('.compared')).toContainText('Release from tags when asked')
// The form unfolds once asked for, as GitHub's compare page does.
await expect(page.getByRole('textbox', { name: 'Title' })).toHaveCount(0)
await page.locator('.compare-ready').getByRole('button', { name: 'Open pull' }).click()
await page.getByRole('textbox', { name: 'Title' }).fill('Bring in the fork: usage, totals and releases')
// Asking the owner to review it as it opens, from the sidebar.
await page.locator('.reviewer-menu summary').click()
await page.locator('.reviewer-menu').getByRole('button', { name: 'alice.test' }).click()
await page.locator('.reviewer-menu summary').click()
await expect(page.locator('.side-people')).toContainText('alice.test')
await page.getByRole('button', { name: 'Open pull' }).click()
await page.waitForURL(/\/alice\.test\/tally\/pull\//)
proposed = page.url()
await expect(page.getByRole('heading', { name: 'Bring in the fork: usage, totals and releases' })).toBeVisible()
expect((await records('bob', 'dev.knotless.pull')).map((r) => r.value.title)).toContain('Bring in the fork: usage, totals and releases')
const pulls = await records('bob', 'dev.knotless.pull')
const made = pulls.find((r) => r.value.title === 'Bring in the fork: usage, totals and releases')
expect(made?.value.reviewers?.map((r) => r.did)).toContain(who.alice.did)
})
test('retargeting a pull', async () => {
@@ -322,6 +332,20 @@ test('logging in to a second account and labelling as the owner', async () => {
await anew(page, (expect) => expect(page.locator('.labels').getByText('kind/bug')).toBeVisible())
})
test('opening an issue with a label, as the owner', async () => {
await go(page, '/alice.test/tally/issues/new')
await page.getByRole('textbox', { name: 'Title' }).fill('Say what counts as a word in the README')
const menu = page.locator('.open-side .label-menu')
await menu.locator('summary').click()
await menu.getByRole('button', { name: 'kind/bug' }).click()
await menu.locator('summary').click()
await expect(page.locator('.open-side .labels')).toContainText('kind/bug')
await page.getByRole('button', { name: 'Open issue' }).click()
await page.waitForURL(/\/alice\.test\/tally\/issue\//)
await expect(page.getByRole('heading', { name: 'Say what counts as a word in the README' })).toBeVisible()
await anew(page, (expect) => expect(page.locator('.item-meta .labels').getByText('kind/bug')).toBeVisible())
})
test('seeing notifications', async () => {
await go(page, '/alice.test/tally')
await expect(page.getByRole('link', { name: /^Notifications, \d+ new$/ })).toBeVisible()
harness/e2e/private.spec.mjs+2−0
@@ -77,6 +77,8 @@ test('opening a pull from a branch', async () => {
await page.getByLabel('From').selectOption({ label: 'alice.test/vault (yours)' })
await page.getByLabel('Branch').selectOption('plans')
await expect(page.locator('.compared')).toContainText('Write down the plans')
await expect(page.getByRole('textbox', { name: 'Title' })).toBeHidden()
await page.locator('.compare-ready').getByRole('button', { name: 'Open pull' }).click()
await page.getByRole('textbox', { name: 'Title' }).fill('Write down the plans')
await page.getByRole('button', { name: 'Open pull' }).click()
await page.waitForURL(/\/alice\.test\/vault\/pull\//)
lexicons/dev/knotless/createIssue.json+3−2
@@ -5,7 +5,7 @@
"defs": {
"main": {
"type": "procedure",
"description": "Writes a dev.knotless.issue record in the account logged in to this appview's web pages, as `knotless issue` does: refused, with the template, when the repository has one (see dev.knotless.getTemplate) and the body follows none of it. Requires the session cookie the pages' login sets.",
"description": "Writes a dev.knotless.issue record in the account logged in to this appview's web pages, as `knotless issue` does, and a dev.knotless.label record about it when labels are chosen: refused, with the template, when the repository has one (see dev.knotless.getTemplate) and the body follows none of it. Requires the session cookie the pages' login sets.",
"input": {
"encoding": "application/json",
"schema": {
@@ -15,7 +15,8 @@
"repo": { "type": "string", "format": "at-uri", "description": "The repository's dev.knotless.repo record, as dev.knotless.getRepo takes it." },
"title": { "type": "string", "maxLength": 3000, "maxGraphemes": 300 },
"body": { "type": "string", "maxLength": 20000, "description": "What it is about, in Markdown." },
"images": { "type": "array", "maxLength": 32, "items": { "type": "ref", "ref": "dev.knotless.defs#image" }, "description": "Pictures uploaded with dev.knotless.uploadImage; those the body links to are attached." }
"images": { "type": "array", "maxLength": 32, "items": { "type": "ref", "ref": "dev.knotless.defs#image" }, "description": "Pictures uploaded with dev.knotless.uploadImage; those the body links to are attached." },
"labels": { "type": "array", "maxLength": 32, "items": { "type": "ref", "ref": "dev.knotless.defs#labelName" }, "description": "Labels to put on it as it opens, as dev.knotless.labelItem would: refused, with nothing written, when one is not the repository's or not the account's to put on." }
}
}
},
lexicons/dev/knotless/createPull.json+2−0
lexicons/dev/knotless/defs.json+10−1
web/src/lib/molecules/LabelPicker.svelte+5−3
web/src/lib/molecules/ReviewerPicker.sveltenew+22−0
web/src/lib/organisms/OpenSide.sveltenew+53−0
web/src/lib/templates/Listing.svelte+1−1
web/src/routes/[authority]/[repo]/issues/new/+page.svelte+9−2
web/src/routes/[authority]/[repo]/pulls/new/+page.svelte+25−5
web/src/routes/[authority]/[repo]/pulls/new/+page.ts+4−2
web/src/app.css+14−0
web/src/tokens.css+1−0