neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
gg badges: list a repository's badges, and patch a README's
8e00315 · gmorell · 2026-11-10 00:12
Message
{commit_body(@commit)}
Files changed
modified
cli/README.md
+26
−0
@@ -85,6 +85,15 @@ gg labels create NAME [--color '#RRGGBB'] [--description TEXT]
| 85 | 85 | gg wiki list | gg wiki show SLUG |
| 86 | 86 | gg packages list | gg package show FORMAT NAME | gg package usage FORMAT |
| 87 | 87 | |
| 88 | +gg badges [list] every badge the repository has, and what it says now | |
| 89 | +gg badges readme [KEY …] [--all] [--style split|dot|outline] [--corners round|square] | |
| 90 | + [--scale 1x|2x|2.5x] [--gg | --no-gg] [--theme auto|NAME] | |
| 91 | + [--format svg|png] [--html | --markdown] | |
| 92 | + print a README block of badges | |
| 93 | +gg badges readme … --patch README.md [--check] | |
| 94 | + write it into the file, or with --check exit | |
| 95 | + non-zero if the file's block is out of date | |
| 96 | + | |
| 88 | 97 | gg dashboard [--refresh SECS] interactive CI dashboard |
| 89 | 98 | |
| 90 | 99 | gg audit [--category C] [--actor HANDLE] [--limit N] [--before ID] |
@@ -99,6 +108,23 @@ repository's owner). It has no Android counterpart on purpose: reading a
| 99 | 108 | security log is a desk job, not a phone one, so it's the one exception |
| 100 | 109 | to keeping the two in step. |
| 101 | 110 | |
| 111 | +`gg badges readme --patch` keeps its block between two comments, and the | |
| 112 | +opening one records what it was asked for: | |
| 113 | + | |
| 114 | +```markdown | |
| 115 | +<!-- gg:badges ci coverage release license style=dot gg --> | |
| 116 | +[](…) [](…) … | |
| 117 | +<!-- /gg:badges --> | |
| 118 | +``` | |
| 119 | + | |
| 120 | +So a second run needs no arguments: `gg badges readme --patch README.md` | |
| 121 | +rebuilds the same badges with the same options, and anything passed | |
| 122 | +overrides what was recorded. Without a block yet, it goes under the | |
| 123 | +file's first `# ` heading, or at the top. Nothing outside the markers is | |
| 124 | +touched, which makes it safe for an agent or a CI job to run, and | |
| 125 | +`--check` lets CI fail a README that has drifted. The Markdown comes from | |
| 126 | +the forge, the same the badge page's README block builds. | |
| 127 | + | |
| 102 | 128 | `--json` on any command prints the server's response verbatim instead of |
| 103 | 129 | a table, which is the intended way to script against it. (`gg ci dispatch |
| 104 | 130 | --json` prints the new run and doesn't watch it.) |
modified
cli/src/api/mod.rs
+11
−0
@@ -716,6 +716,17 @@ impl Client {
| 716 | 716 | |
| 717 | 717 | // ── packages ───────────────────────────────────────────────────── |
| 718 | 718 | |
| 719 | + /// Every badge the repository has; `query` takes the badge options | |
| 720 | + /// (style, corners, scale, gg, theme, format). | |
| 721 | + pub async fn badges( | |
| 722 | + &self, | |
| 723 | + repo: &RepoRef, | |
| 724 | + query: &[(&str, String)], | |
| 725 | + ) -> Result<Res<BadgesResponse>> { | |
| 726 | + self.get(&["repos", &repo.owner, &repo.name, "badges"], query) | |
| 727 | + .await | |
| 728 | + } | |
| 729 | + | |
| 719 | 730 | pub async fn packages(&self, repo: &RepoRef) -> Result<Res<PackagesResponse>> { |
| 720 | 731 | self.get(&["repos", &repo.owner, &repo.name, "packages"], &[]) |
| 721 | 732 | .await |
modified
cli/src/api/models.rs
+35
−0
@@ -705,3 +705,38 @@ pub struct ApiError {
| 705 | 705 | #[serde(default)] |
| 706 | 706 | pub errors: std::collections::BTreeMap<String, String>, |
| 707 | 707 | } |
| 708 | + | |
| 709 | +// ── badges ─────────────────────────────────────────────────────────── | |
| 710 | + | |
| 711 | +/// `GET /repos/:owner/:name/badges`: every badge the repository has, | |
| 712 | +/// each with the Markdown and HTML that embed it. | |
| 713 | +#[derive(Debug, Clone, Deserialize)] | |
| 714 | +pub struct BadgesResponse { | |
| 715 | + pub repository: String, | |
| 716 | + /// The badges a README block starts with. | |
| 717 | + #[serde(default)] | |
| 718 | + pub defaults: Vec<String>, | |
| 719 | + #[serde(default)] | |
| 720 | + pub badges: Vec<Badge>, | |
| 721 | +} | |
| 722 | + | |
| 723 | +#[derive(Debug, Clone, Deserialize)] | |
| 724 | +pub struct Badge { | |
| 725 | + /// `ci`, `workflow:audit`, `package:cargo:gitgud-cli`. | |
| 726 | + pub key: String, | |
| 727 | + pub group: String, | |
| 728 | + pub title: String, | |
| 729 | + #[serde(default)] | |
| 730 | + pub hint: Option<String>, | |
| 731 | + #[serde(default)] | |
| 732 | + pub label: Option<String>, | |
| 733 | + /// What it says now; `None` for the GG mark. | |
| 734 | + #[serde(default)] | |
| 735 | + pub value: Option<String>, | |
| 736 | + /// success, warning, error, info or muted. | |
| 737 | + #[serde(default)] | |
| 738 | + pub status: Option<String>, | |
| 739 | + pub url: String, | |
| 740 | + pub markdown: String, | |
| 741 | + pub html: String, | |
| 742 | +} |
modified
cli/src/cli.rs
+87
−0
@@ -77,6 +77,11 @@ pub enum Command {
| 77 | 77 | #[command(visible_alias = "packages")] |
| 78 | 78 | Package(PackageArgs), |
| 79 | 79 | |
| 80 | + /// Status badges: list them, print a README block, or patch one into | |
| 81 | + /// a README in place. | |
| 82 | + #[command(visible_alias = "badge")] | |
| 83 | + Badges(BadgesArgs), | |
| 84 | + | |
| 80 | 85 | /// Interactive CI dashboard. |
| 81 | 86 | #[command(visible_aliases = ["tui", "ui"])] |
| 82 | 87 | Dashboard(DashboardArgs), |
@@ -805,3 +810,85 @@ mod tests {
| 805 | 810 | )); |
| 806 | 811 | } |
| 807 | 812 | } |
| 813 | + | |
| 814 | +// ── badges ─────────────────────────────────────────────────────────── | |
| 815 | + | |
| 816 | +#[derive(Debug, Args)] | |
| 817 | +pub struct BadgesArgs { | |
| 818 | + #[command(subcommand)] | |
| 819 | + pub command: Option<BadgesCommand>, | |
| 820 | +} | |
| 821 | + | |
| 822 | +#[derive(Debug, Subcommand)] | |
| 823 | +pub enum BadgesCommand { | |
| 824 | + /// Every badge the repository has, and what each says now (the default). | |
| 825 | + #[command(visible_alias = "ls")] | |
| 826 | + List, | |
| 827 | + | |
| 828 | + /// A README block of badges: print it, or patch it into a file. | |
| 829 | + /// | |
| 830 | + /// With --patch, the block goes between `<!-- gg:badges … -->` and | |
| 831 | + /// `<!-- /gg:badges -->`, replacing what's there, or under the first | |
| 832 | + /// heading if there's no block yet. The opening marker records the | |
| 833 | + /// badges and options, so running it again with no arguments keeps | |
| 834 | + /// them; arguments given override what it recorded. | |
| 835 | + Readme(ReadmeArgs), | |
| 836 | +} | |
| 837 | + | |
| 838 | +#[derive(Debug, Args)] | |
| 839 | +pub struct ReadmeArgs { | |
| 840 | + /// Badge keys, in order (see `gg badges`): `ci`, `release`, | |
| 841 | + /// `workflow:audit`, `package:cargo:gitgud-cli`. Default: what the | |
| 842 | + /// block already has, else the forge's usual four. | |
| 843 | + #[arg(value_name = "KEY")] | |
| 844 | + pub keys: Vec<String>, | |
| 845 | + | |
| 846 | + /// Every badge the repository has. | |
| 847 | + #[arg(long, conflicts_with = "keys")] | |
| 848 | + pub all: bool, | |
| 849 | + | |
| 850 | + /// split, dot or outline. | |
| 851 | + #[arg(long, value_name = "STYLE", value_parser = ["split", "dot", "outline"])] | |
| 852 | + pub style: Option<String>, | |
| 853 | + | |
| 854 | + /// round (the theme's radius) or square. | |
| 855 | + #[arg(long, value_name = "CORNERS", value_parser = ["round", "square"])] | |
| 856 | + pub corners: Option<String>, | |
| 857 | + | |
| 858 | + /// 1x, 2x or 2.5x. | |
| 859 | + #[arg(long, value_name = "SCALE", value_parser = ["1x", "2x", "2.5x"])] | |
| 860 | + pub scale: Option<String>, | |
| 861 | + | |
| 862 | + /// Lead each badge with the GG mark. | |
| 863 | + #[arg(long, conflicts_with = "no_gg")] | |
| 864 | + pub gg: bool, | |
| 865 | + | |
| 866 | + /// Drop the GG mark, where the block had it. | |
| 867 | + #[arg(long)] | |
| 868 | + pub no_gg: bool, | |
| 869 | + | |
| 870 | + /// auto (follows the reader's light or dark setting), or a theme by name. | |
| 871 | + #[arg(long, value_name = "THEME")] | |
| 872 | + pub theme: Option<String>, | |
| 873 | + | |
| 874 | + /// svg or png. | |
| 875 | + #[arg(long, value_name = "FORMAT", value_parser = ["svg", "png"])] | |
| 876 | + pub format: Option<String>, | |
| 877 | + | |
| 878 | + /// HTML with separate dark and light images, instead of Markdown. | |
| 879 | + #[arg(long, conflicts_with = "markdown")] | |
| 880 | + pub html: bool, | |
| 881 | + | |
| 882 | + /// Markdown, where the block was HTML. | |
| 883 | + #[arg(long)] | |
| 884 | + pub markdown: bool, | |
| 885 | + | |
| 886 | + /// Write the block into this file (e.g. README.md) instead of printing it. | |
| 887 | + #[arg(long, value_name = "FILE")] | |
| 888 | + pub patch: Option<std::path::PathBuf>, | |
| 889 | + | |
| 890 | + /// With --patch: change nothing, and exit 1 if the file's block is out | |
| 891 | + /// of date. For CI. | |
| 892 | + #[arg(long, requires = "patch")] | |
| 893 | + pub check: bool, | |
| 894 | +} |
added
cli/src/commands/badges.rs
+493
−0
@@ -0,0 +1,493 @@
| 1 | +//! Status badges: what a repository has, and the README block that shows | |
| 2 | +//! them. | |
| 3 | +//! | |
| 4 | +//! The block itself comes from the server (`GET …/badges`), the same | |
| 5 | +//! Markdown the badge sheet's README card builds, so a README patched | |
| 6 | +//! here and one pasted from the page say the same thing. | |
| 7 | +//! | |
| 8 | +//! `--patch` keeps the block between two markers: | |
| 9 | +//! | |
| 10 | +//! ```text | |
| 11 | +//! <!-- gg:badges ci coverage release license style=dot --> | |
| 12 | +//! [](…) [](…) … | |
| 13 | +//! <!-- /gg:badges --> | |
| 14 | +//! ``` | |
| 15 | +//! | |
| 16 | +//! The opening marker records which badges and which options, so a | |
| 17 | +//! second run (an agent's, a CI job's) needs no arguments to reproduce | |
| 18 | +//! it, and any given override what's recorded. Everything outside the | |
| 19 | +//! markers is left exactly as it was. | |
| 20 | + | |
| 21 | +use anyhow::{Context as _, Result, bail}; | |
| 22 | +use serde_json::json; | |
| 23 | +use std::path::Path; | |
| 24 | + | |
| 25 | +use crate::api::models::Badge; | |
| 26 | +use crate::cli::{BadgesCommand, ReadmeArgs}; | |
| 27 | +use crate::commands::Ctx; | |
| 28 | +use crate::output::{Table, Tone, note, paint, print_json, truncate}; | |
| 29 | + | |
| 30 | +const OPEN: &str = "<!-- gg:badges"; | |
| 31 | +const CLOSE: &str = "<!-- /gg:badges -->"; | |
| 32 | + | |
| 33 | +pub async fn run(ctx: &Ctx, command: Option<&BadgesCommand>) -> Result<()> { | |
| 34 | + match command { | |
| 35 | + None | Some(BadgesCommand::List) => list(ctx).await, | |
| 36 | + Some(BadgesCommand::Readme(args)) => readme(ctx, args).await, | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +async fn list(ctx: &Ctx) -> Result<()> { | |
| 41 | + let repo = ctx.repo()?; | |
| 42 | + let res = ctx.api.badges(&repo, &[]).await?; | |
| 43 | + if ctx.emitted(&res.raw)? { | |
| 44 | + return Ok(()); | |
| 45 | + } | |
| 46 | + | |
| 47 | + let mut table = Table::new(["KEY", "GROUP", "SAYS", "WHAT"]); | |
| 48 | + for badge in &res.badges { | |
| 49 | + let says = match (&badge.label, &badge.value) { | |
| 50 | + (Some(label), Some(value)) => format!("{label}: {value}"), | |
| 51 | + _ => "GG".to_string(), | |
| 52 | + }; | |
| 53 | + let marker = if res.defaults.contains(&badge.key) { | |
| 54 | + " *" | |
| 55 | + } else { | |
| 56 | + "" | |
| 57 | + }; | |
| 58 | + table.row([ | |
| 59 | + format!("{}{marker}", badge.key), | |
| 60 | + badge.group.clone(), | |
| 61 | + paint(&says, tone(badge.status.as_deref())), | |
| 62 | + truncate(badge.hint.as_deref().unwrap_or(&badge.title), 40), | |
| 63 | + ]); | |
| 64 | + } | |
| 65 | + table.print(); | |
| 66 | + note("* in a README block by default. `gg badges readme [KEY…]` builds one."); | |
| 67 | + Ok(()) | |
| 68 | +} | |
| 69 | + | |
| 70 | +fn tone(status: Option<&str>) -> Tone { | |
| 71 | + match status { | |
| 72 | + Some("success") => Tone::Good, | |
| 73 | + Some("error") => Tone::Bad, | |
| 74 | + Some("warning") => Tone::Warn, | |
| 75 | + Some("info") => Tone::Accent, | |
| 76 | + Some("muted") => Tone::Muted, | |
| 77 | + _ => Tone::Plain, | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +async fn readme(ctx: &Ctx, args: &ReadmeArgs) -> Result<()> { | |
| 82 | + let repo = ctx.repo()?; | |
| 83 | + | |
| 84 | + // What the file's block already says, when patching one. | |
| 85 | + let existing = match &args.patch { | |
| 86 | + Some(path) => Some(read_existing(path)?), | |
| 87 | + None => None, | |
| 88 | + }; | |
| 89 | + let recorded = existing | |
| 90 | + .as_deref() | |
| 91 | + .and_then(find_block) | |
| 92 | + .map(|b| b.recorded) | |
| 93 | + .unwrap_or_default(); | |
| 94 | + | |
| 95 | + let opts = Options::merge(args, &recorded.options); | |
| 96 | + let res = ctx.api.badges(&repo, &opts.query()).await?; | |
| 97 | + | |
| 98 | + let keys: Vec<String> = if args.all { | |
| 99 | + res.badges.iter().map(|b| b.key.clone()).collect() | |
| 100 | + } else if !args.keys.is_empty() { | |
| 101 | + args.keys.clone() | |
| 102 | + } else if !recorded.keys.is_empty() { | |
| 103 | + recorded.keys.clone() | |
| 104 | + } else { | |
| 105 | + res.defaults.clone() | |
| 106 | + }; | |
| 107 | + | |
| 108 | + let chosen = pick(&res.badges, &keys)?; | |
| 109 | + // HTML if asked for, or if the block was HTML and Markdown wasn't. | |
| 110 | + let html = args.html || (recorded.html && !args.markdown); | |
| 111 | + let body = render(&chosen, html); | |
| 112 | + | |
| 113 | + let Some(path) = &args.patch else { | |
| 114 | + if ctx.json { | |
| 115 | + return print_json(&json!({ | |
| 116 | + "repository": res.repository, | |
| 117 | + "keys": keys, | |
| 118 | + "options": opts.as_json(), | |
| 119 | + "format": if html { "html" } else { "markdown" }, | |
| 120 | + "block": body, | |
| 121 | + })); | |
| 122 | + } | |
| 123 | + println!("{body}"); | |
| 124 | + return Ok(()); | |
| 125 | + }; | |
| 126 | + | |
| 127 | + let before = existing.unwrap_or_default(); | |
| 128 | + let marker = opening_marker(&keys, &opts, html); | |
| 129 | + let after = patch(&before, &marker, &body); | |
| 130 | + let changed = after != before; | |
| 131 | + | |
| 132 | + if ctx.json { | |
| 133 | + print_json(&json!({ | |
| 134 | + "file": path, | |
| 135 | + "changed": changed, | |
| 136 | + "checked": args.check, | |
| 137 | + "keys": keys, | |
| 138 | + "options": opts.as_json(), | |
| 139 | + }))?; | |
| 140 | + } | |
| 141 | + | |
| 142 | + if args.check { | |
| 143 | + if changed { | |
| 144 | + bail!( | |
| 145 | + "{}'s badges are out of date: run `gg badges readme --patch {}`", | |
| 146 | + path.display(), | |
| 147 | + path.display() | |
| 148 | + ); | |
| 149 | + } | |
| 150 | + if !ctx.json { | |
| 151 | + eprintln!("{} is up to date.", path.display()); | |
| 152 | + } | |
| 153 | + return Ok(()); | |
| 154 | + } | |
| 155 | + | |
| 156 | + if changed { | |
| 157 | + std::fs::write(path, &after).with_context(|| format!("writing {}", path.display()))?; | |
| 158 | + if !ctx.json { | |
| 159 | + eprintln!( | |
| 160 | + "Updated {}: {} badge{}.", | |
| 161 | + path.display(), | |
| 162 | + chosen.len(), | |
| 163 | + if chosen.len() == 1 { "" } else { "s" } | |
| 164 | + ); | |
| 165 | + } | |
| 166 | + } else if !ctx.json { | |
| 167 | + eprintln!("{} is already up to date.", path.display()); | |
| 168 | + } | |
| 169 | + Ok(()) | |
| 170 | +} | |
| 171 | + | |
| 172 | +/// A missing file is an empty one: `--patch NEW.md` creates it. | |
| 173 | +fn read_existing(path: &Path) -> Result<String> { | |
| 174 | + match std::fs::read_to_string(path) { | |
| 175 | + Ok(text) => Ok(text), | |
| 176 | + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), | |
| 177 | + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), | |
| 178 | + } | |
| 179 | +} | |
| 180 | + | |
| 181 | +/// The badges `keys` name, in that order. An unknown key is an error that | |
| 182 | +/// lists the ones there are, since a typo otherwise drops a badge silently. | |
| 183 | +fn pick<'a>(badges: &'a [Badge], keys: &[String]) -> Result<Vec<&'a Badge>> { | |
| 184 | + keys.iter() | |
| 185 | + .map(|key| { | |
| 186 | + badges.iter().find(|b| &b.key == key).ok_or_else(|| { | |
| 187 | + let known: Vec<&str> = badges.iter().map(|b| b.key.as_str()).collect(); | |
| 188 | + anyhow::anyhow!("no badge {key:?} here; there are: {}", known.join(", ")) | |
| 189 | + }) | |
| 190 | + }) | |
| 191 | + .collect() | |
| 192 | +} | |
| 193 | + | |
| 194 | +/// Markdown on one line (a row of badges, as READMEs have them), or HTML | |
| 195 | +/// one badge per element. | |
| 196 | +fn render(badges: &[&Badge], html: bool) -> String { | |
| 197 | + if html { | |
| 198 | + badges | |
| 199 | + .iter() | |
| 200 | + .map(|b| b.html.as_str()) | |
| 201 | + .collect::<Vec<_>>() | |
| 202 | + .join("\n") | |
| 203 | + } else { | |
| 204 | + badges | |
| 205 | + .iter() | |
| 206 | + .map(|b| b.markdown.as_str()) | |
| 207 | + .collect::<Vec<_>>() | |
| 208 | + .join(" ") | |
| 209 | + } | |
| 210 | +} | |
| 211 | + | |
| 212 | +// ── options ────────────────────────────────────────────────────────── | |
| 213 | + | |
| 214 | +/// The display options, as recorded in a marker or given as flags. | |
| 215 | +#[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 216 | +struct Options { | |
| 217 | + style: Option<String>, | |
| 218 | + corners: Option<String>, | |
| 219 | + scale: Option<String>, | |
| 220 | + gg: bool, | |
| 221 | + theme: Option<String>, | |
| 222 | + format: Option<String>, | |
| 223 | +} | |
| 224 | + | |
| 225 | +impl Options { | |
| 226 | + /// Flags given win; anything not given keeps what the block recorded. | |
| 227 | + fn merge(args: &ReadmeArgs, recorded: &Options) -> Self { | |
| 228 | + Self { | |
| 229 | + style: args.style.clone().or_else(|| recorded.style.clone()), | |
| 230 | + corners: args.corners.clone().or_else(|| recorded.corners.clone()), | |
| 231 | + scale: args.scale.clone().or_else(|| recorded.scale.clone()), | |
| 232 | + gg: if args.gg { | |
| 233 | + true | |
| 234 | + } else if args.no_gg { | |
| 235 | + false | |
| 236 | + } else { | |
| 237 | + recorded.gg | |
| 238 | + }, | |
| 239 | + theme: args.theme.clone().or_else(|| recorded.theme.clone()), | |
| 240 | + format: args.format.clone().or_else(|| recorded.format.clone()), | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + fn query(&self) -> Vec<(&'static str, String)> { | |
| 245 | + let mut q = Vec::new(); | |
| 246 | + for (name, value) in self.fields() { | |
| 247 | + if let Some(v) = value { | |
| 248 | + q.push((name, v.to_string())); | |
| 249 | + } | |
| 250 | + } | |
| 251 | + if self.gg { | |
| 252 | + q.push(("gg", "true".into())); | |
| 253 | + } | |
| 254 | + q | |
| 255 | + } | |
| 256 | + | |
| 257 | + fn fields(&self) -> [(&'static str, Option<&str>); 5] { | |
| 258 | + [ | |
| 259 | + ("style", self.style.as_deref()), | |
| 260 | + ("corners", self.corners.as_deref()), | |
| 261 | + ("scale", self.scale.as_deref()), | |
| 262 | + ("theme", self.theme.as_deref()), | |
| 263 | + ("format", self.format.as_deref()), | |
| 264 | + ] | |
| 265 | + } | |
| 266 | + | |
| 267 | + fn as_json(&self) -> serde_json::Value { | |
| 268 | + json!({ | |
| 269 | + "style": self.style, "corners": self.corners, "scale": self.scale, | |
| 270 | + "gg": self.gg, "theme": self.theme, "format": self.format, | |
| 271 | + }) | |
| 272 | + } | |
| 273 | +} | |
| 274 | + | |
| 275 | +// ── the block ──────────────────────────────────────────────────────── | |
| 276 | + | |
| 277 | +/// What an opening marker recorded. | |
| 278 | +#[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 279 | +struct Recorded { | |
| 280 | + keys: Vec<String>, | |
| 281 | + options: Options, | |
| 282 | + html: bool, | |
| 283 | +} | |
| 284 | + | |
| 285 | +struct Block { | |
| 286 | + /// Byte range of the whole block, both markers included. | |
| 287 | + start: usize, | |
| 288 | + end: usize, | |
| 289 | + recorded: Recorded, | |
| 290 | +} | |
| 291 | + | |
| 292 | +/// `<!-- gg:badges ci release style=dot gg html -->`: the keys, then | |
| 293 | +/// each option that was set. Keys are percent-encoded where they'd | |
| 294 | +/// otherwise break the comment or the parse (spaces, `=`, `>`). | |
| 295 | +fn opening_marker(keys: &[String], opts: &Options, html: bool) -> String { | |
| 296 | + let mut parts: Vec<String> = keys.iter().map(|k| encode(k)).collect(); | |
| 297 | + for (name, value) in opts.fields() { | |
| 298 | + if let Some(v) = value { | |
| 299 | + parts.push(format!("{name}={}", encode(v))); | |
| 300 | + } | |
| 301 | + } | |
| 302 | + if opts.gg { | |
| 303 | + parts.push("gg".into()); | |
| 304 | + } | |
| 305 | + if html { | |
| 306 | + parts.push("html".into()); | |
| 307 | + } | |
| 308 | + format!("{OPEN} {} -->", parts.join(" ")) | |
| 309 | +} | |
| 310 | + | |
| 311 | +fn parse_marker(inner: &str) -> Recorded { | |
| 312 | + let mut rec = Recorded::default(); | |
| 313 | + for token in inner.split_whitespace() { | |
| 314 | + match token.split_once('=') { | |
| 315 | + Some(("style", v)) => rec.options.style = Some(decode(v)), | |
| 316 | + Some(("corners", v)) => rec.options.corners = Some(decode(v)), | |
| 317 | + Some(("scale", v)) => rec.options.scale = Some(decode(v)), | |
| 318 | + Some(("theme", v)) => rec.options.theme = Some(decode(v)), | |
| 319 | + Some(("format", v)) => rec.options.format = Some(decode(v)), | |
| 320 | + Some(_) => {} | |
| 321 | + None if token == "gg" => rec.options.gg = true, | |
| 322 | + None if token == "html" => rec.html = true, | |
| 323 | + None => rec.keys.push(decode(token)), | |
| 324 | + } | |
| 325 | + } | |
| 326 | + rec | |
| 327 | +} | |
| 328 | + | |
| 329 | +fn find_block(text: &str) -> Option<Block> { | |
| 330 | + let start = text.find(OPEN)?; | |
| 331 | + let open_end = start + text[start..].find("-->")? + 3; | |
| 332 | + let inner = &text[start + OPEN.len()..open_end - 3]; | |
| 333 | + let close = open_end + text[open_end..].find(CLOSE)?; | |
| 334 | + Some(Block { | |
| 335 | + start, | |
| 336 | + end: close + CLOSE.len(), | |
| 337 | + recorded: parse_marker(inner), | |
| 338 | + }) | |
| 339 | +} | |
| 340 | + | |
| 341 | +/// `text` with its badge block replaced by `marker` + `body`, or, with no | |
| 342 | +/// block yet, given one: under the first `# ` heading if there is one, | |
| 343 | +/// else at the top. | |
| 344 | +fn patch(text: &str, marker: &str, body: &str) -> String { | |
| 345 | + let block = format!("{marker}\n{body}\n{CLOSE}"); | |
| 346 | + | |
| 347 | + if let Some(found) = find_block(text) { | |
| 348 | + return format!("{}{block}{}", &text[..found.start], &text[found.end..]); | |
| 349 | + } | |
| 350 | + | |
| 351 | + if let Some(at) = after_first_heading(text) { | |
| 352 | + let (head, tail) = text.split_at(at); | |
| 353 | + let tail = tail.trim_start_matches('\n'); | |
| 354 | + return if tail.is_empty() { | |
| 355 | + format!("{head}\n{block}\n") | |
| 356 | + } else { | |
| 357 | + format!("{head}\n{block}\n\n{tail}") | |
| 358 | + }; | |
| 359 | + } | |
| 360 | + | |
| 361 | + if text.is_empty() { | |
| 362 | + format!("{block}\n") | |
| 363 | + } else { | |
| 364 | + format!("{block}\n\n{text}") | |
| 365 | + } | |
| 366 | +} | |
| 367 | + | |
| 368 | +/// Byte offset just past the first ATX `# ` heading's line (its newline | |
| 369 | +/// included), outside fenced code. | |
| 370 | +fn after_first_heading(text: &str) -> Option<usize> { | |
| 371 | + let mut offset = 0; | |
| 372 | + let mut fenced = false; | |
| 373 | + for line in text.split_inclusive('\n') { | |
| 374 | + let trimmed = line.trim_start(); | |
| 375 | + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { | |
| 376 | + fenced = !fenced; | |
| 377 | + } else if !fenced && (trimmed.starts_with("# ") || trimmed.trim_end() == "#") { | |
| 378 | + return Some(offset + line.len()); | |
| 379 | + } | |
| 380 | + offset += line.len(); | |
| 381 | + } | |
| 382 | + None | |
| 383 | +} | |
| 384 | + | |
| 385 | +fn encode(s: &str) -> String { | |
| 386 | + let mut out = String::with_capacity(s.len()); | |
| 387 | + for c in s.chars() { | |
| 388 | + match c { | |
| 389 | + '%' | '=' | '>' | '<' => out.push_str(&format!("%{:02X}", c as u32)), | |
| 390 | + c if c.is_whitespace() => out.push_str(&format!("%{:02X}", c as u32)), | |
| 391 | + c => out.push(c), | |
| 392 | + } | |
| 393 | + } | |
| 394 | + out | |
| 395 | +} | |
| 396 | + | |
| 397 | +fn decode(s: &str) -> String { | |
| 398 | + let bytes = s.as_bytes(); | |
| 399 | + let mut out = Vec::with_capacity(bytes.len()); | |
| 400 | + let mut i = 0; | |
| 401 | + while i < bytes.len() { | |
| 402 | + if bytes[i] == b'%' | |
| 403 | + && i + 2 < bytes.len() | |
| 404 | + && let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) | |
| 405 | + && let Ok(b) = u8::from_str_radix(hex, 16) | |
| 406 | + { | |
| 407 | + out.push(b); | |
| 408 | + i += 3; | |
| 409 | + } else { | |
| 410 | + out.push(bytes[i]); | |
| 411 | + i += 1; | |
| 412 | + } | |
| 413 | + } | |
| 414 | + String::from_utf8_lossy(&out).into_owned() | |
| 415 | +} | |
| 416 | + | |
| 417 | +#[cfg(test)] | |
| 418 | +mod tests { | |
| 419 | + use super::*; | |
| 420 | + | |
| 421 | + fn opts() -> Options { | |
| 422 | + Options::default() | |
| 423 | + } | |
| 424 | + | |
| 425 | + #[test] | |
| 426 | + fn markers_round_trip() { | |
| 427 | + let keys = vec!["ci".to_string(), "workflow:nightly audit".into()]; | |
| 428 | + let o = Options { | |
| 429 | + style: Some("dot".into()), | |
| 430 | + gg: true, | |
| 431 | + ..opts() | |
| 432 | + }; | |
| 433 | + let marker = opening_marker(&keys, &o, true); | |
| 434 | + assert_eq!( | |
| 435 | + marker, | |
| 436 | + "<!-- gg:badges ci workflow:nightly%20audit style=dot gg html -->" | |
| 437 | + ); | |
| 438 | + | |
| 439 | + let text = format!("{marker}\nx\n{CLOSE}"); | |
| 440 | + let found = find_block(&text).unwrap(); | |
| 441 | + assert_eq!(found.recorded.keys, keys); | |
| 442 | + assert_eq!(found.recorded.options, o); | |
| 443 | + assert!(found.recorded.html); | |
| 444 | + } | |
| 445 | + | |
| 446 | + #[test] | |
| 447 | + fn a_new_block_goes_under_the_first_heading() { | |
| 448 | + let text = "# Widgets\n\nDoes things.\n"; | |
| 449 | + let out = patch(text, "<!-- gg:badges ci -->", "[](l)"); | |
| 450 | + assert_eq!( | |
| 451 | + out, | |
| 452 | + "# Widgets\n\n<!-- gg:badges ci -->\n[](l)\n<!-- /gg:badges -->\n\nDoes things.\n" | |
| 453 | + ); | |
| 454 | + } | |
| 455 | + | |
| 456 | + #[test] | |
| 457 | + fn headings_in_code_fences_dont_count() { | |
| 458 | + let text = "```sh\n# not a heading\n```\n\n# Real\n"; | |
| 459 | + let at = after_first_heading(text).unwrap(); | |
| 460 | + assert_eq!(&text[..at], "```sh\n# not a heading\n```\n\n# Real\n"); | |
| 461 | + } | |
| 462 | + | |
| 463 | + #[test] | |
| 464 | + fn with_no_heading_it_goes_on_top() { | |
| 465 | + assert_eq!( | |
| 466 | + patch("Hello.\n", "<!-- gg:badges ci -->", "B"), | |
| 467 | + "<!-- gg:badges ci -->\nB\n<!-- /gg:badges -->\n\nHello.\n" | |
| 468 | + ); | |
| 469 | + assert_eq!( | |
| 470 | + patch("", "<!-- gg:badges ci -->", "B"), | |
| 471 | + "<!-- gg:badges ci -->\nB\n<!-- /gg:badges -->\n" | |
| 472 | + ); | |
| 473 | + } | |
| 474 | + | |
| 475 | + #[test] | |
| 476 | + fn an_existing_block_is_replaced_and_nothing_else_moves() { | |
| 477 | + let text = "# W\n\n<!-- gg:badges ci -->\nold\n<!-- /gg:badges -->\n\nBody.\n"; | |
| 478 | + let out = patch(text, "<!-- gg:badges ci release -->", "new"); | |
| 479 | + assert_eq!( | |
| 480 | + out, | |
| 481 | + "# W\n\n<!-- gg:badges ci release -->\nnew\n<!-- /gg:badges -->\n\nBody.\n" | |
| 482 | + ); | |
| 483 | + // And patching with the same again changes nothing. | |
| 484 | + assert_eq!(patch(&out, "<!-- gg:badges ci release -->", "new"), out); | |
| 485 | + } | |
| 486 | + | |
| 487 | + #[test] | |
| 488 | + fn decoding_leaves_stray_percents_alone() { | |
| 489 | + assert_eq!(decode("100%"), "100%"); | |
| 490 | + assert_eq!(decode("a%2"), "a%2"); | |
| 491 | + assert_eq!(decode(&encode("x = <y> %z")), "x = <y> %z"); | |
| 492 | + } | |
| 493 | +} |
modified
cli/src/commands/mod.rs
+1
−0
@@ -2,6 +2,7 @@
| 2 | 2 | |
| 3 | 3 | pub mod audit; |
| 4 | 4 | pub mod auth; |
| 5 | +pub mod badges; | |
| 5 | 6 | pub mod ci; |
| 6 | 7 | pub mod issue; |
| 7 | 8 | pub mod label; |
modified
cli/src/main.rs
+1
−0
@@ -91,6 +91,7 @@ async fn run() -> Result<()> {
| 91 | 91 | Command::Labels(labels) => commands::label::run(&ctx, labels.command.as_ref()).await, |
| 92 | 92 | Command::Wiki(wiki) => commands::wiki::run(&ctx, &wiki.command).await, |
| 93 | 93 | Command::Package(package) => commands::package::run(&ctx, &package.command).await, |
| 94 | + Command::Badges(badges) => commands::badges::run(&ctx, badges.command.as_ref()).await, | |
| 94 | 95 | Command::Dashboard(dashboard) => tui::run(&ctx, dashboard).await, |
| 95 | 96 | Command::Audit(audit) => commands::audit::run(&ctx, audit).await, |
| 96 | 97 | } |
Parents: be6c752