Skip to main content

oxi/internal_urls/
issue_handler.rs

1//! `issue://` protocol handler — resolves GitHub issue references to markdown.
2//!
3//! URL formats:
4//! - `N` — issue N in the current repo (detected from git remote)
5//! - `owner/repo/N` — explicit repo
6//!
7//! Uses the GitHub REST API with optional `GITHUB_TOKEN`/`GH_TOKEN` auth.
8
9use async_trait::async_trait;
10use oxi_sdk::SdkError;
11use oxi_sdk::ports::{ProtocolHandler, ResolveContext, ResolvedUrl};
12use serde::Deserialize;
13
14use super::{detect_github_repo, github_token};
15use crate::util::http_client::shared_http_client;
16
17/// Protocol handler for `issue://` URLs.
18#[derive(Debug, Clone, Default)]
19pub struct IssueProtocolHandler;
20
21/// Parsed issue URL components.
22struct IssueUrl {
23    owner: String,
24    repo: String,
25    issue_number: u64,
26}
27
28/// GitHub REST API issue response (subset).
29#[derive(Debug, Deserialize)]
30struct GhIssue {
31    number: u64,
32    title: String,
33    body: Option<String>,
34    state: String,
35    user: Option<GhUser>,
36    labels: Option<Vec<GhLabel>>,
37    created_at: Option<String>,
38    closed_at: Option<String>,
39}
40
41#[derive(Debug, Deserialize)]
42struct GhUser {
43    login: String,
44}
45
46#[derive(Debug, Deserialize)]
47struct GhLabel {
48    name: String,
49}
50
51impl IssueProtocolHandler {
52    /// Parse the URL path into owner/repo/issue_number.
53    fn parse_url(url: &str) -> Result<IssueUrl, SdkError> {
54        let url = url.trim();
55        if url.is_empty() {
56            return Err(SdkError::Internal(anyhow::anyhow!("empty issue URL")));
57        }
58
59        let parts: Vec<&str> = url.split('/').collect();
60        match parts.len() {
61            1 => {
62                // Just an issue number — use current repo
63                let issue_number: u64 = parts[0].parse().map_err(|_| {
64                    SdkError::Internal(anyhow::anyhow!("invalid issue number: {}", parts[0]))
65                })?;
66                let repo = detect_github_repo().ok_or_else(|| {
67                    SdkError::Internal(anyhow::anyhow!(
68                        "could not detect GitHub repo from git remote; use owner/repo/N format"
69                    ))
70                })?;
71                let (owner, repo_name) = split_owner_repo(&repo)?;
72                Ok(IssueUrl {
73                    owner,
74                    repo: repo_name,
75                    issue_number,
76                })
77            }
78            2 => {
79                // owner/repo — missing issue number
80                Err(SdkError::Internal(anyhow::anyhow!(
81                    "issue URL requires a number: {url} (use owner/repo/N)"
82                )))
83            }
84            3 => {
85                // owner/repo/N
86                let issue_number: u64 = parts[2].parse().map_err(|_| {
87                    SdkError::Internal(anyhow::anyhow!("invalid issue number: {}", parts[2]))
88                })?;
89                Ok(IssueUrl {
90                    owner: parts[0].to_string(),
91                    repo: parts[1].to_string(),
92                    issue_number,
93                })
94            }
95            _ => Err(SdkError::Internal(anyhow::anyhow!(
96                "invalid issue URL format: {url}"
97            ))),
98        }
99    }
100}
101
102fn split_owner_repo(repo: &str) -> Result<(String, String), SdkError> {
103    let parts: Vec<&str> = repo.split('/').collect();
104    if parts.len() != 2 {
105        return Err(SdkError::Internal(anyhow::anyhow!(
106            "invalid repo format (expected owner/repo): {repo}"
107        )));
108    }
109    Ok((parts[0].to_string(), parts[1].to_string()))
110}
111
112#[async_trait]
113impl ProtocolHandler for IssueProtocolHandler {
114    fn scheme(&self) -> &str {
115        "issue"
116    }
117
118    async fn resolve(
119        &self,
120        url: &str,
121        _selector: Option<&str>,
122        _ctx: &ResolveContext,
123    ) -> Result<ResolvedUrl, SdkError> {
124        let parsed = Self::parse_url(url)?;
125
126        let api_url = format!(
127            "https://api.github.com/repos/{}/{}/issues/{}",
128            parsed.owner, parsed.repo, parsed.issue_number
129        );
130
131        let client = shared_http_client();
132        let mut request = client
133            .get(&api_url)
134            .header("User-Agent", "oxi-cli")
135            .header("Accept", "application/vnd.github.v3+json");
136
137        if let Some(token) = github_token() {
138            request = request.header("Authorization", format!("Bearer {}", token));
139        }
140
141        let response = request
142            .send()
143            .await
144            .map_err(|e| SdkError::Internal(anyhow::anyhow!("GitHub API request failed: {e}")))?;
145
146        if !response.status().is_success() {
147            let status = response.status();
148            let body = response.text().await.unwrap_or_default();
149            return Err(SdkError::Internal(anyhow::anyhow!(
150                "GitHub API returned {status}: {body}"
151            )));
152        }
153
154        let issue: GhIssue = response.json().await.map_err(|e| {
155            SdkError::Internal(anyhow::anyhow!("failed to parse GitHub API response: {e}"))
156        })?;
157
158        let content = format_issue_markdown(&issue);
159
160        Ok(ResolvedUrl {
161            url: format!(
162                "https://github.com/{}/{}/issues/{}",
163                parsed.owner, parsed.repo, parsed.issue_number
164            ),
165            content,
166            content_type: "text/markdown".into(),
167            size: None,
168            source_path: None,
169            notes: vec![],
170            immutable: false,
171        })
172    }
173}
174
175fn format_issue_markdown(issue: &GhIssue) -> String {
176    let mut md = format!("# Issue #{}: {}\n\n", issue.number, issue.title);
177
178    // State badge
179    let state_label = match issue.state.as_str() {
180        "open" => "🟢 Open",
181        "closed" => "🔴 Closed",
182        other => other,
183    };
184    md.push_str(&format!("**State:** {}\n\n", state_label));
185
186    // Author
187    if let Some(ref user) = issue.user {
188        md.push_str(&format!("**Author:** @{}\n\n", user.login));
189    }
190
191    // Labels
192    if let Some(ref labels) = issue.labels {
193        if !labels.is_empty() {
194            let label_names: Vec<&str> = labels.iter().map(|l| l.name.as_str()).collect();
195            md.push_str(&format!("**Labels:** {}\n\n", label_names.join(", ")));
196        }
197    }
198
199    // Dates
200    if let Some(ref created) = issue.created_at {
201        md.push_str(&format!("**Created:** {}\n", created));
202    }
203    if let Some(ref closed) = issue.closed_at {
204        md.push_str(&format!("**Closed:** {}\n", closed));
205    }
206
207    md.push('\n');
208
209    // Body
210    if let Some(ref body) = issue.body {
211        if !body.is_empty() {
212            md.push_str("---\n\n");
213            md.push_str(body);
214            md.push('\n');
215        }
216    }
217
218    md
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_parse_url_owner_repo_n() {
227        let result = IssueProtocolHandler::parse_url("rust-lang/rust/12345").unwrap();
228        assert_eq!(result.owner, "rust-lang");
229        assert_eq!(result.repo, "rust");
230        assert_eq!(result.issue_number, 12345);
231    }
232
233    #[test]
234    fn test_parse_url_rejects_two_parts() {
235        let result = IssueProtocolHandler::parse_url("owner/repo");
236        assert!(result.is_err());
237    }
238
239    #[test]
240    fn test_parse_url_rejects_empty() {
241        let result = IssueProtocolHandler::parse_url("");
242        assert!(result.is_err());
243    }
244
245    #[test]
246    fn test_parse_url_rejects_invalid_number() {
247        let result = IssueProtocolHandler::parse_url("owner/repo/abc");
248        assert!(result.is_err());
249    }
250
251    #[test]
252    fn test_format_issue_markdown() {
253        let issue = GhIssue {
254            number: 42,
255            title: "Fix memory leak".into(),
256            body: Some("This fixes the leak in the allocator.".into()),
257            state: "open".into(),
258            user: Some(GhUser {
259                login: "dev".into(),
260            }),
261            labels: Some(vec![
262                GhLabel { name: "bug".into() },
263                GhLabel {
264                    name: "high-priority".into(),
265                },
266            ]),
267            created_at: Some("2026-01-15T12:00:00Z".into()),
268            closed_at: None,
269        };
270
271        let md = format_issue_markdown(&issue);
272        assert!(md.contains("# Issue #42: Fix memory leak"));
273        assert!(md.contains("🟢 Open"));
274        assert!(md.contains("@dev"));
275        assert!(md.contains("bug, high-priority"));
276        assert!(md.contains("This fixes the leak"));
277    }
278}