//! `atgc report`: say something about atgc, to whichever place answers it. //! //! One command, two destinations, chosen by the kind of report: //! //! - **A bug becomes a Tangled issue** on atgc's own repo, through //! [`crate::cmd::issue::write::write_issue`] — the same record `atgc issue //! create` writes, on a tracker that can be assigned, closed and linked to //! a commit. A defect belongs where the work happens, and the work happens //! on Tangled. //! - **Everything else becomes a board post**: a feature request, a //! question, anything the board declares. That is an //! `app.userinput.discussion` record written to *your* PDS, pointing at //! the board's `app.userinput.space` record: the same //! record-in-the-author's-repo shape as a Tangled pull request, needing //! nothing from the board's owner and no service in between. What the //! board contributes is its identity (the strong ref the report points at) //! and its declared kinds; both are read logged-out through //! [`crate::clients::atproto::pds`]. It is the right home for the reports //! whose answer is a conversation and a vote count rather than a patch. //! //! Which one a report went to is not left to be inferred: the preview names //! the repo or the board by the same line it always did, and `--json` //! carries a `target` saying which of the two it is. //! //! Everything that will be published is printed before the write, whichever //! destination it is bound for, because the record is public the moment it //! lands and there is no prompt to reconsider at: atgc never prompts (see //! [`crate::term::noinput`]). The diagnostics appended to the body are a //! fixed, enumerable set: versions and host names, never environment or //! paths, so what the preview shows is knowable before the first run, not //! just inspectable after it. //! //! Showing the record and saying what becomes of it are separate. The note //! that it is public prints after the write, or in a dry run as what a real //! run would do. A run that fails never prints it, because nothing was //! published. //! //! The lexicon conventions live in [`crate::lexicon::userinput`], which is intended //! to be lifted out into a crate; what stays here is atgc's half: accounts, //! scopes, diagnostics, output. use crate::clients::atproto::pds; use crate::clients::tangled::resolve; use crate::cmd::auth; use crate::config::account::{self, Selection}; use crate::lexicon::userinput; use crate::lexicon::userinput::{ StrongRef, Upvote, discussion_url, parse_at_uri, space_url, vote_rkey, }; use anyhow::{Context, Result, bail}; use jacquard::client::AgentSessionExt; use jacquard::types::string::Datetime; /// The board reports land on when neither `--space` nor `ATGC_REPORT_SPACE` /// names one: atgc's own, at /// . /// The record is read fresh on every run, so its name and kinds are whatever /// the board says today; only the address lives here. const DEFAULT_BOARD: &str = "at://did:plc:nlzmjyfv6loqtxyzvdcznwgf/app.userinput.space/3msrnb776772b"; /// The repo bugs are filed against when neither `--repo` nor /// `ATGC_REPORT_REPO` names one: atgc's own. /// /// A URL and not the repo's DID, for two reasons. It is what /// [`resolve::repo_ref`] takes, so the same resolution a git remote goes /// through is the one this goes through — no second path to keep working. /// And it survives being read by a person: `permadeath.com/atgc` says which /// repo this is and `did:plc:gspkabpde4kx47fj3bhiwrms` does not. /// /// Deliberately not this checkout's `origin`, which is what `issue create` /// uses: `atgc report bug` is about atgc, and it is typed from inside /// whatever repo the bug was met in. const DEFAULT_REPO: &str = "https://tangled.org/permadeath.com/atgc"; /// The `sh.tangled.label.definition` behind `--agent`, on atgc's repo: /// name `agent`, a `null` value type (a plain tag, carrying nothing), and a /// scope of issues and pulls. /// /// An address like [`DEFAULT_REPO`] and [`DEFAULT_BOARD`], and tied to the /// same repo as the first of those: a definition belongs to the repo whose /// settings created it, so this constant and `--repo` cannot both be free. /// `--agent` therefore refuses a repo that is not the default rather than /// writing an operand naming a definition nothing over there has heard of. const AGENT_LABEL: &str = "at://did:plc:nlzmjyfv6loqtxyzvdcznwgf/sh.tangled.label.definition/3mu622ott2w22"; /// What that definition is called, for the lines that name it to a person. /// The definition record carries the name too, and reading it back to print /// one word would cost a round trip on the way to a label this command /// already knows the address of. const AGENT_LABEL_NAME: &str = "agent"; /// What `--agent` puts in front of the title. /// /// The label is the nice version of this and it is not always allowed to /// happen — tangled.org honours a label op only from an account with push on /// the repo — so the mark that always survives goes in the two fields the /// reporter owns outright. A prefix rather than a suffix because a listing /// truncates from the right, and this is meant to be readable in a list of /// forty issues. const AGENT_TITLE_PREFIX: &str = "[agent] "; /// The line `--agent` puts in the body, under whatever was written. /// /// Its own section rather than a line inside the diagnostics block, because /// `--no-diagnostics` drops that block and must not drop this: who filed a /// report is not a diagnostic about the machine, and a stamp that a flag can /// silently remove is worse than no stamp. const AGENT_BODY_STAMP: &str = "Filed by an agent, with `atgc report bug --agent`."; /// What an operand carries for a `null`-typed definition. /// /// The lexicon types every operand value as a string and the appview /// un-strings it: `ValidateOperandValue` wants the literal `"null"` for this /// type, and reads `""` as "unset this label" — see /// [`crate::lexicon::tangled::LABEL_OP_NSID`]. A plain tag is a label whose /// value is nothing, spelled. const NULL_LABEL_VALUE: &str = "null"; /// The one kind of report that is not a board post. /// /// The board declares it too — it was where bugs went — and /// [`check_kind`] now filters it back out, so the word means "file an issue" /// in exactly one place and cannot also mean "post about it". const BUG_KIND: &str = "bug"; #[derive(clap::Args, Debug)] pub(crate) struct ReportArgs { /// Kind of report: `bug` files a Tangled issue, anything else the board /// declares becomes a board post pub kind: Option, /// One line saying what happened or what is wanted #[arg(long)] pub title: String, /// The report's body #[arg(long)] pub body: Option, /// Read the body from a file, or from stdin with `-` #[arg(long, conflicts_with = "body")] pub body_file: Option, /// A different board: the at:// URI of its app.userinput.space record /// (or set ATGC_REPORT_SPACE) #[arg(long)] pub space: Option, /// A different repo to file a bug against (or set ATGC_REPORT_REPO) #[arg(long)] pub repo: Option, /// Label the bug `agent`: for a report filed by an AI agent #[arg(long)] pub agent: bool, /// Leave the version/platform lines out of the body #[arg(long)] pub no_diagnostics: bool, /// Print what would be published without writing anything #[arg(long)] pub dry_run: bool, /// Print one JSON object describing what was written (or, with --dry-run, /// what would be) instead of the summary lines #[arg(long)] pub json: bool, } /// What `report` filed, or would have filed. /// /// One shape for both destinations, with `target` saying which was used and /// the fields belonging to the other one `null`. Two shapes from one command /// would make every caller parse twice and guess which it got; a /// discriminator and some nulls is what a script can branch on. /// /// The record is public and yours either way, so the object names both /// addresses it has: the at-uri in your PDS, and the page that renders it. #[derive(serde::Serialize, Debug, PartialEq)] pub(in crate::cmd) struct ReportedJson { pub dry_run: bool, /// `"issue"` for a bug, `"board"` for everything else. pub target: &'static str, /// The record written, `null` on a dry run: an /// `sh.tangled.repo.issue` for a bug, an `app.userinput.discussion` /// otherwise. pub uri: Option, pub url: Option, /// The issue's record key, which every `atgc issue` verb takes. `null` /// on a dry run and on a board post, which has no key worth naming. pub rkey: Option, /// The board this was filed on, and its page. `null` for a bug. pub board: Option, pub board_uri: Option, pub board_url: Option, /// The repo the issue was filed against, and its own DID. `null` for a /// board post. pub repo: Option, pub repo_did: Option, /// The kind this report claims. `null` only for a board that declares /// none. pub kind: Option, /// Whether this was filed as an agent's report. The title and body say /// so too, and unlike the label those are the reporter's own record and /// cannot be refused, which is why this and `labels` are separate /// fields rather than one. pub agent: bool, /// The labels asked for on the issue: `["agent"]` with `--agent`, `[]` /// otherwise — including with `--agent` against a repo that is not /// atgc's, whose labels are its own — and always `[]` for a board post. /// Asked for, not confirmed: see `label_op`. pub labels: Vec, /// The `sh.tangled.label.op` record carrying those labels, `null` when /// none were asked for, on a dry run, and when the op was refused after /// the issue landed. The issue is filed either way, which is why this is /// a field of its own rather than an error. pub label_op: Option, pub title: String, /// The body exactly as the record carries it: diagnostics appended and /// all, since that is the text that becomes public. pub body: Option, /// The local images the body named, uploaded and swapped for /// `blob+at://` URIs. Always `[]` for a board post: the userinput /// lexicon has an `#image` shape and atgc does not write it yet. pub images: Vec, } impl ReportedJson { /// The fields both destinations fill in, with the other one's left /// `null`. Written once so a field added to the shape cannot be /// remembered on one path and forgotten on the other. fn new(target: &'static str, dry_run: bool, kind: Option, title: String) -> Self { Self { dry_run, target, uri: None, url: None, rkey: None, board: None, board_uri: None, board_url: None, repo: None, repo_did: None, kind, agent: false, labels: Vec::new(), label_op: None, title, body: None, images: Vec::new(), } } } /// How the destination renders a body. /// /// The diagnostics are four short lines and whether they survive as four /// lines is a property of the reader, not of the text. See /// [`compose_body`]. #[derive(Clone, Copy, Debug, PartialEq)] enum Rendering { /// userinput.app: no markdown to speak of, `whitespace-pre-wrap`. Plain, /// tangled.org: markdown, which joins consecutive lines into a /// paragraph. Markdown, } pub(crate) async fn report(args: ReportArgs) -> Result<()> { crate::term::jsonout::init(args.json); let selection = account::select().await?; selection.announce(); // The whole of the split, and it happens before anything is read: a bug // does not need the board, and reading it to be told which kinds exist // would make an issue depend on a service it has nothing to do with. if args.kind.as_deref() == Some(BUG_KIND) { file_bug(args, selection).await } else { file_on_board(args, selection).await } } /// `atgc report bug`: a `sh.tangled.repo.issue` on atgc's own repo. /// /// The record lands in the reporter's PDS and names the repo by the repo's /// own DID, so this needs no permission on atgc's repo and no membership of /// anything — the same property `issue create` and `pr create` have. What it /// needs is `repo:sh.tangled.repo.issue`, which atgc has requested since /// `issue create` shipped: no re-login, and a session old enough to lack it /// is told to log in rather than walked into a 403. async fn file_bug(args: ReportArgs, selection: Selection) -> Result<()> { // The label is atgc's own, so it is the half of `--agent` that another // repo cannot have: a definition belongs to the repo that declared it, // and an operand naming one over there would be dropped as unknown. The // title and body stamps are the reporter's own record and travel // anywhere, so `--agent` still means something and only says what it had // to leave behind. let labelling = args.agent && args.repo.is_none() && env_url("ATGC_REPORT_REPO").is_none(); if args.agent && !labelling { crate::term::say::note!( Index, "the `{AGENT_LABEL_NAME}` label is atgc's own, so this report is stamped but \ not labelled" ); } if args.space.is_some() { return Err(crate::exit::fail( crate::exit::Exit::Usage, "--space names a feedback board, and a bug is a Tangled issue now; \ drop it, or use --repo to file against a different repo", )); } // Read before touching the network, as `issue create` does: a missing // file is the likeliest mistake and finding out after three round trips // is worse. Required for the same reason it is required there — // tangled.org's ingester drops an issue whose body is empty, so a // title-only bug report would federate and never appear. let Some(body) = read_body(args.body, args.body_file)? else { return Err(crate::cmd::issue::write::empty_body_refusal( "a bug report needs a body; pass --body, or --body-file (with `-` for stdin)", )); }; // tangled.org renders markdown, so the diagnostics go in a fenced block: // see `compose_body`. let body = compose_body( Some(body), args.agent.then(|| AGENT_BODY_STAMP.to_string()), diagnostics(&selection.did, args.no_diagnostics).await, Rendering::Markdown, ) .unwrap_or_default(); // Scanned after composing rather than before, so the text handed to the // uploader is the text that was scanned. The diagnostics carry no image // syntax, so this finds exactly what the reporter wrote. let images = crate::cmd::images::scan(&body)?; let repo_url = args .repo .or_else(|| env_url("ATGC_REPORT_REPO")) .unwrap_or_else(|| DEFAULT_REPO.to_string()); let repo = resolve::repo_ref(&repo_url).await?; let mut report = ReportedJson::new( "issue", args.dry_run, Some(BUG_KIND.to_string()), stamped_title(args.title.trim(), args.agent), ); report.agent = args.agent; report.repo = Some(repo.label().to_string()); report.repo_did = Some(repo.did.clone()); // Known before the write, so it is filled in on a dry run too — unlike // `uri`, nothing about it is invented. Off `web_url` rather than // `issues_url`, which spells the repo as its DID: this command started // from a URL that named the repo `owner/name`, so the link it prints can // say that. A remote that carried only a DID falls back to the same // `tangled.org//issues` `issue create` prints, which redirects. let issues_url = format!("{}/issues", repo.web_url); report.url = Some(issues_url.clone()); report.body = Some(body.clone()); report.images = images.listed(); if args.title.trim().is_empty() { return Err(crate::exit::fail( crate::exit::Exit::Usage, "a bug report needs a title; --title cannot be empty", )); } // The label's own scope, checked here rather than after the issue lands: // `write_issue` refuses on the issue's scope before it writes anything, // and this is the same promise for the second record. A session that // predates `repo:sh.tangled.label.op` should be told to log in while // nothing has been published, not left holding a filed issue that is // missing the label it was filed with. if labelling { report.labels = vec![AGENT_LABEL_NAME.to_string()]; match crate::clients::tangled::scope::require_scope( crate::cmd::acting_scope(&selection.did).as_deref(), selection.handle.as_deref(), crate::lexicon::tangled::LABEL_OP_NSID, ) { Ok(()) => {} Err(e) if args.dry_run => crate::term::say::warning!(Auth, "would fail: {e}"), Err(e) => return Err(e), } } if !args.json { println!("repo: {}", repo.linked()); println!("as: {}", selection.display()); println!("kind: {BUG_KIND}"); for label in &report.labels { println!("label: {label}"); } println!("title: {}", report.title); for line in images.describe() { println!("image: {line}"); } println!("\n{body}\n"); } let filed = crate::cmd::issue::write::write_issue( &selection, &repo.did, &report.title, &body, &images, args.dry_run, ) .await?; let Some(filed) = filed else { if args.json { return crate::term::jsonout::emit(&report); } crate::term::say::note!(Pds, "a real run makes this a public issue on the repo"); println!("dry run; nothing written"); return Ok(()); }; // After the issue, because the op names it: a label on nothing is not a // record worth writing, and the issue is the part that must land. if labelling { report.label_op = apply_agent_label(&selection, &filed.uri).await; } if args.json { report.uri = Some(filed.uri); report.rkey = Some(filed.rkey); crate::term::jsonout::emit(&report)?; crate::cmd::issue::write::note_lag(); return Ok(()); } println!("reported {}", crate::term::hyperlink::url(&issues_url)); println!("record {}", filed.uri); println!("key: {}", filed.rkey); // After the write, because that is when it became true. crate::term::say::note!(Pds, "the issue is public in your PDS"); crate::cmd::issue::write::note_lag(); Ok(()) } /// Write the `agent` label onto an issue that has just been filed, and say /// what became of it. `None` means no label op is on the record. /// /// Best-effort, like the board path's self-upvote and for the same reason: /// the report is filed either way, and failing a command whose real work /// succeeded would be worse than saying what did not happen. The two ways /// this can come to nothing are different enough to be said differently: /// /// - The write itself fails, which is a warning naming the error. /// - The write succeeds and tangled.org ignores it, which is *not* an error /// anywhere and is the likely case for anybody who is not a collaborator: /// the appview honours a label op only from an account with push on the /// repo and drops the rest silently. See /// [`crate::lexicon::tangled::LABEL_OP_NSID`]. That is a note, printed /// whenever the op lands, because nothing later will mention it. /// /// Which is why the label is not what `--agent` rests on. It is written /// because it is the right record and it works for the accounts a project /// has given push to; [`AGENT_TITLE_PREFIX`] and [`AGENT_BODY_STAMP`] are /// what make the mark true for everybody else, and they are in the issue /// before this function is reached. async fn apply_agent_label(selection: &Selection, issue_uri: &str) -> Option { use jacquard::types::string::AtUri; use tangled_lexicon::sh_tangled::label::op::{Op, Operand}; // Built in one fallible step so the two at-uris are parsed where a // failure can be reported as one thing: neither is a value the caller // typed, so a malformed one is a defect in atgc rather than a mistake to // hand back. `delete` is empty rather than absent — the lexicon requires // both arrays, and an op that removes nothing still has to say so. let compose = || -> Result { Ok(Op { subject: AtUri::new(issue_uri.to_string().into())?, add: vec![Operand { key: AtUri::new(AGENT_LABEL.into())?, value: NULL_LABEL_VALUE.into(), extra_data: None, }], delete: Vec::new(), performed_at: Datetime::now(), extra_data: None, }) }; let op = match compose() { Ok(op) => op, Err(e) => { crate::term::say::warning!(Pds, "the issue is filed, but its label was not: {e}"); return None; } }; crate::logging::debug::log(format!( ">> createRecord {}\n{}", crate::lexicon::tangled::LABEL_OP_NSID, crate::logging::debug::pretty(&op) )); let agent = match auth::agent_for_did(&selection.did).await { Ok(agent) => agent, Err(e) => { crate::term::say::warning!(Pds, "the issue is filed, but its label was not: {e}"); return None; } }; // No rkey: the lexicon's key is a TID and the appview's own web UI mints // one per op, so one record per labelling act is the shape it expects. match agent.create_record(op, None).await { Ok(output) => { crate::term::say::note!( Index, "tangled.org shows a label only from an account with push on the repo; the \ title and body say so either way" ); Some(output.uri.to_string()) } Err(e) => { crate::logging::debug::dump_err("createRecord error", &e); crate::term::say::warning!( Pds, "the issue is filed, but its `{AGENT_LABEL_NAME}` label did not land: {e}" ); None } } } /// `atgc report `: an `app.userinput.discussion` on the board. async fn file_on_board(args: ReportArgs, selection: Selection) -> Result<()> { if args.repo.is_some() { return Err(crate::exit::fail( crate::exit::Exit::Usage, "--repo names the repo a bug is filed against; this report goes to a \ board, which --space names", )); } // Before the board is read and the report is composed: checking at the // write costs two round trips and prints a whole public record first. // A dry run writes nothing, so it notes the same thing and previews // anyway, as `key add` and `pr comment` do. let refusal = match crate::clients::tangled::scope::require_scope( crate::cmd::acting_scope(&selection.did).as_deref(), selection.handle.as_deref(), userinput::DISCUSSION_NSID, ) { Ok(()) => None, Err(e) if args.dry_run => Some(e), Err(e) => return Err(e), }; let space_uri = args .space .or_else(|| env_url("ATGC_REPORT_SPACE")) .unwrap_or_else(|| DEFAULT_BOARD.to_string()); let space_ref = parse_at_uri(&space_uri)?; if space_ref.collection != userinput::SPACE_NSID { bail!( "{space_uri} names a {} record, not a {} board", space_ref.collection, userinput::SPACE_NSID ); } // The board is read logged out, like every read in atgc: its name for // the preview, its declared kinds to hold the tag against, and its CID // for the strong ref, so the record points at the board as it was seen. let owner_pds = crate::clients::atproto::did::pds_or_fail(&space_ref.did).await?; let (board, board_cid) = pds::get_record( &owner_pds, &space_ref.did, userinput::SPACE_NSID, &space_ref.rkey, ) .await .context("could not read the feedback board")?; let board_name = board["name"] .as_str() .unwrap_or("(unnamed board)") .to_string(); let kinds = userinput::declared_tags(&board); let tags = check_kind(args.kind.as_deref(), &kinds, &board_name)?; let body = read_body(args.body, args.body_file)?; // Plain lines, not a fenced block: userinput.app renders no fences and // would show the backticks. let body = compose_body( body, // Nothing stamps a board post: `--agent` is a bug's flag, and the // board has no tracker to read a stamp off. None, diagnostics(&selection.did, args.no_diagnostics).await, Rendering::Plain, ); // Built before it is shown, so the preview is the record rather than a // description of one: the constructor normalizes the body on the way in, // and a preview composed separately would drift from what lands. let record = userinput::new_discussion( StrongRef { uri: space_uri.clone(), cid: board_cid, }, args.title.clone(), body, // One, because `check_kind` insisted on one; the record could carry // any number, and does not know that atgc asked for exactly one. &tags, ) .context("report would be refused by the lexicon")?; let mut report = ReportedJson::new( "board", args.dry_run, record.tags.iter().flatten().next().map(ToString::to_string), record.title.to_string(), ); report.board = Some(board_name.clone()); report.board_uri = Some(space_uri.to_string()); report.board_url = Some(space_url(&space_ref.did, &space_ref.rkey)); report.body = record.body.as_ref().map(ToString::to_string); if !args.json { println!( "board: {board_name}: {}", space_url(&space_ref.did, &space_ref.rkey) ); println!("as: {}", selection.display()); for kind in record.tags.iter().flatten() { println!("kind: {kind}"); } println!("title: {}", record.title); if let Some(body) = &record.body { println!("\n{body}\n"); } } if args.dry_run { // What a real run would refuse on is a note about the session // rather than about the report, so under --json it goes to stderr // beside the object rather than into it. if let Some(refusal) = refusal { crate::term::say::warning!(Auth, "would fail: {refusal}"); } if args.json { return crate::term::jsonout::emit(&report); } // Said here rather than before the preview, where it would describe // a record that does not exist yet. crate::term::say::note!(Pds, "a real run makes this public in your PDS"); println!("dry run; nothing written"); return Ok(()); } let agent = auth::agent_for_did(&selection.did).await?; crate::logging::debug::log(format!( ">> createRecord {}\n{}", userinput::DISCUSSION_NSID, serde_json::to_string_pretty(&record).unwrap_or_default() )); let output = agent.create_record(record, None).await.map_err(|e| { crate::logging::debug::dump_err("createRecord error", &e); crate::clients::tangled::scope::scope_refusal( "writing the report", selection.handle.as_deref(), &e.to_string(), ) })?; let uri = output.uri.to_string(); let cid = output.cid.to_string(); // The web client upvotes its own posts as it makes them, so a report // without one starts a vote behind every post made in a browser. Keyed // by the report's own rkey — the lexicon's one-vote-per-subject scheme — // and best-effort: the report is filed either way, and saying so beats // failing a command whose real work succeeded. let vote = Upvote { subject: StrongRef { uri: uri.clone(), cid: Some(cid), } .to_data()?, created_at: Datetime::now(), extra_data: None, }; let rkey = vote_rkey(&uri)?; crate::logging::debug::log(format!( ">> putRecord {} rkey {rkey}\n{}", userinput::UPVOTE_NSID, serde_json::to_string_pretty(&vote).unwrap_or_default() )); let key = crate::clients::atproto::record::Key::any_owned(&rkey) .map_err(|e| anyhow::anyhow!("{rkey} is not usable as a record key: {e}"))?; if let Err(e) = crate::clients::atproto::record::put(&agent, "upvote", &selection.did, key, &vote, None) .await { crate::term::say::warning!( Pds, "the report is filed, but its self-upvote did not land: {e}" ); } let reported = parse_at_uri(&uri)?; let url = discussion_url(&reported.did, &reported.rkey); if args.json { report.uri = Some(uri); report.url = Some(url); return crate::term::jsonout::emit(&report); } println!("reported {}", crate::term::hyperlink::url(&url)); println!("record {uri}"); // After the write, because that is when it became true. crate::term::say::note!(Pds, "the report is public in your PDS"); Ok(()) } /// An `ATGC_*` override that names an address, or nothing. /// /// Empty is nothing, for the reason [`crate::env`] gives: that is what an /// unset variable looks like after a shell has interpolated it. fn env_url(name: &str) -> Option { std::env::var(name).ok().filter(|s| !s.is_empty()) } /// The title as it will be filed: `--agent` puts [`AGENT_TITLE_PREFIX`] in /// front of it. /// /// Idempotent, because a title that already carries the prefix is one /// somebody copied out of the tracker — a report about a report, or a second /// run after a failed one — and `[agent] [agent] …` reads as a bug in atgc. fn stamped_title(title: &str, agent: bool) -> String { if agent && !title.starts_with(AGENT_TITLE_PREFIX) { format!("{AGENT_TITLE_PREFIX}{title}") } else { title.to_string() } } /// Hold the asked-for kind against the kinds the board declares. /// /// This is atgc's policy, not the lexicon's: a discussion may carry no tags /// or several: see [`crate::lexicon::userinput::new_discussion`], which can /// express all of it, and `report` requires exactly one anyway, because a /// board nobody can triage is the failure this command would cause at scale. /// The requirement belongs here, at the interface, and nowhere near the /// records. /// /// The board's record is the source of truth for *which* kinds exist, so /// they change when the board's do, with no atgc release in between: the /// cost is that the list lives in an error message rather than in `--help`, /// which cannot know it. /// /// With one subtraction. The board still declares `bug`, because it is where /// bugs used to go and taking it off the record would orphan the reports /// already filed under it. A bug is a Tangled issue now, so `bug` is not a /// kind a *board post* may claim: it is filtered out of the list here, and /// every refusal names the command that does take it. One word, one /// destination. fn check_kind(kind: Option<&str>, kinds: &[String], board_name: &str) -> Result> { let kinds: Vec<&str> = kinds .iter() .map(String::as_str) .filter(|k| *k != BUG_KIND) .collect(); let listed = kinds.join(", "); // Said in every refusal rather than only in the one for `bug` itself: // somebody who typed `crash` is as likely to want the tracker as // somebody who typed `bug`, and neither can see this split from outside. let elsewhere = "\natgc report bug files a Tangled issue on the repo instead"; match (kind, kinds.is_empty()) { (Some(kind), false) if kinds.contains(&kind) => Ok(vec![kind.to_string()]), // All three are `Usage`. The board answered, so nothing is missing // or unreachable; what is wrong is the word after `atgc report`, and // each message lists the words that would work. (Some(kind), false) => Err(crate::exit::fail( crate::exit::Exit::Usage, format!("{board_name} takes no {kind:?} reports; its kinds: {listed}{elsewhere}"), )), (Some(kind), true) => Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "{board_name} declares no kinds, so {kind:?} would be a tag nobody can \ filter by; drop it{elsewhere}" ), )), (None, false) => Err(crate::exit::fail( crate::exit::Exit::Usage, format!( "say what kind of report this is: {listed}\n\ for example: atgc report {} --title '…'{elsewhere}", kinds[0] ), )), (None, true) => Ok(Vec::new()), } } /// Where a body comes from: [`crate::term::body::read`], flattened, since a /// report has no stored body to clear and so nothing to say about the /// difference between "not passed" and "empty". /// /// Trimmed, unlike the other callers: a report body is prose typed at a /// prompt and goes onto a public board, where leading blank lines are noise /// rather than markdown. A pull or issue body keeps its whitespace because /// indentation there is content. fn read_body(body: Option, body_file: Option) -> Result> { Ok(crate::term::body::read(body, body_file, "body")? .flatten() .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty())) } /// The fixed lines a report carries unless `--no-diagnostics`: atgc's /// version, the platform, git's version, and the PDS host the account lives /// on. Names of software and of a public host, and nothing else: no /// environment, no paths, no repo names, because these land in a public /// record and must be listable here, not just visible in the preview. /// /// The same block whichever destination the report is bound for. A bug /// report is the one that is read by somebody trying to reproduce it, so /// these lines matter more on the issue path than on the board — but they /// are the same lines, because the privacy rule cannot differ between two /// public places. /// /// The first two lines come from [`crate::build_info`] and say more than they /// did. The version carries the commit it was built from, which is what tells /// a report against a tag apart from one against somebody's branch; the /// platform is now the target triple rather than `OS (ARCH)`, which said /// `linux (x86_64)` for both a glibc and a musl build. Both are still names /// of software, a public commit and a target triple, so the rule above is /// unchanged. async fn diagnostics(did: &str, suppressed: bool) -> Option { if suppressed { return None; } let git = crate::clients::git::run::version().unwrap_or_else(|| "git unavailable".to_string()); let pds = crate::clients::atproto::did::pds_from_did_doc(did) .await .map(|url| { url.trim_start_matches("https://") .trim_start_matches("http://") .trim_end_matches('/') .to_string() }) .unwrap_or_else(|| "unknown".to_string()); Some(format!( "atgc {}\n{}\n{git}\npds {pds}", crate::build_info::version_line(), crate::build_info::platform(), )) } /// The body atgc composes: the written part, then the diagnostics. /// /// How the diagnostics are wrapped is the destination's business, and the /// two destinations want opposite things: /// /// - [`Rendering::Plain`] leaves them as four bare lines, because /// userinput.app does not render markdown: its whole formatter is /// `**strong**`, `__u__`, `~~del~~`, `*em*` and links, with no fences, no /// headings and no lists. A fence would show as literal backticks. Bodies /// render inside `whitespace-pre-wrap`, so the line breaks alone do the /// separating a fence was reached for. /// - [`Rendering::Markdown`] fences them, because tangled.org *does* render /// markdown and markdown joins consecutive lines into one paragraph. Left /// bare there, the four lines would run together into a sentence with the /// version, the target triple, git and the PDS host in it. fn compose_body( body: Option, stamp: Option, diagnostics: Option, rendering: Rendering, ) -> Option { let mut sections = Vec::new(); if let Some(body) = body { sections.push(body); } // Between the two, and never inside the diagnostics fence: it is a fact // about who filed this, which outlives `--no-diagnostics`. if let Some(stamp) = stamp { sections.push(stamp); } if let Some(diag) = diagnostics { sections.push(match rendering { Rendering::Plain => diag, Rendering::Markdown => format!("```\n{diag}\n```"), }); } if sections.is_empty() { return None; } Some(sections.join("\n\n")) } #[cfg(test)] mod tests { use super::{ AGENT_BODY_STAMP, AGENT_LABEL, BUG_KIND, NULL_LABEL_VALUE, Rendering, check_kind, compose_body, stamped_title, }; /// The two marks that do not need anybody's permission. /// /// The label is the one `--agent` would rather have and the one it may /// not get: tangled.org honours a label op only from an account with /// push on the repo. The title prefix and the body line are fields of /// the reporter's own record, so they survive that, survive a repo whose /// labels are its own, and survive `--no-diagnostics` — which is why the /// stamp is a section of its own rather than a line in the diagnostics /// block. /// /// The prefix is idempotent: a title copied back out of the tracker and /// re-filed reads `[agent] …` once, not twice. #[test] fn a_report_is_stamped_where_no_permission_can_refuse_it() { assert_eq!(stamped_title("it crashed", true), "[agent] it crashed"); assert_eq!(stamped_title("it crashed", false), "it crashed"); assert_eq!( stamped_title("[agent] it crashed", true), "[agent] it crashed" ); let stamped = compose_body( Some("it crashed".into()), Some(AGENT_BODY_STAMP.into()), None, Rendering::Markdown, ) .expect("a body"); assert_eq!(stamped, format!("it crashed\n\n{AGENT_BODY_STAMP}")); assert!( !stamped.contains("```"), "--no-diagnostics must still carry the stamp: {stamped}" ); } /// The one address in this file nothing else can check: `--agent` names /// a label definition by at-uri, and a typo in it produces an operand /// the appview drops with no error anybody sees — the same silence a /// missing permission produces, so it would be read as that. This holds /// the constant to the collection it claims to be, and to the account /// that owns the default board, which is the account whose repo declared /// the label. /// /// The operand value is pinned beside it because it is the other thing /// no local check would catch: `ValidateOperandValue` wants the literal /// `"null"` for a `null`-typed label, and the empty string means "unset /// this label", so getting it wrong would delete rather than add. #[test] fn the_agent_label_names_a_definition_on_the_same_account_as_the_board() { let label = crate::lexicon::userinput::parse_at_uri(AGENT_LABEL).expect("an at-uri"); assert_eq!( label.collection, crate::lexicon::tangled::LABEL_DEFINITION_NSID ); let board = crate::lexicon::userinput::parse_at_uri(super::DEFAULT_BOARD).expect("an at-uri"); assert_eq!(label.did, board.did, "the label is not atgc's own"); assert_eq!(NULL_LABEL_VALUE, "null"); } /// atgc's policy, which is stricter than the lexicon on purpose: exactly /// one kind, from the list the board declares. A kind it does not declare /// is refused *with the list*, and so is naming none: the discussion /// record would happily carry zero or five, and this is the interface /// deciding otherwise. A board declaring no kinds is the one case where /// none is right, since a tag nobody can filter by would just be lost. #[test] fn exactly_one_declared_kind_is_atgcs_rule() { let kinds = vec!["feature".to_string(), "question".to_string()]; assert_eq!( check_kind(Some("feature"), &kinds, "b").unwrap(), vec!["feature"] ); let refusal = check_kind(Some("rant"), &kinds, "b") .unwrap_err() .to_string(); assert!(refusal.contains("feature, question"), "{refusal}"); let refusal = check_kind(None, &kinds, "b").unwrap_err().to_string(); assert!(refusal.contains("feature, question"), "{refusal}"); assert!(check_kind(None, &[], "b").unwrap().is_empty()); assert!(check_kind(Some("feature"), &[], "b").is_err()); } /// The split, from the board's side. The live board declares `bug` /// alongside the rest, and after the split that word is not a board post: /// it is filtered out of the kinds offered, refused if asked for, and /// every refusal — not just its own — says which command does take it, so /// somebody who typed `crash` finds the tracker too. #[test] fn bug_is_not_a_kind_the_board_still_takes() { let kinds = vec![ BUG_KIND.to_string(), "feature".to_string(), "question".to_string(), ]; let refusal = check_kind(Some(BUG_KIND), &kinds, "atgc") .unwrap_err() .to_string(); assert!( refusal.contains("atgc report bug files a Tangled issue"), "{refusal}" ); for refusal in [ check_kind(Some("crash"), &kinds, "atgc"), check_kind(None, &kinds, "atgc"), ] { let refusal = refusal.expect_err("a refusal").to_string(); assert!(!refusal.contains("bug, feature"), "{refusal}"); assert!(refusal.contains("feature, question"), "{refusal}"); assert!( refusal.contains("atgc report bug files a Tangled issue"), "{refusal}" ); } } /// Every one of those refusals is `2`, not the unclassified `1` they all /// used to be. The board answered, so nothing here is missing or /// unreachable; what is wrong is the word after `atgc report`, and each /// message lists the words that would have worked. A caller retrying one /// of these unchanged fails identically forever. #[test] fn a_kind_the_board_will_not_take_is_a_command_line_to_fix() { let kinds = vec!["feature".to_string(), "question".to_string()]; for refusal in [ check_kind(Some("rant"), &kinds, "b"), check_kind(None, &kinds, "b"), check_kind(Some("feature"), &[], "b"), ] { let err = refusal.expect_err("a refusal"); assert_eq!( crate::exit::classify(&err), crate::exit::Exit::Usage, "{err}" ); } } /// Diagnostics ride in the body rather than in a field of their own (the /// lexicon has none), and how they are wrapped is the destination's /// business: bare lines for the board, which renders no fences and would /// show the backticks, and a fenced block for tangled.org, whose markdown /// would otherwise run all four lines into one paragraph. A bodiless, /// diagnostics-less report composes no body at all rather than an empty /// one, and nothing here rewrites what the reporter wrote. #[test] fn the_body_is_the_written_part_then_the_diagnostics() { assert_eq!(compose_body(None, None, None, Rendering::Plain), None); assert_eq!(compose_body(None, None, None, Rendering::Markdown), None); assert_eq!( compose_body( Some("see https://x.yz".into()), None, Some("atgc 0.1".into()), Rendering::Plain ) .unwrap(), "see https://x.yz\n\natgc 0.1" ); assert_eq!( compose_body(None, None, Some("atgc 0.1".into()), Rendering::Plain).unwrap(), "atgc 0.1" ); assert_eq!( compose_body( Some("it crashed".into()), None, Some("atgc 0.1\nx86_64".into()), Rendering::Markdown ) .unwrap(), "it crashed\n\n```\natgc 0.1\nx86_64\n```" ); } }