Skip to main content

aatxe_core/
github.rs

1//! Pure helpers for the GitHub sticky-comment protocol.
2//!
3//! This module does *not* perform network IO — it produces the URLs, headers,
4//! and method/body pairs the CLI feeds to its HTTP client. Keeping the
5//! protocol pure makes it trivial to unit-test (no mocked HTTP) and easy to
6//! swap clients later.
7//!
8//! The CLI binary wires this up to [`ureq`] (or anything else) in
9//! `crates/aatxe/src/github_http.rs`.
10
11use crate::report::STICKY_MARKER;
12use crate::secret::Secret;
13
14/// PR context required to find / create / update the sticky comment.
15#[derive(Debug, Clone)]
16pub struct GithubContext {
17    /// Repo in `owner/name` form.
18    pub repo: String,
19    /// Pull request number.
20    pub pr: u64,
21    /// GitHub token (PAT or Actions `GITHUB_TOKEN`). Stored as a [`Secret`]
22    /// so an accidental `Debug` print of the surrounding struct does not
23    /// leak the token.
24    pub token: Secret,
25    /// Override the GH API base. Defaults to `https://api.github.com`.
26    pub api_base: Option<String>,
27}
28
29impl GithubContext {
30    pub fn api_base(&self) -> &str {
31        self.api_base.as_deref().unwrap_or("https://api.github.com")
32    }
33}
34
35/// Header pairs to send on every GH REST call. The token is revealed from
36/// its [`Secret`] wrapper only when the header value is constructed; the
37/// returned `Authorization` string is the only place the cleartext lives,
38/// and it is consumed by the HTTP client immediately.
39pub fn default_headers(token: &Secret) -> Vec<(&'static str, String)> {
40    vec![
41        ("Authorization", format!("Bearer {}", token.reveal())),
42        ("Accept", "application/vnd.github+json".to_string()),
43        ("X-GitHub-Api-Version", "2022-11-28".to_string()),
44        ("User-Agent", format!("aatxe/{}", env!("CARGO_PKG_VERSION"))),
45    ]
46}
47
48/// URL for listing comments on a PR (paged, 100 per page).
49pub fn list_comments_url(ctx: &GithubContext, page: u32) -> String {
50    format!(
51        "{}/repos/{}/issues/{}/comments?per_page=100&page={}",
52        ctx.api_base(),
53        ctx.repo,
54        ctx.pr,
55        page,
56    )
57}
58
59/// URL for updating an issue comment by id.
60pub fn patch_comment_url(ctx: &GithubContext, comment_id: u64) -> String {
61    format!(
62        "{}/repos/{}/issues/comments/{}",
63        ctx.api_base(),
64        ctx.repo,
65        comment_id,
66    )
67}
68
69/// URL for creating a new issue comment on a PR.
70pub fn create_comment_url(ctx: &GithubContext) -> String {
71    format!(
72        "{}/repos/{}/issues/{}/comments",
73        ctx.api_base(),
74        ctx.repo,
75        ctx.pr,
76    )
77}
78
79/// Validate that a rendered comment body carries the sticky marker. Returns
80/// an [`Err`] without the marker so a caller can refuse to post a body that
81/// would create a new comment instead of updating the existing one.
82pub fn validate_sticky(body: &str) -> Result<(), &'static str> {
83    if body.contains(STICKY_MARKER) {
84        Ok(())
85    } else {
86        Err("comment body is missing the sticky marker; render with crate::render_markdown")
87    }
88}
89
90/// Resolve PR number and repo slug from common GH Actions / generic CI env
91/// vars. Returns `None` for fields not present so the caller can decide what
92/// to do (e.g. fall back to CLI flags).
93#[derive(Debug, Clone, Default)]
94pub struct DetectedContext {
95    pub repo: Option<String>,
96    pub pr: Option<u64>,
97    pub token: Option<Secret>,
98}
99
100/// Read GH Actions / generic CI env to populate as much of [`GithubContext`]
101/// as possible. The caller mixes in CLI overrides.
102pub fn detect_context<F: Fn(&str) -> Option<String>>(get: F) -> DetectedContext {
103    let token = get("GITHUB_TOKEN")
104        .or_else(|| get("GH_TOKEN"))
105        .map(Secret::new);
106    let repo = get("GITHUB_REPOSITORY");
107    let pr = detect_pr_number(&get);
108    DetectedContext { repo, pr, token }
109}
110
111fn detect_pr_number<F: Fn(&str) -> Option<String>>(get: &F) -> Option<u64> {
112    if let Some(v) = get("AATXE_PR") {
113        if let Ok(n) = v.parse::<u64>() {
114            if n > 0 {
115                return Some(n);
116            }
117        }
118    }
119    // GitHub Actions: GITHUB_REF=refs/pull/<num>/merge
120    if let Some(reff) = get("GITHUB_REF") {
121        if let Some(rest) = reff.strip_prefix("refs/pull/") {
122            if let Some(slash) = rest.find('/') {
123                if let Ok(n) = rest[..slash].parse::<u64>() {
124                    return Some(n);
125                }
126            }
127        }
128    }
129    // `pull_request` event payload: GITHUB_REF_NAME=<num>/merge
130    if let Some(name) = get("GITHUB_REF_NAME") {
131        if let Some((num, suffix)) = name.split_once('/') {
132            if matches!(suffix, "merge" | "head") {
133                if let Ok(n) = num.parse::<u64>() {
134                    return Some(n);
135                }
136            }
137        }
138    }
139    None
140}