1use anyhow::{Context, Result, bail};
16use serde::Serialize;
17use sha2::{Digest, Sha256};
18use std::path::Path;
19
20use crate::git::github_cli::{extract_first_url, gh_command, run_checked_gh_command};
21use crate::runutil::TimeoutClass;
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
73pub(crate) fn parse_issue_number(url: &str) -> Option<u32> {
74 let marker = "/issues/";
75 let idx = url.find(marker)?;
76 let rest = &url[idx + marker.len()..];
77 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
78 digits.parse().ok()
79}
80
81pub(crate) fn create_issue(
82 repo_root: &Path,
83 selector_repo: Option<&str>,
84 title: &str,
85 body_file: &Path,
86 labels: &[String],
87 assignees: &[String],
88) -> Result<IssueInfo> {
89 let safe_title = title.trim();
90 if safe_title.is_empty() {
91 bail!("Issue title must be non-empty");
92 }
93
94 let mut cmd = gh_command(repo_root);
95 cmd.arg("issue")
96 .arg("create")
97 .arg("--title")
98 .arg(safe_title)
99 .arg("--body-file")
100 .arg(body_file);
101
102 if let Some(repo) = selector_repo {
103 cmd.arg("-R").arg(repo);
104 }
105
106 for label in labels {
107 cmd.arg("--label").arg(label);
108 }
109 for assignee in assignees {
110 cmd.arg("--assignee").arg(assignee);
111 }
112
113 let output = run_gh_issue_command(cmd, "gh issue create")
114 .with_context(|| format!("run gh issue create in {}", repo_root.display()))?;
115
116 let stdout = String::from_utf8_lossy(&output.stdout);
117 let url = extract_first_url(&stdout).ok_or_else(|| {
118 anyhow::anyhow!(
119 "Unable to parse issue URL from gh output. Output: {}",
120 stdout.trim()
121 )
122 })?;
123
124 Ok(IssueInfo {
125 number: parse_issue_number(&url),
126 url,
127 })
128}
129
130pub(crate) fn edit_issue(
131 repo_root: &Path,
132 selector_repo: Option<&str>,
133 issue_selector: &str, title: &str,
135 body_file: &Path,
136 add_labels: &[String],
137 add_assignees: &[String],
138) -> Result<()> {
139 let safe_title = title.trim();
140 if safe_title.is_empty() {
141 bail!("Issue title must be non-empty");
142 }
143
144 let mut cmd = gh_command(repo_root);
145 cmd.arg("issue")
146 .arg("edit")
147 .arg(issue_selector)
148 .arg("--title")
149 .arg(safe_title)
150 .arg("--body-file")
151 .arg(body_file);
152
153 if let Some(repo) = selector_repo {
154 cmd.arg("-R").arg(repo);
155 }
156
157 for label in add_labels {
158 cmd.arg("--add-label").arg(label);
159 }
160 for assignee in add_assignees {
161 cmd.arg("--add-assignee").arg(assignee);
162 }
163
164 run_gh_issue_command(cmd, "gh issue edit")
165 .with_context(|| format!("run gh issue edit in {}", repo_root.display()))?;
166
167 Ok(())
168}
169
170fn run_gh_issue_command(
171 command: std::process::Command,
172 description: impl Into<String>,
173) -> Result<std::process::Output> {
174 run_checked_gh_command(command, description, TimeoutClass::GitHubCli, "gh issue")
175}
176
177#[cfg(test)]
178mod tests {
179 use super::parse_issue_number;
180 use crate::git::github_cli::extract_first_url;
181
182 #[test]
183 fn extract_first_url_picks_first_url_line() {
184 let output = "Creating issue for task...\nhttps://github.com/org/repo/issues/5\n";
185 let url = extract_first_url(output).expect("url");
186 assert_eq!(url, "https://github.com/org/repo/issues/5");
187 }
188
189 #[test]
190 fn extract_first_url_returns_none_when_no_url() {
191 let output = "Some output without a URL\n";
192 assert!(extract_first_url(output).is_none());
193 }
194
195 #[test]
196 fn parse_issue_number_extracts_number() {
197 assert_eq!(
198 parse_issue_number("https://github.com/org/repo/issues/123"),
199 Some(123)
200 );
201 assert_eq!(
202 parse_issue_number("https://github.com/org/repo/issues/42?foo=bar"),
203 Some(42)
204 );
205 }
206
207 #[test]
208 fn parse_issue_number_returns_none_for_invalid() {
209 assert!(parse_issue_number("https://github.com/org/repo/pull/123").is_none());
210 assert!(parse_issue_number("not a url").is_none());
211 assert!(parse_issue_number("").is_none());
212 }
213}