chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Labels and reviewers are chosen as a pull or issue opens, and a pull compares first
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." } | ||
} | ||
} | ||
}, | ||