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;
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,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 } } } } } } }";
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 Ok(document)
163 }
164}
165
166impl<R: GhCommandRunner> GhCliFetcher<R> {
167 fn structured_json(
168 &self,
169 working_directory: &std::path::Path,
170 args: &[String],
171 ) -> Result<Value, GithubReadError> {
172 let result = self
173 .runner
174 .run(working_directory, args)
175 .map_err(|error| match error {
176 GhCommandError::NotFound => GithubReadError::GithubCliMissing,
177 GhCommandError::Other(message) => GithubReadError::FetchFailed(format!(
178 "could not start GitHub CLI: {}",
179 redact_gh_error(&message)
180 )),
181 })?;
182 if !result.success {
183 return Err(GithubReadError::FetchFailed(redact_gh_error(
184 &best_gh_error(&result.stderr, &result.stdout),
185 )));
186 }
187 let json: Value = serde_json::from_slice(&result.stdout).map_err(|error| {
188 GithubReadError::InvalidStructuredResponse(format!(
189 "GitHub CLI returned invalid structured JSON: {error}"
190 ))
191 })?;
192 if json.get("success").and_then(Value::as_bool) == Some(false) {
193 let underlying = json
194 .get("error")
195 .or_else(|| json.get("message"))
196 .and_then(Value::as_str)
197 .unwrap_or("GitHub declined the resource request");
198 return Err(GithubReadError::FetchFailed(redact_gh_error(underlying)));
199 }
200 if let Some(errors) = json.get("errors") {
201 return Err(GithubReadError::FetchFailed(redact_gh_error(
202 &errors.to_string(),
203 )));
204 }
205 Ok(json)
206 }
207}
208
209fn normalize_document(
210 resource: &GithubResource,
211 json: &Value,
212) -> Result<GithubDocument, GithubReadError> {
213 normalize_structured_document(resource, json).map_err(|error| {
214 GithubReadError::InvalidStructuredResponse(format!(
215 "GitHub returned an incomplete structured response: {}",
216 redact_gh_error(&error.to_string())
217 ))
218 })
219}
220
221pub fn gh_view_args(resource: &GithubResource) -> Vec<String> {
224 let fields = match resource.kind {
225 GithubResourceKind::Issue => ISSUE_JSON_FIELDS,
226 GithubResourceKind::PullRequest => PR_JSON_FIELDS,
227 };
228 let mut args = vec![
229 resource.kind.command().to_string(),
230 "view".to_string(),
231 resource.number.to_string(),
232 ];
233 if let Some(repository) = &resource.repository {
234 args.push("-R".to_string());
235 args.push(repository.clone());
236 }
237 args.push("--json".to_string());
238 args.push(fields.to_string());
239 args
240}
241
242pub fn gh_pr_review_comments_args(
246 resource: &GithubResource,
247 resolved_repository: &str,
248) -> Result<Vec<String>, GithubReadError> {
249 if resource.kind != GithubResourceKind::PullRequest {
250 return Err(GithubReadError::InvalidStructuredResponse(
251 "review-comment query requested for a non-pull-request resource".to_string(),
252 ));
253 }
254 let (owner, repository) = resolved_repository.split_once('/').ok_or_else(|| {
255 GithubReadError::InvalidStructuredResponse(
256 "GitHub structured response returned an invalid resolved repository".to_string(),
257 )
258 })?;
259 if owner.is_empty() || repository.is_empty() || repository.contains('/') {
260 return Err(GithubReadError::InvalidStructuredResponse(
261 "GitHub structured response returned an invalid resolved repository".to_string(),
262 ));
263 }
264 let number = i32::try_from(resource.number).map_err(|_| {
265 GithubReadError::InvalidStructuredResponse(
266 "GitHub resource number exceeds the GraphQL integer range".to_string(),
267 )
268 })?;
269 let mut args = vec![
270 "api".to_string(),
271 "graphql".to_string(),
272 "-f".to_string(),
273 format!("query={PR_REVIEW_COMMENTS_QUERY}"),
274 "-F".to_string(),
275 format!("owner={owner}"),
276 "-F".to_string(),
277 format!("name={repository}"),
278 "-F".to_string(),
279 format!("number={number}"),
280 ];
281 if let Some(repository) = &resource.repository {
282 args.push("-R".to_string());
283 args.push(repository.clone());
284 }
285 Ok(args)
286}
287
288fn best_gh_error(stderr: &[u8], stdout: &[u8]) -> String {
289 let stderr = String::from_utf8_lossy(stderr).trim().to_string();
290 if !stderr.is_empty() {
291 return stderr;
292 }
293 let stdout = String::from_utf8_lossy(stdout).trim().to_string();
294 if !stdout.is_empty() {
295 return stdout;
296 }
297 "GitHub CLI failed without an error message".to_string()
298}
299
300static GITHUB_TOKEN: LazyLock<regex::Regex> = LazyLock::new(|| {
301 regex::Regex::new(r"(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)")
302 .expect("GitHub token redaction expression is valid")
303});
304static AUTHORIZATION_VALUE: LazyLock<regex::Regex> = LazyLock::new(|| {
305 regex::Regex::new(r"(?i)((?:authorization|token)\s*[:=]\s*)[^\s,;]+")
306 .expect("authorization redaction expression is valid")
307});
308
309pub fn redact_gh_error(message: &str) -> String {
312 let with_tokens = GITHUB_TOKEN.replace_all(message, "[redacted]");
313 AUTHORIZATION_VALUE
314 .replace_all(&with_tokens, "${1}[redacted]")
315 .into_owned()
316}
317
318#[cfg(test)]
319mod tests {
320 use std::sync::Mutex;
321
322 use serde_json::json;
323
324 use super::*;
325 use crate::github_read::resource::{GithubResource, GithubResourceKind};
326
327 #[derive(Default)]
328 struct FixtureRunner {
329 calls: Mutex<Vec<(PathBuf, Vec<String>)>>,
330 output: Mutex<Option<Result<GhCommandOutput, GhCommandError>>>,
331 }
332
333 impl GhCommandRunner for FixtureRunner {
334 fn run(
335 &self,
336 working_directory: &std::path::Path,
337 args: &[String],
338 ) -> Result<GhCommandOutput, GhCommandError> {
339 self.calls
340 .lock()
341 .unwrap()
342 .push((working_directory.to_path_buf(), args.to_vec()));
343 self.output.lock().unwrap().take().unwrap()
344 }
345 }
346
347 #[test]
348 fn explicit_and_short_forms_differ_only_by_repo_flag() {
349 let short = GithubResource {
350 kind: GithubResourceKind::Issue,
351 number: 1,
352 repository: None,
353 comment_selector: None,
354 };
355 let explicit = GithubResource {
356 repository: Some("owner/repo".to_string()),
357 ..short.clone()
358 };
359 let short_args = gh_view_args(&short);
360 let explicit_args = gh_view_args(&explicit);
361 assert!(!short_args.iter().any(|argument| argument == "-R"));
362 assert!(explicit_args
363 .windows(2)
364 .any(|pair| pair == ["-R", "owner/repo"]));
365
366 let short_pr = GithubResource {
367 kind: GithubResourceKind::PullRequest,
368 ..short.clone()
369 };
370 let explicit_pr = GithubResource {
371 repository: Some("owner/repo".to_string()),
372 ..short_pr.clone()
373 };
374 let short_review_args = gh_pr_review_comments_args(&short_pr, "owner/repo").unwrap();
375 assert!(!short_review_args.iter().any(|argument| argument == "-R"));
376 let explicit_review_args = gh_pr_review_comments_args(&explicit_pr, "owner/repo").unwrap();
377 assert!(explicit_review_args
378 .windows(2)
379 .any(|pair| pair == ["-R", "owner/repo"]));
380 assert!(short_review_args
381 .iter()
382 .any(|argument| argument.starts_with("query=query AftReadPullRequestReviewComments")));
383 }
384
385 #[test]
386 fn fetcher_uses_structured_json_and_redacts_failures() {
387 let runner = FixtureRunner::default();
388 *runner.output.lock().unwrap() = Some(Ok(GhCommandOutput {
389 success: true,
390 stdout: serde_json::to_vec(&json!({
391 "number": 1,
392 "title": "fixture",
393 "url": "https://github.com/owner/repo/issues/1"
394 }))
395 .unwrap(),
396 stderr: Vec::new(),
397 }));
398 let fetcher = GhCliFetcher::new(runner);
399 let request = GithubFetchRequest {
400 resource: GithubResource {
401 kind: GithubResourceKind::Issue,
402 number: 1,
403 repository: None,
404 comment_selector: None,
405 },
406 working_directory: PathBuf::from("/fixture"),
407 };
408 let document = fetcher.fetch(&request).unwrap();
409 assert_eq!(document.repository, "owner/repo");
410
411 let redacted = redact_gh_error("HTTP 401 token=ghp_secret github_pat_secret");
412 assert!(!redacted.contains("secret"));
413 assert!(redacted.contains("HTTP 401"));
414 }
415}