use std::fmt::Write as _;
use anyhow::Context as _;
use serde::Deserialize;
use crate::commands::ticket::runner::CommandRunner;
#[derive(Debug, Deserialize)]
struct GhProject {
#[serde(default)]
title: String,
#[serde(default)]
number: u64,
#[serde(default)]
closed: bool,
}
#[derive(Debug, Deserialize)]
struct GhProjectList {
#[serde(default)]
projects: Vec<GhProject>,
}
pub(crate) fn render_filing_targets(runner: &dyn CommandRunner) -> String {
let mut out = String::new();
out.push_str("\nfiling targets — live, from gh:\n");
match fetch_milestones(runner) {
Ok(titles) => {
let _ = writeln!(out, " open milestones ({}):", titles.len());
for title in &titles {
let _ = writeln!(out, " {title}");
}
}
Err(e) => {
let _ = writeln!(out, " milestones: unavailable ({})", one_line(&e));
}
}
match fetch_projects(runner) {
Ok(projects) => {
let _ = writeln!(out, " open projects ({}):", projects.len());
for (number, title) in &projects {
let _ = writeln!(out, " #{number} {title}");
}
}
Err(e) => {
let _ = writeln!(out, " projects: unavailable ({})", one_line(&e));
}
}
out
}
pub(crate) fn one_line(err: &anyhow::Error) -> String {
err.to_string()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("; ")
}
fn fetch_milestones(runner: &dyn CommandRunner) -> anyhow::Result<Vec<String>> {
let out = runner.run(
"gh",
&[
"api",
"repos/{owner}/{repo}/milestones",
"--paginate",
"--jq",
".[].title",
],
)?;
let text = out.ok_or_stderr("gh api repos/{owner}/{repo}/milestones")?;
Ok(text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect())
}
const PROJECT_LIST_LIMIT: &str = "200";
fn fetch_projects(runner: &dyn CommandRunner) -> anyhow::Result<Vec<(u64, String)>> {
match fetch_projects_owner_scoped(runner) {
Ok(projects) => Ok(projects),
Err(e) if is_scope_shaped_failure(&e) => fetch_projects_bare(runner).map_err(|_| e),
Err(e) => Err(e),
}
}
fn fetch_projects_owner_scoped(runner: &dyn CommandRunner) -> anyhow::Result<Vec<(u64, String)>> {
let owner_out = runner.run(
"gh",
&["repo", "view", "--json", "owner", "--jq", ".owner.login"],
)?;
let owner = owner_out.ok_or_stderr("gh repo view --json owner")?;
if owner.is_empty() {
anyhow::bail!("`gh repo view` named no owner — is this a GitHub repository?");
}
let list_out = runner.run(
"gh",
&[
"project",
"list",
"--owner",
&owner,
"-L",
PROJECT_LIST_LIMIT,
"--format",
"json",
],
)?;
let text = list_out.ok_or_stderr("gh project list")?;
parse_project_list(&text, &format!("`gh project list --owner {owner}`"))
}
fn fetch_projects_bare(runner: &dyn CommandRunner) -> anyhow::Result<Vec<(u64, String)>> {
let list_out = runner.run(
"gh",
&[
"project",
"list",
"-L",
PROJECT_LIST_LIMIT,
"--format",
"json",
],
)?;
let text = list_out.ok_or_stderr("gh project list")?;
parse_project_list(&text, "`gh project list`")
}
fn parse_project_list(text: &str, source: &str) -> anyhow::Result<Vec<(u64, String)>> {
let parsed: GhProjectList =
serde_json::from_str(text).with_context(|| format!("could not parse {source} output"))?;
Ok(parsed
.projects
.into_iter()
.filter(|p| !p.closed)
.map(|p| (p.number, p.title))
.collect())
}
fn is_scope_shaped_failure(err: &anyhow::Error) -> bool {
let text = err.to_string().to_lowercase();
(text.contains("scope") && text.contains("project")) || text.contains("read:project")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::ticket::runner::CommandOutput;
use std::cell::RefCell;
struct FakeRunner {
outputs: RefCell<Vec<anyhow::Result<CommandOutput>>>,
calls: RefCell<Vec<Vec<String>>>,
}
impl FakeRunner {
fn new(outputs: Vec<anyhow::Result<CommandOutput>>) -> Self {
Self {
outputs: RefCell::new(outputs),
calls: RefCell::new(Vec::new()),
}
}
}
impl CommandRunner for FakeRunner {
fn run(&self, program: &str, args: &[&str]) -> anyhow::Result<CommandOutput> {
let mut call = vec![program.to_string()];
call.extend(args.iter().map(|a| (*a).to_string()));
self.calls.borrow_mut().push(call);
let mut outs = self.outputs.borrow_mut();
if outs.is_empty() {
anyhow::bail!("FakeRunner exhausted")
}
outs.remove(0)
}
}
fn ok_out(stdout: &str) -> anyhow::Result<CommandOutput> {
Ok(CommandOutput {
success: true,
stdout: stdout.to_string(),
stderr: String::new(),
})
}
fn fail_out(stderr: &str) -> anyhow::Result<CommandOutput> {
Ok(CommandOutput {
success: false,
stdout: String::new(),
stderr: stderr.to_string(),
})
}
const PROJECTS_JSON: &str = r#"{"projects":[
{"number":3,"title":"trusty-mpm","closed":false},
{"number":9,"title":"Retired 2025","closed":true}
]}"#;
#[test]
fn filing_targets_list_open_milestones_and_projects() {
let runner = FakeRunner::new(vec![
ok_out("Backlog · mpm/core\nmpm 1.4\n"),
ok_out("bobmatnyc"),
ok_out(PROJECTS_JSON),
]);
let text = render_filing_targets(&runner);
assert!(text.contains("open milestones (2):"), "{text}");
assert!(text.contains("Backlog · mpm/core"), "{text}");
assert!(text.contains("open projects (1):"), "{text}");
assert!(text.contains("#3 trusty-mpm"), "{text}");
let calls = runner.calls.borrow();
assert!(
calls[2].contains(&"bobmatnyc".to_string()),
"{:?}",
calls[2]
);
}
#[test]
fn filing_targets_skip_closed_projects() {
let runner = FakeRunner::new(vec![ok_out(""), ok_out("bobmatnyc"), ok_out(PROJECTS_JSON)]);
let text = render_filing_targets(&runner);
assert!(!text.contains("Retired 2025"), "{text}");
}
#[test]
fn project_list_carries_an_explicit_limit() {
let runner = FakeRunner::new(vec![ok_out(""), ok_out("bobmatnyc"), ok_out(PROJECTS_JSON)]);
let _ = render_filing_targets(&runner);
let calls = runner.calls.borrow();
let argv = &calls[2];
let flag = argv
.iter()
.position(|a| a == "-L")
.unwrap_or_else(|| panic!("`gh project list` carries -L: {argv:?}"));
assert_eq!(
argv.get(flag + 1).map(String::as_str),
Some(PROJECT_LIST_LIMIT),
"{argv:?}"
);
assert!(
PROJECT_LIST_LIMIT.parse::<u32>().is_ok_and(|n| n > 30),
"the limit must exceed gh's silent default of 30: {PROJECT_LIST_LIMIT}"
);
}
#[test]
fn filing_targets_report_a_milestone_fetch_failure() {
let runner = FakeRunner::new(vec![
fail_out("gh: Not Found (HTTP 404)"),
ok_out("bobmatnyc"),
ok_out(PROJECTS_JSON),
]);
let text = render_filing_targets(&runner);
assert!(text.contains("milestones: unavailable ("), "{text}");
assert!(text.contains("HTTP 404"), "{text}");
assert!(!text.contains("open milestones (0)"), "{text}");
}
#[test]
fn filing_targets_report_a_project_fetch_failure() {
let runner = FakeRunner::new(vec![
ok_out("mpm 1.4\n"),
fail_out("gh: your token has not been granted the required scopes: `project`"),
]);
let text = render_filing_targets(&runner);
assert!(text.contains("projects: unavailable ("), "{text}");
assert!(text.contains("project"), "{text}");
assert!(text.contains("open milestones (1):"), "{text}");
}
const SCOPE_ERROR: &str = "gh: your token has not been granted the required scopes: \
the 'read:project' scope is required";
#[test]
fn filing_targets_fall_back_to_the_bare_project_list_on_a_scope_error() {
let runner = FakeRunner::new(vec![
ok_out("Backlog · mpm/core\n"),
ok_out("bobmatnyc"),
fail_out(SCOPE_ERROR),
ok_out(PROJECTS_JSON),
]);
let text = render_filing_targets(&runner);
assert!(text.contains("open projects (1):"), "{text}");
assert!(text.contains("#3 trusty-mpm"), "{text}");
assert!(!text.contains("projects: unavailable"), "{text}");
let calls = runner.calls.borrow();
let fallback = calls.last().expect("a fallback call was made");
assert!(!fallback.contains(&"--owner".to_string()), "{fallback:?}");
}
#[test]
fn a_non_scope_project_failure_does_not_fall_back() {
let runner = FakeRunner::new(vec![
ok_out("Backlog · mpm/core\n"),
ok_out("bobmatnyc"),
fail_out("gh: not authenticated"),
]);
let text = render_filing_targets(&runner);
assert!(text.contains("projects: unavailable ("), "{text}");
assert!(text.contains("not authenticated"), "{text}");
assert_eq!(runner.calls.borrow().len(), 3, "no fallback call was made");
}
#[test]
fn a_multi_line_gh_error_stays_one_line() {
let runner = FakeRunner::new(vec![fail_out("first\n\nsecond"), fail_out("boom")]);
let text = render_filing_targets(&runner);
let line = text
.lines()
.find(|l| l.contains("milestones: unavailable"))
.expect("the unavailable line renders");
assert!(line.contains("first; second"), "{line}");
}
}