Skip to main content

elizaos_plugin_github/providers/
issue_context.rs

1#![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
11/// Extract an issue or PR number from text using common patterns.
12pub 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
35/// Provides detailed context about a specific GitHub issue or pull request
36/// when referenced in a message, or lists recent open issues as fallback.
37pub 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        // Extract issue number from message text
65        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                // No issue reference found — return recent open issues as fallback
76                return self.fallback_open_issues(service, owner, repo).await;
77            }
78        };
79
80        // Fetch the specific issue
81        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); // used in error paths above
300
301        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
314/// TS-parity alias provider (name: `GITHUB_ISSUE_CONTEXT`).
315pub 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}