1use std::fmt;
2use std::path::PathBuf;
3use std::process::Command;
4use std::sync::LazyLock;
5
6use serde_json::Value;
7
8use super::model::GithubDocument;
9use super::normalize::{normalize_structured_document, normalize_timeline_events};
10use super::resource::{GithubResource, GithubResourceKind};
11
12const ISSUE_JSON_FIELDS: &str = "number,title,state,author,createdAt,updatedAt,labels,assignees,milestone,body,reactionGroups,comments,url";
13const PR_JSON_FIELDS: &str = "number,title,state,author,createdAt,updatedAt,labels,assignees,milestone,body,reactionGroups,comments,files,reviews,baseRefName,headRefName,reviewDecision,url";
14const PR_REVIEW_COMMENTS_QUERY: &str = "query AftReadPullRequestReviewComments($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { nameWithOwner pullRequest(number: $number) { number reviews(first: 100) { nodes { author { login } body state submittedAt comments(first: 100) { totalCount nodes { author { login } body createdAt updatedAt isMinimized path line originalLine } } } } } } }";
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct GithubFetchRequest {
19 pub resource: GithubResource,
20 pub working_directory: PathBuf,
21}
22
23pub trait GithubFetcher: Send + Sync {
26 fn fetch(&self, request: &GithubFetchRequest) -> Result<GithubDocument, GithubReadError>;
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31pub enum GithubReadError {
32 GithubReadDisabled,
33 InvalidResource(String),
34 InvalidCommentSelector(String),
35 GithubCliMissing,
36 FetchFailed(String),
37 InvalidStructuredResponse(String),
38}
39
40impl GithubReadError {
41 pub fn code(&self) -> &'static str {
42 match self {
43 Self::GithubReadDisabled => "gh_read_disabled",
44 Self::InvalidResource(_) => "invalid_resource",
45 Self::InvalidCommentSelector(_) => "invalid_comment_selector",
46 Self::GithubCliMissing => "github_cli_missing",
47 Self::FetchFailed(_) | Self::InvalidStructuredResponse(_) => "github_fetch_failed",
48 }
49 }
50
51 pub fn invalid_resource(message: impl Into<String>) -> Self {
52 Self::InvalidResource(message.into())
53 }
54}
55
56impl fmt::Display for GithubReadError {
57 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58 match self {
59 Self::GithubReadDisabled => formatter
60 .write_str("GitHub reads are disabled; set gh_read.enabled: true in aft.jsonc"),
61 Self::InvalidResource(message)
62 | Self::InvalidCommentSelector(message)
63 | Self::FetchFailed(message)
64 | Self::InvalidStructuredResponse(message) => formatter.write_str(message),
65 Self::GithubCliMissing => formatter.write_str(
66 "GitHub reads require the `gh` CLI. Install GitHub CLI and authenticate it with `gh auth login`.",
67 ),
68 }
69 }
70}
71
72impl std::error::Error for GithubReadError {}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct GhCommandOutput {
77 pub success: bool,
78 pub stdout: Vec<u8>,
79 pub stderr: Vec<u8>,
80}
81
82#[derive(Clone, Debug, Eq, PartialEq)]
84pub enum GhCommandError {
85 NotFound,
86 Other(String),
87}
88
89pub trait GhCommandRunner: Send + Sync {
92 fn run(
93 &self,
94 working_directory: &std::path::Path,
95 args: &[String],
96 ) -> Result<GhCommandOutput, GhCommandError>;
97}
98
99#[derive(Default)]
102pub struct SystemGhCommandRunner;
103
104impl GhCommandRunner for SystemGhCommandRunner {
105 fn run(
106 &self,
107 working_directory: &std::path::Path,
108 args: &[String],
109 ) -> Result<GhCommandOutput, GhCommandError> {
110 let output = Command::new("gh")
111 .args(args)
112 .current_dir(working_directory)
113 .output()
114 .map_err(|error| match error.kind() {
115 std::io::ErrorKind::NotFound => GhCommandError::NotFound,
116 _ => GhCommandError::Other(error.to_string()),
117 })?;
118 Ok(GhCommandOutput {
119 success: output.status.success(),
120 stdout: output.stdout,
121 stderr: output.stderr,
122 })
123 }
124}
125
126pub struct GhCliFetcher<R = SystemGhCommandRunner> {
132 runner: R,
133}
134
135impl GhCliFetcher<SystemGhCommandRunner> {
136 pub fn system() -> Self {
137 Self {
138 runner: SystemGhCommandRunner,
139 }
140 }
141}
142
143impl<R> GhCliFetcher<R> {
144 pub fn new(runner: R) -> Self {
145 Self { runner }
146 }
147}
148
149impl<R: GhCommandRunner> GithubFetcher for GhCliFetcher<R> {
150 fn fetch(&self, request: &GithubFetchRequest) -> Result<GithubDocument, GithubReadError> {
151 let json =
152 self.structured_json(&request.working_directory, &gh_view_args(&request.resource))?;
153 let mut document = normalize_document(&request.resource, &json)?;
154 if request.resource.kind == GithubResourceKind::PullRequest {
155 let review_json = self.structured_json(
156 &request.working_directory,
157 &gh_pr_review_comments_args(&request.resource, &document.repository)?,
158 )?;
159 let review_document = normalize_document(&request.resource, &review_json)?;
160 document.review_comment_sections = review_document.review_comment_sections;
161 }
162 let timeline_json = self.structured_json(
163 &request.working_directory,
164 &gh_timeline_args(&request.resource, &document.repository)?,
165 )?;
166 document.timeline = normalize_timeline_events(&timeline_json);
167 Ok(document)
168 }
169}
170
171impl<R: GhCommandRunner> GhCliFetcher<R> {
172 fn structured_json(
173 &self,
174 working_directory: &std::path::Path,
175 args: &[String],
176 ) -> Result<Value, GithubReadError> {
177 let result = self
178 .runner
179 .run(working_directory, args)
180 .map_err(|error| match error {
181 GhCommandError::NotFound => GithubReadError::GithubCliMissing,
182 GhCommandError::Other(message) => GithubReadError::FetchFailed(format!(
183 "could not start GitHub CLI: {}",
184 redact_gh_error(&message)
185 )),
186 })?;
187 if !result.success {
188 return Err(GithubReadError::FetchFailed(redact_gh_error(
189 &best_gh_error(&result.stderr, &result.stdout),
190 )));
191 }
192 let json: Value = serde_json::from_slice(&result.stdout).map_err(|error| {
193 GithubReadError::InvalidStructuredResponse(format!(
194 "GitHub CLI returned invalid structured JSON: {error}"
195 ))
196 })?;
197 if json.get("success").and_then(Value::as_bool) == Some(false) {
198 let underlying = json
199 .get("error")
200 .or_else(|| json.get("message"))
201 .and_then(Value::as_str)
202 .unwrap_or("GitHub declined the resource request");
203 return Err(GithubReadError::FetchFailed(redact_gh_error(underlying)));
204 }
205 if let Some(errors) = json.get("errors") {
206 return Err(GithubReadError::FetchFailed(redact_gh_error(
207 &errors.to_string(),
208 )));
209 }
210 Ok(json)
211 }
212}
213
214fn normalize_document(
215 resource: &GithubResource,
216 json: &Value,
217) -> Result<GithubDocument, GithubReadError> {
218 normalize_structured_document(resource, json).map_err(|error| {
219 GithubReadError::InvalidStructuredResponse(format!(
220 "GitHub returned an incomplete structured response: {}",
221 redact_gh_error(&error.to_string())
222 ))
223 })
224}
225
226pub fn gh_view_args(resource: &GithubResource) -> Vec<String> {
229 let fields = match resource.kind {
230 GithubResourceKind::Issue => ISSUE_JSON_FIELDS,
231 GithubResourceKind::PullRequest => PR_JSON_FIELDS,
232 };
233 let mut args = vec![
234 resource.kind.command().to_string(),
235 "view".to_string(),
236 resource.number.to_string(),
237 ];
238 if let Some(repository) = &resource.repository {
239 args.push("-R".to_string());
240 args.push(repository.clone());
241 }
242 args.push("--json".to_string());
243 args.push(fields.to_string());
244 args
245}
246
247pub fn gh_timeline_args(
250 resource: &GithubResource,
251 resolved_repository: &str,
252) -> Result<Vec<String>, GithubReadError> {
253 let (owner, repository) = resolved_repository.split_once('/').ok_or_else(|| {
254 GithubReadError::InvalidStructuredResponse(
255 "GitHub structured response returned an invalid resolved repository".to_string(),
256 )
257 })?;
258 if owner.is_empty() || repository.is_empty() || repository.contains('/') {
259 return Err(GithubReadError::InvalidStructuredResponse(
260 "GitHub structured response returned an invalid resolved repository".to_string(),
261 ));
262 }
263 Ok(vec![
264 "api".to_string(),
265 format!(
266 "repos/{owner}/{repository}/issues/{}/timeline?per_page=100",
267 resource.number
268 ),
269 "--paginate".to_string(),
270 "--slurp".to_string(),
271 ])
272}
273
274pub fn gh_pr_review_comments_args(
275 resource: &GithubResource,
276 resolved_repository: &str,
277) -> Result<Vec<String>, GithubReadError> {
278 if resource.kind != GithubResourceKind::PullRequest {
279 return Err(GithubReadError::InvalidStructuredResponse(
280 "review-comment query requested for a non-pull-request resource".to_string(),
281 ));
282 }
283 let (owner, repository) = resolved_repository.split_once('/').ok_or_else(|| {
284 GithubReadError::InvalidStructuredResponse(
285 "GitHub structured response returned an invalid resolved repository".to_string(),
286 )
287 })?;
288 if owner.is_empty() || repository.is_empty() || repository.contains('/') {
289 return Err(GithubReadError::InvalidStructuredResponse(
290 "GitHub structured response returned an invalid resolved repository".to_string(),
291 ));
292 }
293 let number = i32::try_from(resource.number).map_err(|_| {
294 GithubReadError::InvalidStructuredResponse(
295 "GitHub resource number exceeds the GraphQL integer range".to_string(),
296 )
297 })?;
298 let args = vec![
299 "api".to_string(),
300 "graphql".to_string(),
301 "-f".to_string(),
302 format!("query={PR_REVIEW_COMMENTS_QUERY}"),
303 "-F".to_string(),
304 format!("owner={owner}"),
305 "-F".to_string(),
306 format!("name={repository}"),
307 "-F".to_string(),
308 format!("number={number}"),
309 ];
310 Ok(args)
311}
312
313fn best_gh_error(stderr: &[u8], stdout: &[u8]) -> String {
314 let stderr = String::from_utf8_lossy(stderr).trim().to_string();
315 if !stderr.is_empty() {
316 return stderr;
317 }
318 let stdout = String::from_utf8_lossy(stdout).trim().to_string();
319 if !stdout.is_empty() {
320 return stdout;
321 }
322 "GitHub CLI failed without an error message".to_string()
323}
324
325static GITHUB_TOKEN: LazyLock<regex::Regex> = LazyLock::new(|| {
326 regex::Regex::new(r"(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)")
327 .expect("GitHub token redaction expression is valid")
328});
329static AUTHORIZATION_VALUE: LazyLock<regex::Regex> = LazyLock::new(|| {
330 regex::Regex::new(r"(?i)((?:authorization|token)\s*[:=]\s*)[^\s,;]+")
331 .expect("authorization redaction expression is valid")
332});
333
334pub fn redact_gh_error(message: &str) -> String {
337 let with_tokens = GITHUB_TOKEN.replace_all(message, "[redacted]");
338 AUTHORIZATION_VALUE
339 .replace_all(&with_tokens, "${1}[redacted]")
340 .into_owned()
341}
342
343#[cfg(test)]
344mod tests {
345 use std::sync::Mutex;
346
347 use serde_json::json;
348
349 use super::*;
350 use crate::github_read::resource::{GithubResource, GithubResourceKind};
351
352 #[derive(Default)]
353 struct FixtureRunner {
354 calls: Mutex<Vec<(PathBuf, Vec<String>)>>,
355 output: Mutex<Vec<Result<GhCommandOutput, GhCommandError>>>,
356 }
357
358 impl GhCommandRunner for FixtureRunner {
359 fn run(
360 &self,
361 working_directory: &std::path::Path,
362 args: &[String],
363 ) -> Result<GhCommandOutput, GhCommandError> {
364 self.calls
365 .lock()
366 .unwrap()
367 .push((working_directory.to_path_buf(), args.to_vec()));
368 self.output.lock().unwrap().remove(0)
369 }
370 }
371
372 #[test]
373 fn explicit_and_short_forms_differ_only_by_repo_flag() {
374 let short = GithubResource {
375 kind: GithubResourceKind::Issue,
376 number: 1,
377 repository: None,
378 comment_selector: None,
379 };
380 let explicit = GithubResource {
381 repository: Some("owner/repo".to_string()),
382 ..short.clone()
383 };
384 let short_args = gh_view_args(&short);
385 let explicit_args = gh_view_args(&explicit);
386 assert!(!short_args.iter().any(|argument| argument == "-R"));
387 assert!(explicit_args
388 .windows(2)
389 .any(|pair| pair == ["-R", "owner/repo"]));
390
391 let short_pr = GithubResource {
392 kind: GithubResourceKind::PullRequest,
393 ..short.clone()
394 };
395 let explicit_pr = GithubResource {
396 repository: Some("owner/repo".to_string()),
397 ..short_pr.clone()
398 };
399 let short_review_args = gh_pr_review_comments_args(&short_pr, "owner/repo").unwrap();
400 assert!(!short_review_args.iter().any(|argument| argument == "-R"));
401 let explicit_review_args = gh_pr_review_comments_args(&explicit_pr, "owner/repo").unwrap();
402 assert!(
403 !explicit_review_args.iter().any(|argument| argument == "-R"),
404 "the GraphQL request resolves owner and repository from its variables"
405 );
406 assert!(short_review_args
407 .iter()
408 .any(|argument| argument.starts_with("query=query AftReadPullRequestReviewComments")));
409 }
410
411 #[test]
412 fn fetcher_uses_structured_json_and_redacts_failures() {
413 let runner = FixtureRunner::default();
414 *runner.output.lock().unwrap() = vec![
415 Ok(GhCommandOutput {
416 success: true,
417 stdout: serde_json::to_vec(&json!({
418 "number": 1,
419 "title": "fixture",
420 "url": "https://github.com/owner/repo/issues/1"
421 }))
422 .unwrap(),
423 stderr: Vec::new(),
424 }),
425 Ok(GhCommandOutput {
426 success: true,
427 stdout: b"[]".to_vec(),
428 stderr: Vec::new(),
429 }),
430 ];
431 let fetcher = GhCliFetcher::new(runner);
432 let request = GithubFetchRequest {
433 resource: GithubResource {
434 kind: GithubResourceKind::Issue,
435 number: 1,
436 repository: None,
437 comment_selector: None,
438 },
439 working_directory: PathBuf::from("/fixture"),
440 };
441 let document = fetcher.fetch(&request).unwrap();
442 assert_eq!(document.repository, "owner/repo");
443
444 let redacted = redact_gh_error("HTTP 401 token=ghp_secret github_pat_secret");
445 assert!(!redacted.contains("secret"));
446 assert!(redacted.contains("HTTP 401"));
447 }
448}