1use anyhow::{bail, Context, Result};
2use std::path::PathBuf;
3use url::Url;
4
5#[derive(Debug, Clone, PartialEq)]
7pub enum IssueRef {
8 GitHub {
9 owner: String,
10 repo: String,
11 number: u64,
12 },
13}
14
15impl IssueRef {
16 pub fn parse(s: &str) -> Result<Self> {
22 let s = s.trim();
23
24 if s.starts_with("worktree://") {
26 return Self::parse_worktree_url(s);
27 }
28
29 if s.starts_with("https://github.com") || s.starts_with("http://github.com") {
31 return Self::parse_github_url(s);
32 }
33
34 if let Some(result) = Self::try_parse_shorthand(s) {
36 return result;
37 }
38
39 bail!(
40 "Could not parse issue reference: {s:?}\n\
41 Supported formats:\n\
42 - https://github.com/owner/repo/issues/42\n\
43 - worktree://open?owner=owner&repo=repo&issue=42\n\
44 - owner/repo#42"
45 )
46 }
47
48 fn parse_worktree_url(s: &str) -> Result<Self> {
49 let url = Url::parse(s).with_context(|| format!("Invalid URL: {s}"))?;
50 let mut owner = None;
51 let mut repo = None;
52 let mut issue_num = None;
53
54 for (key, val) in url.query_pairs() {
55 match key.as_ref() {
56 "owner" => owner = Some(val.into_owned()),
57 "repo" => repo = Some(val.into_owned()),
58 "issue" => {
59 issue_num = Some(
60 val.parse::<u64>()
61 .with_context(|| format!("Invalid issue number: {val}"))?,
62 );
63 }
64 "url" => {
65 return Self::parse_github_url(&val);
67 }
68 _ => {}
69 }
70 }
71
72 Ok(Self::GitHub {
73 owner: owner.context("Missing 'owner' query param")?,
74 repo: repo.context("Missing 'repo' query param")?,
75 number: issue_num.context("Missing 'issue' query param")?,
76 })
77 }
78
79 fn parse_github_url(s: &str) -> Result<Self> {
80 let url = Url::parse(s).with_context(|| format!("Invalid URL: {s}"))?;
81
82 let segments: Vec<&str> = url
83 .path_segments()
84 .context("URL has no path")?
85 .filter(|s| !s.is_empty())
86 .collect();
87
88 if segments.len() < 4 || segments[2] != "issues" {
90 bail!(
91 "Expected GitHub issue URL like https://github.com/owner/repo/issues/42, got: {s}"
92 );
93 }
94
95 let owner = segments[0].to_string();
96 let repo = segments[1].to_string();
97 let number = segments[3]
98 .parse::<u64>()
99 .with_context(|| format!("Invalid issue number in URL: {}", segments[3]))?;
100
101 Ok(Self::GitHub { owner, repo, number })
102 }
103
104 fn try_parse_shorthand(s: &str) -> Option<Result<Self>> {
105 let (repo_part, num_str) = s.split_once('#')?;
107 let (owner, repo) = repo_part.split_once('/')?;
108
109 if owner.is_empty() || repo.is_empty() {
110 return Some(Err(anyhow::anyhow!("Invalid shorthand format: {s}")));
111 }
112
113 let number = match num_str.parse::<u64>() {
114 Ok(n) => n,
115 Err(_) => return Some(Err(anyhow::anyhow!("Invalid issue number in shorthand: {num_str}"))),
116 };
117
118 Some(Ok(Self::GitHub {
119 owner: owner.to_string(),
120 repo: repo.to_string(),
121 number,
122 }))
123 }
124
125 pub fn workspace_dir_name(&self) -> String {
127 match self {
128 Self::GitHub { number, .. } => format!("issue-{number}"),
129 }
130 }
131
132 pub fn branch_name(&self) -> String {
134 self.workspace_dir_name()
135 }
136
137 pub fn clone_url(&self) -> String {
139 match self {
140 Self::GitHub { owner, repo, .. } => {
141 format!("https://github.com/{owner}/{repo}.git")
142 }
143 }
144 }
145
146 pub fn temp_path(&self) -> PathBuf {
148 self.bare_clone_path().join(self.workspace_dir_name())
149 }
150
151 pub fn bare_clone_path(&self) -> PathBuf {
153 match self {
154 Self::GitHub { owner, repo, .. } => std::env::temp_dir()
155 .join("worktree-io")
156 .join("github")
157 .join(owner)
158 .join(repo),
159 }
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn test_parse_shorthand() {
169 let r = IssueRef::parse("owner/repo#42").unwrap();
170 assert_eq!(
171 r,
172 IssueRef::GitHub {
173 owner: "owner".into(),
174 repo: "repo".into(),
175 number: 42
176 }
177 );
178 }
179
180 #[test]
181 fn test_parse_github_url() {
182 let r = IssueRef::parse("https://github.com/microsoft/vscode/issues/12345").unwrap();
183 assert_eq!(
184 r,
185 IssueRef::GitHub {
186 owner: "microsoft".into(),
187 repo: "vscode".into(),
188 number: 12345
189 }
190 );
191 }
192
193 #[test]
194 fn test_parse_worktree_url() {
195 let r = IssueRef::parse("worktree://open?owner=acme&repo=api&issue=7").unwrap();
196 assert_eq!(
197 r,
198 IssueRef::GitHub {
199 owner: "acme".into(),
200 repo: "api".into(),
201 number: 7
202 }
203 );
204 }
205
206 #[test]
207 fn test_paths() {
208 let r = IssueRef::GitHub {
209 owner: "acme".into(),
210 repo: "api".into(),
211 number: 7,
212 };
213 assert!(r.bare_clone_path().ends_with("worktree-io/github/acme/api"));
214 assert!(r.temp_path().ends_with("worktree-io/github/acme/api/issue-7"));
215 }
216
217 #[test]
218 fn test_clone_url() {
219 let r = IssueRef::GitHub {
220 owner: "acme".into(),
221 repo: "api".into(),
222 number: 7,
223 };
224 assert_eq!(r.clone_url(), "https://github.com/acme/api.git");
225 }
226}