gg badges: list a repository's badges, and patch a README's

8e00315 · gmorell · 2026-11-10 00:12

11 files +654 -0
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 +[![CI status](…)](…) [![coverage](…)](…) …
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 +//! [![CI status](…)](…) [![coverage](…)](…) …
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 -->", "[![ci](u)](l)");
450 + assert_eq!(
451 + out,
452 + "# Widgets\n\n<!-- gg:badges ci -->\n[![ci](u)](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