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