Skip to main content

formal_ai/
github_logs.rs

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}