elizaos_plugin_github/providers/
issue_context.rs1#![allow(missing_docs)]
2
3use regex::Regex;
4use serde_json::json;
5
6use super::{GitHubProvider, ProviderContext, ProviderResult};
7use crate::error::Result;
8use crate::types::ListIssuesParams;
9use crate::GitHubService;
10
11pub fn extract_issue_number(text: &str) -> Option<u64> {
13 let patterns = [
14 r"#(\d+)",
15 r"(?i)issue\s*#?(\d+)",
16 r"(?i)pr\s*#?(\d+)",
17 r"(?i)pull\s*request\s*#?(\d+)",
18 ];
19
20 for pattern in &patterns {
21 if let Ok(re) = Regex::new(pattern) {
22 if let Some(caps) = re.captures(text) {
23 if let Some(num) = caps.get(1) {
24 if let Ok(n) = num.as_str().parse::<u64>() {
25 return Some(n);
26 }
27 }
28 }
29 }
30 }
31
32 None
33}
34
35pub struct IssueContextProvider;
38
39impl GitHubProvider for IssueContextProvider {
40 fn name(&self) -> &str {
41 "ISSUE_CONTEXT"
42 }
43
44 fn description(&self) -> &str {
45 "Provides detailed context about a specific GitHub issue or pull request when referenced"
46 }
47
48 async fn get(
49 &self,
50 context: &ProviderContext,
51 service: &GitHubService,
52 ) -> Result<ProviderResult> {
53 let config = service.config();
54 let owner = config.owner.as_deref().unwrap_or("");
55 let repo = config.repo.as_deref().unwrap_or("");
56
57 if owner.is_empty() || repo.is_empty() {
58 return Ok(ProviderResult {
59 context: String::new(),
60 data: json!(null),
61 });
62 }
63
64 let text = context
66 .message
67 .get("content")
68 .and_then(|c| c.get("text"))
69 .and_then(|t| t.as_str())
70 .unwrap_or("");
71
72 let issue_number = match extract_issue_number(text) {
73 Some(n) => n,
74 None => {
75 return self.fallback_open_issues(service, owner, repo).await;
77 }
78 };
79
80 match service.get_issue(owner, repo, issue_number).await {
82 Ok(issue) => {
83 if issue.is_pull_request {
84 self.format_pull_request(service, owner, repo, issue_number)
85 .await
86 } else {
87 Ok(self.format_issue(&issue, owner, repo))
88 }
89 }
90 Err(_) => Ok(ProviderResult {
91 context: format!(
92 "Issue/PR #{} not found in {}/{}",
93 issue_number, owner, repo
94 ),
95 data: json!(null),
96 }),
97 }
98 }
99}
100
101impl IssueContextProvider {
102 async fn fallback_open_issues(
103 &self,
104 service: &GitHubService,
105 owner: &str,
106 repo: &str,
107 ) -> Result<ProviderResult> {
108 let params = ListIssuesParams {
109 owner: owner.to_string(),
110 repo: repo.to_string(),
111 state: crate::types::IssueStateFilter::Open,
112 labels: None,
113 sort: crate::types::IssueSort::Updated,
114 direction: crate::types::SortDirection::Desc,
115 assignee: None,
116 creator: None,
117 per_page: 5,
118 page: 1,
119 };
120
121 let issues = service.list_issues(params).await?;
122
123 if issues.is_empty() {
124 return Ok(ProviderResult {
125 context: "No open issues in this repository.".to_string(),
126 data: json!(null),
127 });
128 }
129
130 let issue_list: Vec<String> = issues
131 .iter()
132 .map(|i| format!("- #{}: {}", i.number, i.title))
133 .collect();
134
135 Ok(ProviderResult {
136 context: format!("Recent open issues:\n{}", issue_list.join("\n")),
137 data: json!({
138 "total": issues.len(),
139 "issues": issues.iter().map(|i| json!({
140 "number": i.number,
141 "title": i.title,
142 "state": format!("{:?}", i.state).to_lowercase(),
143 "comments": i.comments,
144 })).collect::<Vec<_>>(),
145 }),
146 })
147 }
148
149 async fn format_pull_request(
150 &self,
151 service: &GitHubService,
152 owner: &str,
153 repo: &str,
154 pull_number: u64,
155 ) -> Result<ProviderResult> {
156 match service.get_pull_request(owner, repo, pull_number).await {
157 Ok(pr) => {
158 let labels: String = pr
159 .labels
160 .iter()
161 .map(|l| l.name.as_str())
162 .collect::<Vec<_>>()
163 .join(", ");
164 let assignees: String = pr
165 .assignees
166 .iter()
167 .map(|a| a.login.as_str())
168 .collect::<Vec<_>>()
169 .join(", ");
170 let reviewers: String = pr
171 .requested_reviewers
172 .iter()
173 .map(|r| r.login.as_str())
174 .collect::<Vec<_>>()
175 .join(", ");
176
177 let mut parts = vec![
178 format!("## Pull Request #{}: {}", pr.number, pr.title),
179 String::new(),
180 format!(
181 "**State:** {:?}{}{}",
182 pr.state,
183 if pr.draft { " (Draft)" } else { "" },
184 if pr.merged { " (Merged)" } else { "" }
185 ),
186 format!("**Author:** {}", pr.user.login),
187 format!(
188 "**Branch:** {} → {}",
189 pr.head.branch_ref, pr.base.branch_ref
190 ),
191 format!("**Created:** {}", pr.created_at),
192 format!("**Updated:** {}", pr.updated_at),
193 ];
194
195 if !labels.is_empty() {
196 parts.push(format!("**Labels:** {}", labels));
197 }
198 if !assignees.is_empty() {
199 parts.push(format!("**Assignees:** {}", assignees));
200 }
201 if !reviewers.is_empty() {
202 parts.push(format!("**Reviewers Requested:** {}", reviewers));
203 }
204
205 parts.push(String::new());
206 parts.push(format!(
207 "**Changes:** +{} / -{} ({} files)",
208 pr.additions, pr.deletions, pr.changed_files
209 ));
210 parts.push(String::new());
211 parts.push("### Description".to_string());
212 parts.push(
213 pr.body
214 .as_deref()
215 .unwrap_or("_No description provided_")
216 .to_string(),
217 );
218 parts.push(String::new());
219 parts.push(format!("**URL:** {}", pr.html_url));
220
221 Ok(ProviderResult {
222 context: parts.join("\n"),
223 data: json!({
224 "type": "pull_request",
225 "number": pr.number,
226 "title": pr.title,
227 "state": format!("{:?}", pr.state),
228 "draft": pr.draft,
229 "merged": pr.merged,
230 }),
231 })
232 }
233 Err(_) => Ok(ProviderResult {
234 context: format!(
235 "Issue/PR #{} not found in {}/{}",
236 pull_number, owner, repo
237 ),
238 data: json!(null),
239 }),
240 }
241 }
242
243 fn format_issue(
244 &self,
245 issue: &crate::types::GitHubIssue,
246 owner: &str,
247 repo: &str,
248 ) -> ProviderResult {
249 let labels: String = issue
250 .labels
251 .iter()
252 .map(|l| l.name.as_str())
253 .collect::<Vec<_>>()
254 .join(", ");
255 let assignees: String = issue
256 .assignees
257 .iter()
258 .map(|a| a.login.as_str())
259 .collect::<Vec<_>>()
260 .join(", ");
261
262 let state_str = match issue.state_reason {
263 Some(reason) => format!("{:?} ({:?})", issue.state, reason),
264 None => format!("{:?}", issue.state),
265 };
266
267 let mut parts = vec![
268 format!("## Issue #{}: {}", issue.number, issue.title),
269 String::new(),
270 format!("**State:** {}", state_str),
271 format!("**Author:** {}", issue.user.login),
272 format!("**Created:** {}", issue.created_at),
273 format!("**Updated:** {}", issue.updated_at),
274 format!("**Comments:** {}", issue.comments),
275 ];
276
277 if !labels.is_empty() {
278 parts.push(format!("**Labels:** {}", labels));
279 }
280 if !assignees.is_empty() {
281 parts.push(format!("**Assignees:** {}", assignees));
282 }
283 if let Some(ref milestone) = issue.milestone {
284 parts.push(format!("**Milestone:** {}", milestone.title));
285 }
286
287 parts.push(String::new());
288 parts.push("### Description".to_string());
289 parts.push(
290 issue
291 .body
292 .as_deref()
293 .unwrap_or("_No description provided_")
294 .to_string(),
295 );
296 parts.push(String::new());
297 parts.push(format!("**URL:** {}", issue.html_url));
298
299 let _ = (owner, repo); ProviderResult {
302 context: parts.join("\n"),
303 data: json!({
304 "type": "issue",
305 "number": issue.number,
306 "title": issue.title,
307 "state": format!("{:?}", issue.state),
308 "comments": issue.comments,
309 }),
310 }
311 }
312}
313
314pub struct GitHubIssueContextProvider;
316
317impl GitHubProvider for GitHubIssueContextProvider {
318 fn name(&self) -> &str {
319 "GITHUB_ISSUE_CONTEXT"
320 }
321
322 fn description(&self) -> &str {
323 "Provides detailed context about a specific GitHub issue or pull request when referenced"
324 }
325
326 fn get(
327 &self,
328 context: &ProviderContext,
329 service: &GitHubService,
330 ) -> impl std::future::Future<Output = Result<ProviderResult>> + Send {
331 IssueContextProvider.get(context, service)
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn test_extract_issue_number_hash() {
341 assert_eq!(extract_issue_number("Fix #42"), Some(42));
342 assert_eq!(extract_issue_number("See #123 for details"), Some(123));
343 }
344
345 #[test]
346 fn test_extract_issue_number_issue_keyword() {
347 assert_eq!(extract_issue_number("issue 42"), Some(42));
348 assert_eq!(extract_issue_number("Issue #99"), Some(99));
349 assert_eq!(extract_issue_number("issue#7"), Some(7));
350 }
351
352 #[test]
353 fn test_extract_issue_number_pr_keyword() {
354 assert_eq!(extract_issue_number("PR #15"), Some(15));
355 assert_eq!(extract_issue_number("pr 10"), Some(10));
356 }
357
358 #[test]
359 fn test_extract_issue_number_pull_request_keyword() {
360 assert_eq!(extract_issue_number("pull request #5"), Some(5));
361 assert_eq!(extract_issue_number("Pull Request 3"), Some(3));
362 }
363
364 #[test]
365 fn test_extract_issue_number_none() {
366 assert_eq!(extract_issue_number("Hello world"), None);
367 assert_eq!(extract_issue_number("No numbers here"), None);
368 assert_eq!(extract_issue_number(""), None);
369 }
370
371 #[test]
372 fn test_provider_names() {
373 let provider = IssueContextProvider;
374 assert_eq!(provider.name(), "ISSUE_CONTEXT");
375 assert!(!provider.description().is_empty());
376
377 let alias = GitHubIssueContextProvider;
378 assert_eq!(alias.name(), "GITHUB_ISSUE_CONTEXT");
379 }
380}