1use anyhow::{Context, Result};
2
3use super::{GhClient, Issue};
4use crate::process::CommandRunner;
5
6#[derive(Debug, Clone)]
11pub struct ParsedIssue {
12 pub issue: Issue,
13 pub target_repo: Option<String>,
14 pub body_without_frontmatter: String,
15}
16
17pub fn parse_issue_frontmatter(issue: &Issue, target_field: &str) -> ParsedIssue {
23 let body = issue.body.trim_start();
24
25 if !body.starts_with("---") {
26 return ParsedIssue {
27 issue: issue.clone(),
28 target_repo: None,
29 body_without_frontmatter: issue.body.clone(),
30 };
31 }
32
33 let after_open = &body[3..];
35 let closing = after_open.find("\n---");
36
37 let Some(close_idx) = closing else {
38 return ParsedIssue {
40 issue: issue.clone(),
41 target_repo: None,
42 body_without_frontmatter: issue.body.clone(),
43 };
44 };
45
46 let frontmatter = &after_open[..close_idx];
47 let rest = &after_open[close_idx + 4..]; let body_without = rest.trim_start_matches('\n').to_string();
49
50 let needle = format!("{target_field}:");
52 let target_repo = frontmatter.lines().find_map(|line| {
53 let trimmed = line.trim();
54 if trimmed.starts_with(&needle) {
55 Some(trimmed[needle.len()..].trim().to_string())
56 } else {
57 None
58 }
59 });
60
61 ParsedIssue { issue: issue.clone(), target_repo, body_without_frontmatter: body_without }
62}
63
64impl<R: CommandRunner> GhClient<R> {
65 pub async fn get_issues_by_label(&self, label: &str) -> Result<Vec<Issue>> {
67 let output = self
68 .runner
69 .run_gh(
70 &Self::s(&[
71 "issue",
72 "list",
73 "--label",
74 label,
75 "--author",
76 "@me",
77 "--json",
78 "number,title,body,labels",
79 "--state",
80 "open",
81 "--limit",
82 "100",
83 ]),
84 &self.repo_dir,
85 )
86 .await
87 .context("fetching issues by label")?;
88 Self::check_output(&output, "fetch issues")?;
89
90 let mut issues: Vec<Issue> =
91 serde_json::from_str(&output.stdout).context("parsing issue list JSON")?;
92 issues.sort_by_key(|i| i.number);
94 Ok(issues)
95 }
96
97 pub async fn get_issue(&self, issue_number: u32) -> Result<Issue> {
99 let output = self
100 .runner
101 .run_gh(
102 &Self::s(&[
103 "issue",
104 "view",
105 &issue_number.to_string(),
106 "--json",
107 "number,title,body,labels",
108 ]),
109 &self.repo_dir,
110 )
111 .await
112 .context("fetching issue")?;
113 Self::check_output(&output, "fetch issue")?;
114
115 let issue: Issue = serde_json::from_str(&output.stdout).context("parsing issue JSON")?;
116 Ok(issue)
117 }
118
119 pub async fn comment_on_issue(&self, issue_number: u32, body: &str) -> Result<()> {
121 let output = self
122 .runner
123 .run_gh(
124 &Self::s(&["issue", "comment", &issue_number.to_string(), "--body", body]),
125 &self.repo_dir,
126 )
127 .await
128 .context("commenting on issue")?;
129 Self::check_output(&output, "comment on issue")?;
130 Ok(())
131 }
132
133 pub async fn close_issue(&self, issue_number: u32, comment: Option<&str>) -> Result<()> {
135 let num_str = issue_number.to_string();
136 let mut args = vec!["issue", "close", &num_str];
137 if let Some(body) = comment {
138 args.extend(["--comment", body]);
139 }
140 let output =
141 self.runner.run_gh(&Self::s(&args), &self.repo_dir).await.context("closing issue")?;
142 Self::check_output(&output, "close issue")?;
143 Ok(())
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use std::path::Path;
150
151 use super::*;
152 use crate::{
153 github::GhClient,
154 process::{CommandOutput, MockCommandRunner},
155 };
156
157 #[tokio::test]
158 async fn get_issues_by_label_parses_json() {
159 let mut mock = MockCommandRunner::new();
160 mock.expect_run_gh().returning(|_, _| {
161 Box::pin(async {
162 Ok(CommandOutput {
163 stdout: r#"[{"number":3,"title":"Third","body":"c","labels":[{"name":"o-ready"}]},{"number":1,"title":"First","body":"a","labels":[{"name":"o-ready"}]},{"number":2,"title":"Second","body":"b","labels":[{"name":"o-ready"}]}]"#.to_string(),
164 stderr: String::new(),
165 success: true,
166 })
167 })
168 });
169
170 let client = GhClient::new(mock, Path::new("/tmp"));
171 let issues = client.get_issues_by_label("o-ready").await.unwrap();
172
173 assert_eq!(issues.len(), 3);
174 assert_eq!(issues[0].number, 1);
176 assert_eq!(issues[1].number, 2);
177 assert_eq!(issues[2].number, 3);
178 }
179
180 #[tokio::test]
181 async fn get_issues_by_label_filters_by_current_user() {
182 let mut mock = MockCommandRunner::new();
183 mock.expect_run_gh().returning(|args, _| {
184 assert!(args.contains(&"--author".to_string()));
185 assert!(args.contains(&"@me".to_string()));
186 Box::pin(async {
187 Ok(CommandOutput { stdout: "[]".to_string(), stderr: String::new(), success: true })
188 })
189 });
190
191 let client = GhClient::new(mock, Path::new("/tmp"));
192 let issues = client.get_issues_by_label("o-ready").await.unwrap();
193 assert!(issues.is_empty());
194 }
195
196 #[tokio::test]
197 async fn get_issue_parses_single() {
198 let mut mock = MockCommandRunner::new();
199 mock.expect_run_gh().returning(|_, _| {
200 Box::pin(async {
201 Ok(CommandOutput {
202 stdout: r#"{"number":42,"title":"Fix bug","body":"details","labels":[]}"#
203 .to_string(),
204 stderr: String::new(),
205 success: true,
206 })
207 })
208 });
209
210 let client = GhClient::new(mock, Path::new("/tmp"));
211 let issue = client.get_issue(42).await.unwrap();
212
213 assert_eq!(issue.number, 42);
214 assert_eq!(issue.title, "Fix bug");
215 assert_eq!(issue.body, "details");
216 }
217
218 #[tokio::test]
219 async fn comment_on_issue_succeeds() {
220 let mut mock = MockCommandRunner::new();
221 mock.expect_run_gh().returning(|_, _| {
222 Box::pin(async {
223 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
224 })
225 });
226
227 let client = GhClient::new(mock, Path::new("/tmp"));
228 let result = client.comment_on_issue(42, "hello").await;
229 assert!(result.is_ok());
230 }
231
232 #[tokio::test]
233 async fn close_issue_with_comment() {
234 let mut mock = MockCommandRunner::new();
235 mock.expect_run_gh().returning(|args, _| {
236 assert!(args.contains(&"issue".to_string()));
237 assert!(args.contains(&"close".to_string()));
238 assert!(args.contains(&"--comment".to_string()));
239 Box::pin(async {
240 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
241 })
242 });
243
244 let client = GhClient::new(mock, Path::new("/tmp"));
245 let result = client.close_issue(42, Some("Done")).await;
246 assert!(result.is_ok());
247 }
248
249 #[tokio::test]
250 async fn close_issue_without_comment() {
251 let mut mock = MockCommandRunner::new();
252 mock.expect_run_gh().returning(|args, _| {
253 assert!(!args.contains(&"--comment".to_string()));
254 Box::pin(async {
255 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
256 })
257 });
258
259 let client = GhClient::new(mock, Path::new("/tmp"));
260 let result = client.close_issue(42, None).await;
261 assert!(result.is_ok());
262 }
263
264 fn make_issue(body: &str) -> Issue {
265 Issue { number: 1, title: "Test".to_string(), body: body.to_string(), labels: vec![] }
266 }
267
268 #[test]
269 fn parse_frontmatter_extracts_target_repo() {
270 let issue = make_issue("---\ntarget_repo: my-service\n---\n\nFix the bug");
271 let parsed = parse_issue_frontmatter(&issue, "target_repo");
272 assert_eq!(parsed.target_repo.as_deref(), Some("my-service"));
273 assert_eq!(parsed.body_without_frontmatter, "Fix the bug");
274 }
275
276 #[test]
277 fn parse_frontmatter_custom_field_name() {
278 let issue = make_issue("---\nrepo: other-thing\n---\n\nDo stuff");
279 let parsed = parse_issue_frontmatter(&issue, "repo");
280 assert_eq!(parsed.target_repo.as_deref(), Some("other-thing"));
281 }
282
283 #[test]
284 fn parse_frontmatter_no_frontmatter() {
285 let issue = make_issue("Just a regular issue body");
286 let parsed = parse_issue_frontmatter(&issue, "target_repo");
287 assert!(parsed.target_repo.is_none());
288 assert_eq!(parsed.body_without_frontmatter, "Just a regular issue body");
289 }
290
291 #[test]
292 fn parse_frontmatter_unclosed_delimiters() {
293 let issue = make_issue("---\ntarget_repo: oops\nno closing delimiter");
294 let parsed = parse_issue_frontmatter(&issue, "target_repo");
295 assert!(parsed.target_repo.is_none());
296 assert_eq!(parsed.body_without_frontmatter, issue.body);
297 }
298
299 #[test]
300 fn parse_frontmatter_missing_field() {
301 let issue = make_issue("---\nother_key: value\n---\n\nBody here");
302 let parsed = parse_issue_frontmatter(&issue, "target_repo");
303 assert!(parsed.target_repo.is_none());
304 assert_eq!(parsed.body_without_frontmatter, "Body here");
305 }
306
307 #[test]
308 fn parse_frontmatter_strips_leading_newlines() {
309 let issue = make_issue("---\ntarget_repo: svc\n---\n\n\nBody");
310 let parsed = parse_issue_frontmatter(&issue, "target_repo");
311 assert_eq!(parsed.body_without_frontmatter, "Body");
312 }
313
314 #[test]
315 fn parse_frontmatter_preserves_issue() {
316 let issue = make_issue("---\ntarget_repo: api\n---\nContent");
317 let parsed = parse_issue_frontmatter(&issue, "target_repo");
318 assert_eq!(parsed.issue.number, 1);
319 assert_eq!(parsed.issue.title, "Test");
320 }
321
322 #[test]
323 fn parse_frontmatter_with_extra_fields() {
324 let issue =
325 make_issue("---\npriority: high\ntarget_repo: backend\nlabel: bug\n---\n\nDetails");
326 let parsed = parse_issue_frontmatter(&issue, "target_repo");
327 assert_eq!(parsed.target_repo.as_deref(), Some("backend"));
328 assert_eq!(parsed.body_without_frontmatter, "Details");
329 }
330
331 #[test]
332 fn parse_frontmatter_empty_body() {
333 let issue = make_issue("");
334 let parsed = parse_issue_frontmatter(&issue, "target_repo");
335 assert!(parsed.target_repo.is_none());
336 assert_eq!(parsed.body_without_frontmatter, "");
337 }
338}