Skip to main content

aptu_core/github/
instructions.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Repository instructions fetching for PR review context.
4//!
5//! Fetches AGENTS.md or .github/instructions/pr-review.md from a repository
6//! to inject as context into PR review prompts.
7
8use tracing::instrument;
9
10/// Fetches repository instructions for PR review context.
11///
12/// Attempts to fetch instructions from the repository in the following order:
13/// 1. If `override_path` is provided, fetch only from that path
14/// 2. Otherwise, try "AGENTS.md" then ".github/instructions/pr-review.md"
15///
16/// Returns `None` if:
17/// - Neither file exists (when no override)
18/// - File content is empty
19/// - Any error occurs during fetching
20///
21/// The returned content:
22/// - Has YAML frontmatter stripped (leading `---\n...---\n` block)
23/// - Is truncated to 1500 characters maximum
24///
25/// # Arguments
26///
27/// * `client` - Octocrab GitHub API client
28/// * `owner` - Repository owner
29/// * `repo` - Repository name
30/// * `head_sha` - Commit SHA to fetch from
31/// * `override_path` - Optional path to fetch instead of default paths
32#[instrument(skip(client), fields(owner = %owner, repo = %repo, head_sha = %head_sha))]
33pub async fn fetch_repo_instructions(
34    client: &octocrab::Octocrab,
35    owner: &str,
36    repo: &str,
37    head_sha: &str,
38    override_path: Option<&str>,
39    max_chars: usize,
40) -> Option<String> {
41    let paths = if let Some(path) = override_path {
42        vec![path.to_string()]
43    } else {
44        vec![
45            "AGENTS.md".to_string(),
46            ".github/instructions/pr-review.md".to_string(),
47        ]
48    };
49
50    for path in paths {
51        match fetch_file_content(client, owner, repo, &path, head_sha).await {
52            Some(content) => {
53                if !content.is_empty() {
54                    let stripped = strip_yaml_frontmatter(&content);
55                    let truncated = truncate_to_chars(&stripped, max_chars);
56                    tracing::debug!(
57                        file = %path,
58                        chars = truncated.len(),
59                        "Fetched repo instructions"
60                    );
61                    return Some(truncated);
62                }
63            }
64            None => {
65                tracing::debug!(file = %path, "Instructions file not found or error fetching");
66            }
67        }
68    }
69
70    tracing::debug!("No instructions file found");
71    None
72}
73
74/// Fetches a single file's content from the repository.
75///
76/// Returns `None` on any error (404, decode failure, etc.).
77async fn fetch_file_content(
78    client: &octocrab::Octocrab,
79    owner: &str,
80    repo: &str,
81    filename: &str,
82    head_sha: &str,
83) -> Option<String> {
84    match client
85        .repos(owner, repo)
86        .get_content()
87        .path(filename)
88        .r#ref(head_sha)
89        .send()
90        .await
91    {
92        Ok(content) => {
93            // Try to decode the first item (should be the file, not a directory listing)
94            if let Some(item) = content.items.first() {
95                if let Some(decoded) = item.decoded_content() {
96                    return Some(decoded);
97                }
98                tracing::debug!(
99                    path = filename,
100                    "failed to decode instructions file content"
101                );
102                return None;
103            }
104            None
105        }
106        Err(e) => {
107            tracing::debug!(error = %e, path = filename, "failed to fetch instructions file");
108            None
109        }
110    }
111}
112
113/// Strips YAML frontmatter from content.
114///
115/// If content starts with `---\n`, finds the closing `---\n` and removes that block.
116/// Handles both LF (\n) and CRLF (\r\n) line endings.
117/// Otherwise, returns content unchanged.
118fn strip_yaml_frontmatter(content: &str) -> String {
119    // Only strip if content begins with a frontmatter delimiter
120    let after_open = if let Some(rest) = content.strip_prefix("---\n") {
121        rest
122    } else if let Some(rest) = content.strip_prefix("---\r\n") {
123        rest
124    } else {
125        return content.to_string();
126    };
127
128    // Find closing delimiter; if absent, return content as-is (no frontmatter)
129    if let Some(end) = after_open.find("\n---\n") {
130        after_open[end + 5..].to_string()
131    } else if let Some(end) = after_open.find("\r\n---\r\n") {
132        after_open[end + 7..].to_string()
133    } else {
134        // No closing delimiter found; treat entire content as body
135        content.to_string()
136    }
137}
138
139/// Truncates content to a maximum number of characters.
140fn truncate_to_chars(content: &str, max_chars: usize) -> String {
141    content.chars().take(max_chars).collect::<String>()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_strip_yaml_frontmatter_with_frontmatter() {
150        let content = "---\ntitle: Test\nauthor: Me\n---\nActual content here";
151        let result = strip_yaml_frontmatter(content);
152        assert_eq!(result, "Actual content here");
153    }
154
155    #[test]
156    fn test_strip_yaml_frontmatter_without_frontmatter() {
157        let content = "Just plain content";
158        let result = strip_yaml_frontmatter(content);
159        assert_eq!(result, "Just plain content");
160    }
161
162    #[test]
163    fn test_strip_yaml_frontmatter_no_closing() {
164        let content = "---\ntitle: Test\nNo closing marker";
165        let result = strip_yaml_frontmatter(content);
166        // If no closing delimiter found, treat entire content as body (no frontmatter)
167        assert_eq!(result, "---\ntitle: Test\nNo closing marker");
168    }
169
170    #[test]
171    fn test_truncate_to_chars() {
172        let content = "0123456789";
173        let result = truncate_to_chars(content, 5);
174        assert_eq!(result, "01234");
175    }
176
177    #[test]
178    fn test_truncate_to_chars_longer_than_max() {
179        let content = "short";
180        let result = truncate_to_chars(content, 100);
181        assert_eq!(result, "short");
182    }
183
184    #[test]
185    fn test_truncate_to_chars_unicode() {
186        let content = "hello 🌍 world";
187        let result = truncate_to_chars(content, 8);
188        assert_eq!(result, "hello 🌍 ");
189    }
190}