Skip to main content

atomcode_core/atomgit/
url.rs

1use anyhow::{anyhow, Result};
2
3/// Parsed coordinates of an AtomGit/GitCode issue URL.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct IssueRef {
6    pub owner: String,
7    pub repo: String,
8    pub number: u64,
9}
10
11/// `(owner, repo)` without the issue number — produced either from an
12/// issue URL (via `IssueRef`) or from a git remote URL (via
13/// `parse_repo_url`). Case-insensitive equality for comparison.
14#[derive(Debug, Clone)]
15pub struct RepoRef {
16    pub owner: String,
17    pub repo: String,
18}
19
20impl RepoRef {
21    pub fn matches(&self, other: &RepoRef) -> bool {
22        self.owner.eq_ignore_ascii_case(&other.owner) && self.repo.eq_ignore_ascii_case(&other.repo)
23    }
24}
25
26impl From<&IssueRef> for RepoRef {
27    fn from(r: &IssueRef) -> Self {
28        Self {
29            owner: r.owner.clone(),
30            repo: r.repo.clone(),
31        }
32    }
33}
34
35/// Hosts we accept as "this is an atomgit.com repo". `gitcode.com` is
36/// the same physical origin mirrored onto a second domain — the owner /
37/// repo slug is identical and every API call still targets
38/// `api.atomgit.com`, so a checkout whose `origin` points to either host
39/// should be treated as the same repo for /issue / /fixissue purposes.
40///
41/// If more mirror hosts appear later (e.g. a private enterprise mirror),
42/// extend this list. Matching is case-insensitive.
43const ATOMGIT_MIRROR_HOSTS: &[&str] = &["atomgit.com", "gitcode.com"];
44
45fn is_atomgit_host(host: &str) -> bool {
46    ATOMGIT_MIRROR_HOSTS
47        .iter()
48        .any(|h| host.eq_ignore_ascii_case(h))
49}
50
51/// Parse a git remote URL into `(owner, repo)`. Supports the four
52/// common forms:
53///   * `https://atomgit.com/owner/repo.git`
54///   * `https://atomgit.com/owner/repo`
55///   * `git@atomgit.com:owner/repo.git`
56///   * `ssh://git@atomgit.com/owner/repo.git`
57///
58/// Also accepts `gitcode.com` in any of those forms — atomgit mirrors
59/// every repo onto gitcode with the same slug, and the API always
60/// resolves against `api.atomgit.com`, so the host is effectively
61/// informational here.
62///
63/// Returns `None` when the URL isn't an atomgit mirror host — used to
64/// detect "cwd is in a git repo but it's a different host (e.g. GitHub)"
65/// and skip validation rather than false-positive.
66pub fn parse_repo_url(url: &str) -> Option<RepoRef> {
67    let trimmed = url.trim();
68
69    // SSH shorthand: `git@host:owner/repo.git`
70    if let Some(rest) = trimmed.strip_prefix("git@") {
71        let (host_with_port, path) = rest.split_once(':')?;
72        let host = strip_port(host_with_port);
73        if !is_atomgit_host(host) {
74            return None;
75        }
76        return split_owner_repo(path);
77    }
78
79    // URL forms (https://, http://, ssh://)
80    let without_scheme = if let Some(rest) = trimmed.strip_prefix("https://") {
81        rest
82    } else if let Some(rest) = trimmed.strip_prefix("http://") {
83        rest
84    } else if let Some(rest) = trimmed.strip_prefix("ssh://") {
85        rest
86    } else {
87        return None;
88    };
89
90    // Strip optional userinfo (`user[:password]@`). Git for Windows
91    // credential helpers rewrite origin to embed the OAuth token, e.g.
92    // `https://oauth2:TOKEN@atomgit.com/owner/repo.git`; without this,
93    // the host check sees `oauth2:TOKEN@atomgit.com` and /issue /
94    // /fixissue both fail with "needs cwd to be a clone of an
95    // atomgit.com repo" even though the underlying repo IS atomgit.
96    // Same path also handles ssh://git@host/... (the leading `git@`
97    // is just userinfo here, no need for a separate strip).
98    //
99    // The `userinfo.contains('/')` guard ensures we only strip when
100    // the `@` appears in authority — a defensive parse for the rare
101    // case of `@` inside a path segment.
102    let after_userinfo = match without_scheme.split_once('@') {
103        Some((userinfo, rest)) if !userinfo.contains('/') => rest,
104        _ => without_scheme,
105    };
106
107    let (host_with_port, path) = after_userinfo.split_once('/')?;
108    let host = strip_port(host_with_port);
109    if !is_atomgit_host(host) {
110        return None;
111    }
112    split_owner_repo(path)
113}
114
115/// Strip an optional `:port` suffix from an authority component.
116/// `atomgit.com:443` → `atomgit.com`; `atomgit.com` → `atomgit.com`.
117fn strip_port(host: &str) -> &str {
118    host.split_once(':').map(|(h, _)| h).unwrap_or(host)
119}
120
121fn split_owner_repo(path: &str) -> Option<RepoRef> {
122    let mut parts = path
123        .trim_start_matches('/')
124        .split('/')
125        .filter(|s| !s.is_empty());
126    let owner = parts.next()?.to_string();
127    let repo = parts.next()?.to_string();
128    let repo = repo.strip_suffix(".git").unwrap_or(&repo).to_string();
129    if owner.is_empty() || repo.is_empty() {
130        return None;
131    }
132    Some(RepoRef { owner, repo })
133}
134
135/// Detect the atomgit.com (owner, repo) of a local git checkout by
136/// running `git remote get-url origin` in `cwd`. Returns:
137///   * `Ok(Some(RepoRef))` — found an atomgit.com origin.
138///   * `Ok(None)` — not a git repo, or origin points elsewhere / missing.
139///     Callers treat this as "can't validate, proceed with a warning".
140///   * `Err(...)` — the git command itself failed unexpectedly.
141pub fn detect_cwd_atomgit_repo(cwd: &std::path::Path) -> std::io::Result<Option<RepoRef>> {
142    let mut cmd = std::process::Command::new("git");
143    cmd.args(["remote", "get-url", "origin"])
144        .current_dir(cwd);
145    crate::process_utils::suppress_console_window_sync(&mut cmd);
146    let output = cmd.output()?;
147    if !output.status.success() {
148        // `git` returned non-zero — either not a repo, or no `origin`.
149        // Both are "can't validate"; don't bubble the raw stderr up.
150        return Ok(None);
151    }
152    let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
153    if url.is_empty() {
154        return Ok(None);
155    }
156    Ok(parse_repo_url(&url))
157}
158
159impl IssueRef {
160    /// Parse `https://atomgit.com/{owner}/{repo}/issues/{number}` or the
161    /// equivalent `gitcode.com` mirror URL.
162    /// Trailing slash and `?query`/`#fragment` are tolerated.
163    pub fn parse(url: &str) -> Result<Self> {
164        let trimmed = url.trim();
165        let without_scheme = trimmed
166            .strip_prefix("https://")
167            .or_else(|| trimmed.strip_prefix("http://"))
168            .ok_or_else(|| anyhow!("issue URL must start with http(s)://"))?;
169
170        // Drop query + fragment before splitting path segments.
171        let path_only = without_scheme
172            .split(['?', '#'])
173            .next()
174            .unwrap_or(without_scheme);
175
176        let mut parts = path_only.split('/').filter(|s| !s.is_empty());
177        let host = parts
178            .next()
179            .ok_or_else(|| anyhow!("missing host in issue URL"))?;
180        let host = strip_port(host);
181        if !is_atomgit_host(host) {
182            return Err(anyhow!(
183                "only atomgit.com/gitcode.com issue URLs are supported (got host {})",
184                host
185            ));
186        }
187
188        let owner = parts
189            .next()
190            .ok_or_else(|| anyhow!("missing owner in issue URL"))?
191            .to_string();
192        let repo = parts
193            .next()
194            .ok_or_else(|| anyhow!("missing repo in issue URL"))?
195            .to_string();
196        let issues_seg = parts
197            .next()
198            .ok_or_else(|| anyhow!("missing 'issues' segment in URL"))?;
199        if issues_seg != "issues" {
200            return Err(anyhow!(
201                "expected '/issues/' in URL, got '/{}/'",
202                issues_seg
203            ));
204        }
205        let number_str = parts
206            .next()
207            .ok_or_else(|| anyhow!("missing issue number in URL"))?;
208        let number = number_str
209            .parse::<u64>()
210            .map_err(|_| anyhow!("issue number '{}' is not a positive integer", number_str))?;
211
212        Ok(Self {
213            owner,
214            repo,
215            number,
216        })
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn parses_canonical_url() {
226        let r = IssueRef::parse("https://atomgit.com/atomgit_atomcode/atomcode/issues/42").unwrap();
227        assert_eq!(r.owner, "atomgit_atomcode");
228        assert_eq!(r.repo, "atomcode");
229        assert_eq!(r.number, 42);
230    }
231
232    #[test]
233    fn parses_with_trailing_slash() {
234        let r = IssueRef::parse("https://atomgit.com/a/b/issues/1/").unwrap();
235        assert_eq!(r.number, 1);
236    }
237
238    #[test]
239    fn parses_with_query_and_fragment() {
240        let r = IssueRef::parse("https://atomgit.com/a/b/issues/7?x=1#comment").unwrap();
241        assert_eq!(r.number, 7);
242    }
243
244    #[test]
245    fn parses_gitcode_mirror_issue_url() {
246        let r = IssueRef::parse("https://gitcode.com/atomgit_atomcode/atomcode/issues/340")
247            .unwrap();
248        assert_eq!(r.owner, "atomgit_atomcode");
249        assert_eq!(r.repo, "atomcode");
250        assert_eq!(r.number, 340);
251    }
252
253    #[test]
254    fn parses_issue_url_with_host_port() {
255        let r = IssueRef::parse("https://gitcode.com:443/a/b/issues/8").unwrap();
256        assert_eq!(r.owner, "a");
257        assert_eq!(r.repo, "b");
258        assert_eq!(r.number, 8);
259    }
260
261    #[test]
262    fn rejects_non_atomgit_host() {
263        assert!(IssueRef::parse("https://github.com/a/b/issues/1").is_err());
264    }
265
266    #[test]
267    fn rejects_missing_number() {
268        assert!(IssueRef::parse("https://atomgit.com/a/b/issues").is_err());
269    }
270
271    #[test]
272    fn rejects_non_numeric() {
273        assert!(IssueRef::parse("https://atomgit.com/a/b/issues/abc").is_err());
274    }
275
276    #[test]
277    fn rejects_wrong_path() {
278        assert!(IssueRef::parse("https://atomgit.com/a/b/pulls/1").is_err());
279    }
280
281    #[test]
282    fn parse_repo_url_https() {
283        let r = parse_repo_url("https://atomgit.com/owner/repo.git").unwrap();
284        assert_eq!(r.owner, "owner");
285        assert_eq!(r.repo, "repo");
286    }
287
288    #[test]
289    fn parse_repo_url_https_no_git_suffix() {
290        let r = parse_repo_url("https://atomgit.com/owner/repo").unwrap();
291        assert_eq!(r.repo, "repo");
292    }
293
294    #[test]
295    fn parse_repo_url_ssh_shorthand() {
296        let r = parse_repo_url("git@atomgit.com:owner/repo.git").unwrap();
297        assert_eq!(r.owner, "owner");
298        assert_eq!(r.repo, "repo");
299    }
300
301    #[test]
302    fn parse_repo_url_ssh_full() {
303        let r = parse_repo_url("ssh://git@atomgit.com/owner/repo.git").unwrap();
304        assert_eq!(r.owner, "owner");
305        assert_eq!(r.repo, "repo");
306    }
307
308    #[test]
309    fn parse_repo_url_rejects_non_atomgit() {
310        assert!(parse_repo_url("https://github.com/foo/bar.git").is_none());
311        assert!(parse_repo_url("git@github.com:foo/bar.git").is_none());
312    }
313
314    #[test]
315    fn parse_repo_url_strips_oauth_userinfo() {
316        // Git for Windows credential helper rewrites origin URLs to
317        // include the OAuth token in the userinfo segment after the
318        // user runs `git push` once. Before the fix, /issue / /fixissue
319        // both failed with "needs cwd to be a clone of an atomgit.com
320        // repo" because the host check saw `oauth2:TOKEN@atomgit.com`.
321        let r = parse_repo_url("https://oauth2:abc123token@atomgit.com/owner/repo.git").unwrap();
322        assert_eq!(r.owner, "owner");
323        assert_eq!(r.repo, "repo");
324    }
325
326    #[test]
327    fn parse_repo_url_strips_basic_auth_userinfo() {
328        // `https://user:password@host/...` — basic auth, also rewritten
329        // by some helpers. Same fix should cover it.
330        let r = parse_repo_url("https://user:password@atomgit.com/owner/repo.git").unwrap();
331        assert_eq!(r.owner, "owner");
332        assert_eq!(r.repo, "repo");
333    }
334
335    #[test]
336    fn parse_repo_url_strips_user_only_userinfo() {
337        // `https://user@host/...` — username with no password.
338        let r = parse_repo_url("https://alice@gitcode.com/atomgit_atomcode/atomcode").unwrap();
339        assert_eq!(r.owner, "atomgit_atomcode");
340        assert_eq!(r.repo, "atomcode");
341    }
342
343    #[test]
344    fn parse_repo_url_strips_host_port() {
345        // `https://atomgit.com:443/...` — explicit port, legal but rare.
346        let r = parse_repo_url("https://atomgit.com:443/owner/repo.git").unwrap();
347        assert_eq!(r.owner, "owner");
348        assert_eq!(r.repo, "repo");
349    }
350
351    #[test]
352    fn parse_repo_url_ssh_with_port() {
353        // `ssh://git@atomgit.com:22/...`.
354        let r = parse_repo_url("ssh://git@atomgit.com:22/owner/repo.git").unwrap();
355        assert_eq!(r.owner, "owner");
356        assert_eq!(r.repo, "repo");
357    }
358
359    #[test]
360    fn parse_repo_url_oauth_token_does_not_match_non_atomgit() {
361        // Defensive: an oauth-rewritten URL pointing at github.com is
362        // still not atomgit. The fix must not introduce a false positive.
363        assert!(parse_repo_url("https://oauth2:TOKEN@github.com/foo/bar.git").is_none());
364    }
365
366    #[test]
367    fn parse_repo_url_accepts_gitcode_mirror() {
368        // gitcode.com is the second domain atomgit mirrors every repo
369        // onto. Plenty of users' local `origin` is set to the gitcode
370        // remote (e.g. `git@gitcode.com:atomgit_atomcode/atomcode.git`)
371        // while the issue / API still lives on atomgit.com. The slug is
372        // the same, so `/issue` / `/fixissue` must treat it as the same
373        // repo — otherwise cwd validation fails with "needs cwd to be a
374        // clone of an atomgit.com repo" and the user gets stuck.
375        for url in [
376            "git@gitcode.com:atomgit_atomcode/atomcode.git",
377            "https://gitcode.com/atomgit_atomcode/atomcode.git",
378            "https://gitcode.com/atomgit_atomcode/atomcode",
379            "ssh://git@gitcode.com/atomgit_atomcode/atomcode.git",
380        ] {
381            let r = parse_repo_url(url)
382                .unwrap_or_else(|| panic!("should parse gitcode mirror URL: {}", url));
383            assert_eq!(r.owner, "atomgit_atomcode");
384            assert_eq!(r.repo, "atomcode");
385        }
386    }
387
388    #[test]
389    fn repo_ref_matches_case_insensitive() {
390        let a = RepoRef {
391            owner: "Atomgit_Atomcode".into(),
392            repo: "AtomCode".into(),
393        };
394        let b = RepoRef {
395            owner: "atomgit_atomcode".into(),
396            repo: "atomcode".into(),
397        };
398        assert!(a.matches(&b));
399    }
400
401    #[test]
402    fn issue_ref_to_repo_ref() {
403        let r = IssueRef::parse("https://atomgit.com/o/r/issues/1").unwrap();
404        let rr: RepoRef = (&r).into();
405        assert_eq!(rr.owner, "o");
406        assert_eq!(rr.repo, "r");
407    }
408}