aptu_core/github/
instructions.rs1use tracing::instrument;
9
10#[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
74async 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 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
113fn strip_yaml_frontmatter(content: &str) -> String {
119 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 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 content.to_string()
136 }
137}
138
139fn 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 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}