Skip to main content

ralph/git/
issue.rs

1//! GitHub Issue helpers using the `gh` CLI.
2//!
3//! Responsibilities:
4//! - Create and edit GitHub issues for Ralph tasks via `gh issue`.
5//! - Parse issue URLs/numbers from `gh` output for persistence.
6//!
7//! Not handled here:
8//! - Queue mutation or task persistence.
9//! - Rendering issue bodies from tasks (see `cli::queue::export`).
10//!
11//! Invariants/assumptions:
12//! - `gh` is installed and authenticated.
13//! - Commands run with `GH_NO_UPDATE_NOTIFIER=1` to avoid noisy prompts.
14
15use 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, // number or URL
148    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}