Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072//! `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/// <https://userinput.app/s/did:plc:nlzmjyfv6loqtxyzvdcznwgf/3msrnb776772b>./// 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<String>, /// One line saying what happened or what is wanted #[arg(long)] pub title: String, /// The report's body #[arg(long)] pub body: Option<String>, /// Read the body from a file, or from stdin with `-` #[arg(long, conflicts_with = "body")] pub body_file: Option<String>, /// A different board: the at:// URI of its app.userinput.space record /// (or set ATGC_REPORT_SPACE) #[arg(long)] pub space: Option<String>, /// A different repo to file a bug against (or set ATGC_REPORT_REPO) #[arg(long)] pub repo: Option<String>, /// 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<String>, pub url: Option<String>, /// 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<String>, /// The board this was filed on, and its page. `null` for a bug. pub board: Option<String>, pub board_uri: Option<String>, pub board_url: Option<String>, /// The repo the issue was filed against, and its own DID. `null` for a /// board post. pub repo: Option<String>, pub repo_did: Option<String>, /// The kind this report claims. `null` only for a board that declares /// none. pub kind: Option<String>, /// 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<String>, /// 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<String>, 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<String>, /// 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<crate::cmd::images::ImageJson>,}
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<String>, 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/<repoDid>/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<String> { 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<Op> { 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 <anything else>`: 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<String> { 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<Vec<String>> { 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<String>, body_file: Option<String>) -> Result<Option<String>> { 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<String> { 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<String>, stamp: Option<String>, diagnostics: Option<String>, rendering: Rendering,) -> Option<String> { 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```" ); }}