1use anyhow::{bail, Context, Result};
2use std::path::Path;
3
4pub fn gh_username() -> Option<String> {
5 std::process::Command::new("gh")
6 .args(["api", "user", "-q", ".login"])
7 .output()
8 .ok()
9 .filter(|o| o.status.success())
10 .and_then(|o| {
11 let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
12 if s.is_empty() { None } else { Some(s) }
13 })
14}
15
16pub fn fetch_authenticated_user(token: &str) -> Result<String> {
17 let client = reqwest::blocking::Client::new();
18 let resp: serde_json::Value = client
19 .get("https://api.github.com/user")
20 .header("Authorization", format!("Bearer {token}"))
21 .header("Accept", "application/vnd.github+json")
22 .header("User-Agent", "apm")
23 .send()
24 .context("GitHub API request failed")?
25 .error_for_status()
26 .context("GitHub API returned error status")?
27 .json()
28 .context("GitHub API response is not valid JSON")?;
29 resp["login"]
30 .as_str()
31 .map(|s| s.to_string())
32 .context("GitHub API response missing 'login' field")
33}
34
35pub fn fetch_repo_collaborators(token: &str, repo: &str) -> Result<Vec<String>> {
36 let client = reqwest::blocking::Client::new();
37 let url = format!("https://api.github.com/repos/{repo}/collaborators");
38 let resp: serde_json::Value = client
39 .get(&url)
40 .header("Authorization", format!("Bearer {token}"))
41 .header("Accept", "application/vnd.github+json")
42 .header("User-Agent", "apm")
43 .send()
44 .context("GitHub API request failed")?
45 .error_for_status()
46 .context("GitHub API returned error status")?
47 .json()
48 .context("GitHub API response is not valid JSON")?;
49 let logins = resp
50 .as_array()
51 .context("GitHub API response is not an array")?
52 .iter()
53 .filter_map(|v| v["login"].as_str().map(|s| s.to_string()))
54 .collect();
55 Ok(logins)
56}
57
58pub fn gh_pr_create_or_update(root: &Path, branch: &str, default_branch: &str, id: &str, title: &str, body: &str, messages: &mut Vec<String>) -> Result<()> {
59 let existing = std::process::Command::new("gh")
60 .args(["pr", "list", "--head", branch, "--state", "open", "--json", "number", "--jq", ".[0].number"])
61 .current_dir(root)
62 .output()?;
63
64 let pr_num = String::from_utf8_lossy(&existing.stdout).trim().to_string();
65 if !pr_num.is_empty() && pr_num != "null" {
66 messages.push(format!("PR #{pr_num} already open for {branch}"));
67 return Ok(());
68 }
69
70 let title_str = pr_title(id, title);
71 let out = std::process::Command::new("gh")
72 .args(["pr", "create", "--base", default_branch, "--head", branch,
73 "--title", &title_str, "--body", body])
74 .current_dir(root)
75 .output()?;
76
77 if out.status.success() {
78 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
79 messages.push(format!("PR created: {url}"));
80 } else {
81 bail!("gh pr create failed: {}", String::from_utf8_lossy(&out.stderr).trim());
82 }
83 Ok(())
84}
85
86fn pr_title(id: &str, title: &str) -> String {
87 let short_id = &id[..8.min(id.len())];
88 if title.is_empty() {
89 short_id.to_string()
90 } else {
91 format!("{short_id}: {title}")
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 #[ignore]
101 fn fetch_authenticated_user_live() {
102 let token = std::env::var("GITHUB_TOKEN").expect("GITHUB_TOKEN required");
103 let login = fetch_authenticated_user(&token).unwrap();
104 assert!(!login.is_empty());
105 }
106
107 #[test]
108 fn pr_title_includes_short_id_prefix() {
109 let id = "034ed345-apm-state-include-ticket-id-in-github-pr";
110 assert_eq!(pr_title(id, "Fix the thing"), "034ed345: Fix the thing");
111 }
112
113 #[test]
114 fn pr_title_empty_title_falls_back_to_short_id() {
115 let id = "034ed345-apm-state-include-ticket-id-in-github-pr";
116 assert_eq!(pr_title(id, ""), "034ed345");
117 }
118
119 #[test]
120 fn pr_title_short_id_exactly_8_chars() {
121 let id = "abcd1234efgh";
122 assert_eq!(pr_title(id, "My ticket"), "abcd1234: My ticket");
123 }
124}