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
86pub fn gh_pr_create_or_update_between(
87 root: &Path,
88 head: &str,
89 base: &str,
90 title: &str,
91 body: &str,
92 messages: &mut Vec<String>,
93) -> Result<()> {
94 let existing = std::process::Command::new("gh")
95 .args(["pr", "list", "--head", head, "--base", base, "--state", "open", "--json", "number", "--jq", ".[0].number"])
96 .current_dir(root)
97 .output()?;
98
99 let pr_num = String::from_utf8_lossy(&existing.stdout).trim().to_string();
100 if !pr_num.is_empty() && pr_num != "null" {
101 messages.push(format!("PR #{pr_num} already open ({head} → {base})"));
102 return Ok(());
103 }
104
105 let out = std::process::Command::new("gh")
106 .args(["pr", "create", "--base", base, "--head", head, "--title", title, "--body", body])
107 .current_dir(root)
108 .output()?;
109
110 if out.status.success() {
111 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
112 messages.push(format!("PR created: {url}"));
113 } else {
114 bail!("gh pr create failed: {}", String::from_utf8_lossy(&out.stderr).trim());
115 }
116 Ok(())
117}
118
119fn pr_title(id: &str, title: &str) -> String {
120 let short_id = &id[..8.min(id.len())];
121 if title.is_empty() {
122 short_id.to_string()
123 } else {
124 format!("{short_id}: {title}")
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 #[ignore]
134 fn fetch_authenticated_user_live() {
135 let token = std::env::var("GITHUB_TOKEN").expect("GITHUB_TOKEN required");
136 let login = fetch_authenticated_user(&token).unwrap();
137 assert!(!login.is_empty());
138 }
139
140 #[test]
141 fn pr_title_includes_short_id_prefix() {
142 let id = "034ed345-apm-state-include-ticket-id-in-github-pr";
143 assert_eq!(pr_title(id, "Fix the thing"), "034ed345: Fix the thing");
144 }
145
146 #[test]
147 fn pr_title_empty_title_falls_back_to_short_id() {
148 let id = "034ed345-apm-state-include-ticket-id-in-github-pr";
149 assert_eq!(pr_title(id, ""), "034ed345");
150 }
151
152 #[test]
153 fn pr_title_short_id_exactly_8_chars() {
154 let id = "abcd1234efgh";
155 assert_eq!(pr_title(id, "My ticket"), "abcd1234: My ticket");
156 }
157}