oxi/internal_urls/
issue_handler.rs1use 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#[derive(Debug, Clone, Default)]
19pub struct IssueProtocolHandler;
20
21struct IssueUrl {
23 owner: String,
24 repo: String,
25 issue_number: u64,
26}
27
28#[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 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 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 Err(SdkError::Internal(anyhow::anyhow!(
81 "issue URL requires a number: {url} (use owner/repo/N)"
82 )))
83 }
84 3 => {
85 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 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 if let Some(ref user) = issue.user {
188 md.push_str(&format!("**Author:** @{}\n\n", user.login));
189 }
190
191 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 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 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}