Skip to main content

worktree_io/
issue.rs

1use anyhow::{bail, Context, Result};
2use std::path::PathBuf;
3use url::Url;
4
5/// A reference to a GitHub issue that identifies a workspace.
6#[derive(Debug, Clone, PartialEq)]
7pub enum IssueRef {
8    GitHub {
9        owner: String,
10        repo: String,
11        number: u64,
12    },
13}
14
15impl IssueRef {
16    /// Parse any of the supported input formats:
17    /// - `https://github.com/owner/repo/issues/42`
18    /// - `worktree://open?owner=X&repo=Y&issue=42`
19    /// - `worktree://open?url=<encoded-github-url>`
20    /// - `owner/repo#42`
21    pub fn parse(s: &str) -> Result<Self> {
22        let s = s.trim();
23
24        // Try worktree:// scheme first
25        if s.starts_with("worktree://") {
26            return Self::parse_worktree_url(s);
27        }
28
29        // Try https://github.com URL
30        if s.starts_with("https://github.com") || s.starts_with("http://github.com") {
31            return Self::parse_github_url(s);
32        }
33
34        // Try owner/repo#N shorthand
35        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                    // query_pairs() already percent-decodes the value for us
66                    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        // Expect: owner / repo / "issues" / number
89        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        // Format: owner/repo#42
106        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    /// Directory name used inside the bare clone for this worktree.
126    pub fn workspace_dir_name(&self) -> String {
127        match self {
128            Self::GitHub { number, .. } => format!("issue-{number}"),
129        }
130    }
131
132    /// Git branch name for this issue worktree.
133    pub fn branch_name(&self) -> String {
134        self.workspace_dir_name()
135    }
136
137    /// HTTPS clone URL for the repository.
138    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    /// Path to the worktree checkout: `$TMPDIR/worktree-io/github/owner/repo/issue-N`
147    pub fn temp_path(&self) -> PathBuf {
148        self.bare_clone_path().join(self.workspace_dir_name())
149    }
150
151    /// Path to the bare clone: `$TMPDIR/worktree-io/github/owner/repo`
152    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}