1use anyhow::{Context, Result, bail};
16use serde::Serialize;
17use sha2::{Digest, Sha256};
18use std::path::Path;
19use std::process::Command;
20
21use crate::runutil::{ManagedCommand, TimeoutClass, execute_managed_command};
22
23pub(crate) const GITHUB_ISSUE_SYNC_HASH_KEY: &str = "github_issue_sync_hash";
24
25pub(crate) struct IssueInfo {
26 pub url: String,
27 pub number: Option<u32>,
28}
29
30#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
31struct IssueSyncPayload<'a> {
32 title: &'a str,
33 body: &'a str,
34 labels: Vec<String>,
35 assignees: Vec<String>,
36 repo: Option<&'a str>,
37}
38
39pub(crate) fn normalize_issue_metadata_list(values: &[String]) -> Vec<String> {
40 let mut values = values
41 .iter()
42 .map(|value| value.trim())
43 .filter(|value| !value.is_empty())
44 .map(ToString::to_string)
45 .collect::<Vec<_>>();
46 values.sort_unstable();
47 values.dedup();
48 values
49}
50
51pub(crate) fn compute_issue_sync_hash(
52 title: &str,
53 body: &str,
54 labels: &[String],
55 assignees: &[String],
56 repo: Option<&str>,
57) -> Result<String> {
58 let payload = IssueSyncPayload {
59 title: title.trim(),
60 body: body.trim(),
61 labels: normalize_issue_metadata_list(labels),
62 assignees: normalize_issue_metadata_list(assignees),
63 repo: repo.map(str::trim).filter(|r| !r.is_empty()),
64 };
65
66 let encoded = serde_json::to_string(&payload)
67 .context("failed to serialize issue sync fingerprint payload")?;
68 let mut hasher = Sha256::new();
69 hasher.update(encoded.as_bytes());
70 Ok(hex::encode(hasher.finalize()))
71}
72
73fn extract_first_url(output: &str) -> Option<String> {
74 output
75 .lines()
76 .map(str::trim)
77 .find(|line| line.starts_with("http://") || line.starts_with("https://"))
78 .map(|line| line.to_string())
79}
80
81pub(crate) fn parse_issue_number(url: &str) -> Option<u32> {
82 let marker = "/issues/";
83 let idx = url.find(marker)?;
84 let rest = &url[idx + marker.len()..];
85 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
86 digits.parse().ok()
87}
88
89pub(crate) fn create_issue(
90 repo_root: &Path,
91 selector_repo: Option<&str>,
92 title: &str,
93 body_file: &Path,
94 labels: &[String],
95 assignees: &[String],
96) -> Result<IssueInfo> {
97 let safe_title = title.trim();
98 if safe_title.is_empty() {
99 bail!("Issue title must be non-empty");
100 }
101
102 let mut cmd = Command::new("gh");
103 cmd.current_dir(repo_root)
104 .env("GH_NO_UPDATE_NOTIFIER", "1")
105 .arg("issue")
106 .arg("create")
107 .arg("--title")
108 .arg(safe_title)
109 .arg("--body-file")
110 .arg(body_file);
111
112 if let Some(repo) = selector_repo {
113 cmd.arg("-R").arg(repo);
114 }
115
116 for label in labels {
117 cmd.arg("--label").arg(label);
118 }
119 for assignee in assignees {
120 cmd.arg("--assignee").arg(assignee);
121 }
122
123 let output = run_gh_issue_command(cmd, "gh issue create")
124 .with_context(|| format!("run gh issue create in {}", repo_root.display()))?;
125
126 if !output.status.success() {
127 let stderr = String::from_utf8_lossy(&output.stderr);
128 bail!("gh issue create failed: {}", stderr.trim());
129 }
130
131 let stdout = String::from_utf8_lossy(&output.stdout);
132 let url = extract_first_url(&stdout).ok_or_else(|| {
133 anyhow::anyhow!(
134 "Unable to parse issue URL from gh output. Output: {}",
135 stdout.trim()
136 )
137 })?;
138
139 Ok(IssueInfo {
140 number: parse_issue_number(&url),
141 url,
142 })
143}
144
145pub(crate) fn edit_issue(
146 repo_root: &Path,
147 selector_repo: Option<&str>,
148 issue_selector: &str, title: &str,
150 body_file: &Path,
151 add_labels: &[String],
152 add_assignees: &[String],
153) -> Result<()> {
154 let safe_title = title.trim();
155 if safe_title.is_empty() {
156 bail!("Issue title must be non-empty");
157 }
158
159 let mut cmd = Command::new("gh");
160 cmd.current_dir(repo_root)
161 .env("GH_NO_UPDATE_NOTIFIER", "1")
162 .arg("issue")
163 .arg("edit")
164 .arg(issue_selector)
165 .arg("--title")
166 .arg(safe_title)
167 .arg("--body-file")
168 .arg(body_file);
169
170 if let Some(repo) = selector_repo {
171 cmd.arg("-R").arg(repo);
172 }
173
174 for label in add_labels {
175 cmd.arg("--add-label").arg(label);
176 }
177 for assignee in add_assignees {
178 cmd.arg("--add-assignee").arg(assignee);
179 }
180
181 let output = run_gh_issue_command(cmd, "gh issue edit")
182 .with_context(|| format!("run gh issue edit in {}", repo_root.display()))?;
183
184 if !output.status.success() {
185 let stderr = String::from_utf8_lossy(&output.stderr);
186 bail!("gh issue edit failed: {}", stderr.trim());
187 }
188
189 Ok(())
190}
191
192fn run_gh_issue_command(
193 command: Command,
194 description: impl Into<String>,
195) -> Result<std::process::Output> {
196 execute_managed_command(ManagedCommand::new(
197 command,
198 description,
199 TimeoutClass::GitHubCli,
200 ))
201 .map(|output| {
202 let truncated = output.stdout_truncated || output.stderr_truncated;
203 if truncated {
204 log::debug!("managed gh issue capture truncated command output");
205 }
206 output.into_output()
207 })
208 .map_err(Into::into)
209}
210
211#[cfg(test)]
212mod tests {
213 use super::{extract_first_url, parse_issue_number};
214
215 #[test]
216 fn extract_first_url_picks_first_url_line() {
217 let output = "Creating issue for task...\nhttps://github.com/org/repo/issues/5\n";
218 let url = extract_first_url(output).expect("url");
219 assert_eq!(url, "https://github.com/org/repo/issues/5");
220 }
221
222 #[test]
223 fn extract_first_url_returns_none_when_no_url() {
224 let output = "Some output without a URL\n";
225 assert!(extract_first_url(output).is_none());
226 }
227
228 #[test]
229 fn parse_issue_number_extracts_number() {
230 assert_eq!(
231 parse_issue_number("https://github.com/org/repo/issues/123"),
232 Some(123)
233 );
234 assert_eq!(
235 parse_issue_number("https://github.com/org/repo/issues/42?foo=bar"),
236 Some(42)
237 );
238 }
239
240 #[test]
241 fn parse_issue_number_returns_none_for_invalid() {
242 assert!(parse_issue_number("https://github.com/org/repo/pull/123").is_none());
243 assert!(parse_issue_number("not a url").is_none());
244 assert!(parse_issue_number("").is_none());
245 }
246}