1use std::fmt::Write as _;
2use std::fs;
3use std::path::PathBuf;
4use std::process::Command;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use serde::Serialize;
8
9const REPO_VIEW_FIELDS: &str =
10 "name,description,owner,url,defaultBranchRef,createdAt,pushedAt,isPrivate,licenseInfo";
11const ISSUE_VIEW_FIELDS: &str =
12 "number,title,body,author,state,labels,comments,createdAt,updatedAt,url";
13const ISSUE_LIST_FIELDS: &str = "number,title,state,labels,createdAt,updatedAt,url,author";
14const PR_VIEW_FIELDS: &str = "number,title,body,author,state,isDraft,headRefName,baseRefName,commits,comments,reviews,reviewDecision,mergeStateStatus,createdAt,updatedAt,url";
15const PR_LIST_FIELDS: &str =
16 "number,title,state,createdAt,updatedAt,url,author,headRefName,baseRefName,isDraft";
17const RUN_LIST_FIELDS: &str =
18 "databaseId,workflowName,status,conclusion,createdAt,updatedAt,headSha,headBranch,event,url";
19const RUN_VIEW_FIELDS: &str =
20 "databaseId,workflowName,status,conclusion,createdAt,updatedAt,headSha,headBranch,event,url,jobs";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct GithubLogCollectorConfig {
24 pub repo: String,
25 pub output_dir: PathBuf,
26 pub issues: Vec<u64>,
27 pub pulls: Vec<u64>,
28 pub runs: Vec<u64>,
29 pub recent_issues: usize,
30 pub recent_pulls: usize,
31 pub recent_runs: usize,
32 pub branch: Option<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct GithubLogCapture {
37 pub kind: String,
38 pub file: String,
39 pub command: Vec<String>,
40}
41
42impl GithubLogCapture {
43 #[must_use]
44 pub fn command_line(&self) -> String {
45 self.command
46 .iter()
47 .map(|arg| shell_quote(arg))
48 .collect::<Vec<_>>()
49 .join(" ")
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct GithubLogCapturedFile {
55 pub kind: String,
56 pub file: String,
57 pub command: Vec<String>,
58 pub bytes: usize,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct GithubLogCollectionSummary {
63 pub repo: String,
64 pub output_dir: PathBuf,
65 pub captured: Vec<GithubLogCapturedFile>,
66 pub manifest_path: PathBuf,
67}
68
69#[derive(Debug, Serialize)]
70struct GithubLogManifest<'a> {
71 repo: &'a str,
72 generated_by: &'a str,
73 generated_at_unix_seconds: u64,
74 captures: &'a [GithubLogCapturedFile],
75}
76
77pub fn github_log_capture_plan(
78 config: &GithubLogCollectorConfig,
79) -> Result<Vec<GithubLogCapture>, String> {
80 let (owner, name) = split_repo(&config.repo)?;
81 let api_repo = format!("repos/{owner}/{name}");
82 let mut captures = Vec::new();
83
84 push_capture(
85 &mut captures,
86 "repo",
87 "repo.json",
88 vec![
89 "gh",
90 "repo",
91 "view",
92 config.repo.as_str(),
93 "--json",
94 REPO_VIEW_FIELDS,
95 ],
96 );
97
98 if config.recent_issues > 0 {
99 push_capture(
100 &mut captures,
101 "recent_issues",
102 "issues-recent.json",
103 vec![
104 String::from("gh"),
105 String::from("issue"),
106 String::from("list"),
107 String::from("--repo"),
108 config.repo.clone(),
109 String::from("--state"),
110 String::from("all"),
111 String::from("--limit"),
112 config.recent_issues.to_string(),
113 String::from("--json"),
114 String::from(ISSUE_LIST_FIELDS),
115 ],
116 );
117 }
118
119 if config.recent_pulls > 0 {
120 push_capture(
121 &mut captures,
122 "recent_pulls",
123 "pulls-recent.json",
124 vec![
125 String::from("gh"),
126 String::from("pr"),
127 String::from("list"),
128 String::from("--repo"),
129 config.repo.clone(),
130 String::from("--state"),
131 String::from("all"),
132 String::from("--limit"),
133 config.recent_pulls.to_string(),
134 String::from("--json"),
135 String::from(PR_LIST_FIELDS),
136 ],
137 );
138 }
139
140 if config.recent_runs > 0 {
141 let mut command = vec![
142 String::from("gh"),
143 String::from("run"),
144 String::from("list"),
145 String::from("--repo"),
146 config.repo.clone(),
147 String::from("--limit"),
148 config.recent_runs.to_string(),
149 ];
150 if let Some(branch) = config.branch.as_deref() {
151 command.push(String::from("--branch"));
152 command.push(branch.to_owned());
153 }
154 command.push(String::from("--json"));
155 command.push(String::from(RUN_LIST_FIELDS));
156 push_capture(
157 &mut captures,
158 "recent_runs",
159 "actions-runs-recent.json",
160 command,
161 );
162 }
163
164 for issue in &config.issues {
165 push_capture(
166 &mut captures,
167 "issue",
168 &format!("issue-{issue}.json"),
169 vec![
170 String::from("gh"),
171 String::from("issue"),
172 String::from("view"),
173 issue.to_string(),
174 String::from("--repo"),
175 config.repo.clone(),
176 String::from("--json"),
177 String::from(ISSUE_VIEW_FIELDS),
178 ],
179 );
180 push_capture(
181 &mut captures,
182 "issue_comments",
183 &format!("issue-{issue}-comments.json"),
184 vec![
185 String::from("gh"),
186 String::from("api"),
187 format!("{api_repo}/issues/{issue}/comments"),
188 String::from("--paginate"),
189 ],
190 );
191 }
192
193 for pull in &config.pulls {
194 push_capture(
195 &mut captures,
196 "pull",
197 &format!("pr-{pull}.json"),
198 vec![
199 String::from("gh"),
200 String::from("pr"),
201 String::from("view"),
202 pull.to_string(),
203 String::from("--repo"),
204 config.repo.clone(),
205 String::from("--json"),
206 String::from(PR_VIEW_FIELDS),
207 ],
208 );
209 push_capture(
210 &mut captures,
211 "pull_conversation_comments",
212 &format!("pr-{pull}-conversation-comments.json"),
213 vec![
214 String::from("gh"),
215 String::from("api"),
216 format!("{api_repo}/issues/{pull}/comments"),
217 String::from("--paginate"),
218 ],
219 );
220 push_capture(
221 &mut captures,
222 "pull_review_comments",
223 &format!("pr-{pull}-review-comments.json"),
224 vec![
225 String::from("gh"),
226 String::from("api"),
227 format!("{api_repo}/pulls/{pull}/comments"),
228 String::from("--paginate"),
229 ],
230 );
231 push_capture(
232 &mut captures,
233 "pull_reviews",
234 &format!("pr-{pull}-reviews.json"),
235 vec![
236 String::from("gh"),
237 String::from("api"),
238 format!("{api_repo}/pulls/{pull}/reviews"),
239 String::from("--paginate"),
240 ],
241 );
242 push_capture(
243 &mut captures,
244 "pull_diff",
245 &format!("pr-{pull}.diff"),
246 vec![
247 String::from("gh"),
248 String::from("pr"),
249 String::from("diff"),
250 pull.to_string(),
251 String::from("--repo"),
252 config.repo.clone(),
253 ],
254 );
255 }
256
257 for run in &config.runs {
258 push_capture(
259 &mut captures,
260 "run",
261 &format!("run-{run}.json"),
262 vec![
263 String::from("gh"),
264 String::from("run"),
265 String::from("view"),
266 run.to_string(),
267 String::from("--repo"),
268 config.repo.clone(),
269 String::from("--json"),
270 String::from(RUN_VIEW_FIELDS),
271 ],
272 );
273 push_capture(
274 &mut captures,
275 "run_log",
276 &format!("run-{run}.log"),
277 vec![
278 String::from("gh"),
279 String::from("run"),
280 String::from("view"),
281 run.to_string(),
282 String::from("--repo"),
283 config.repo.clone(),
284 String::from("--log"),
285 ],
286 );
287 }
288
289 Ok(captures)
290}
291
292pub fn render_github_log_plan(config: &GithubLogCollectorConfig) -> Result<String, String> {
293 let plan = github_log_capture_plan(config)?;
294 let mut output = String::new();
295 output.push_str("# GitHub log capture plan\n");
296 let _ = writeln!(output, "repo: {}", config.repo);
297 let _ = writeln!(output, "output_dir: {}", config.output_dir.display());
298 output.push('\n');
299 for capture in plan {
300 let _ = writeln!(output, "- {}", capture.kind);
301 let _ = writeln!(output, " file: {}", capture.file);
302 let _ = writeln!(output, " command: {}", capture.command_line());
303 }
304 Ok(output)
305}
306
307pub fn collect_github_logs(
308 config: &GithubLogCollectorConfig,
309) -> Result<GithubLogCollectionSummary, String> {
310 collect_github_logs_with_runner(config, run_command)
311}
312
313pub fn collect_github_logs_with_runner(
314 config: &GithubLogCollectorConfig,
315 mut runner: impl FnMut(&[String]) -> Result<Vec<u8>, String>,
316) -> Result<GithubLogCollectionSummary, String> {
317 let plan = github_log_capture_plan(config)?;
318 fs::create_dir_all(&config.output_dir).map_err(|error| {
319 format!(
320 "failed to create output directory {}: {error}",
321 config.output_dir.display()
322 )
323 })?;
324
325 let mut captured = Vec::new();
326 for capture in plan {
327 let output = runner(&capture.command)
328 .map_err(|error| format!("{} failed: {error}", capture.command_line()))?;
329 let output_path = config.output_dir.join(&capture.file);
330 fs::write(&output_path, &output)
331 .map_err(|error| format!("failed to write {}: {error}", output_path.display()))?;
332 captured.push(GithubLogCapturedFile {
333 kind: capture.kind,
334 file: capture.file,
335 command: capture.command,
336 bytes: output.len(),
337 });
338 }
339
340 let manifest = GithubLogManifest {
341 repo: &config.repo,
342 generated_by: "formal-ai github-logs collect",
343 generated_at_unix_seconds: unix_now(),
344 captures: &captured,
345 };
346 let manifest_text = serde_json::to_string_pretty(&manifest)
347 .map_err(|error| format!("failed to format manifest: {error}"))?;
348 let manifest_path = config.output_dir.join("manifest.json");
349 fs::write(&manifest_path, manifest_text)
350 .map_err(|error| format!("failed to write {}: {error}", manifest_path.display()))?;
351
352 Ok(GithubLogCollectionSummary {
353 repo: config.repo.clone(),
354 output_dir: config.output_dir.clone(),
355 captured,
356 manifest_path,
357 })
358}
359
360fn push_capture<S>(captures: &mut Vec<GithubLogCapture>, kind: &str, file: &str, command: Vec<S>)
361where
362 S: Into<String>,
363{
364 captures.push(GithubLogCapture {
365 kind: kind.to_owned(),
366 file: file.to_owned(),
367 command: command.into_iter().map(Into::into).collect(),
368 });
369}
370
371fn run_command(args: &[String]) -> Result<Vec<u8>, String> {
372 let Some((program, rest)) = args.split_first() else {
373 return Err(String::from("empty command"));
374 };
375 let output = Command::new(program)
376 .args(rest)
377 .output()
378 .map_err(|error| format!("failed to start {program}: {error}"))?;
379 if output.status.success() {
380 Ok(output.stdout)
381 } else {
382 let stderr = String::from_utf8_lossy(&output.stderr);
383 Err(format!("exit status {}: {stderr}", output.status))
384 }
385}
386
387fn split_repo(repo: &str) -> Result<(&str, &str), String> {
388 let mut parts = repo.split('/');
389 let owner = parts.next().unwrap_or_default();
390 let name = parts.next().unwrap_or_default();
391 if owner.is_empty() || name.is_empty() || parts.next().is_some() {
392 return Err(format!(
393 "GitHub repository must use OWNER/REPO format, got `{repo}`"
394 ));
395 }
396 validate_repo_part("owner", owner)?;
397 validate_repo_part("repo", name)?;
398 Ok((owner, name))
399}
400
401fn validate_repo_part(label: &str, value: &str) -> Result<(), String> {
402 if value.chars().all(is_repo_name_char) {
403 return Ok(());
404 }
405 Err(format!(
406 "GitHub repository {label} contains unsupported characters: `{value}`"
407 ))
408}
409
410const fn is_repo_name_char(character: char) -> bool {
411 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
412}
413
414fn shell_quote(value: &str) -> String {
415 if value
416 .chars()
417 .all(|character| character.is_ascii_alphanumeric() || "-_./:=,@".contains(character))
418 {
419 return value.to_owned();
420 }
421 format!("'{}'", value.replace('\'', "'\\''"))
422}
423
424fn unix_now() -> u64 {
425 SystemTime::now()
426 .duration_since(UNIX_EPOCH)
427 .map_or(0, |duration| duration.as_secs())
428}