| 1 | //! A repository's files at a branch, tag or commit as one archive, at |
| 2 | //! GitHub's addresses: `GET /repos/{owner}/{repo}/tarball/{ref}` and |
| 3 | //! `/zipball/{ref}` (and without a ref, the default branch). |
| 4 | //! |
| 5 | //! As GitHub's redirect to codeload, these answer `302 Found` with a link to |
| 6 | //! the site's archive route (apps/web routes/repo/archive.ts) for the commit |
| 7 | //! the ref names, carrying a token the repos service made for whoever asked |
| 8 | //! (services/repos archive_links.rs). The link is good for 10 minutes and |
| 9 | //! needs no token of its own, so a client following the redirect, which |
| 10 | //! drops its `Authorization` header on the way, gets a private |
| 11 | //! repository's files too. The archive holds one folder, |
| 12 | //! `<owner>-<repo>-<short sha>/`, as GitHub's does: `actions/checkout` |
| 13 | //! downloads one this way in a job container without git, and expects it. |
| 14 | |
| 15 | use g1t_contracts::repos::{ArchiveLink, ArchiveLinkArgs}; |
| 16 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 17 | use serde_json::{Value, json}; |
| 18 | use worker::Result; |
| 19 | |
| 20 | use crate::operations::{Services, repo_path}; |
| 21 | |
| 22 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 23 | pub enum ArchiveOp { |
| 24 | DownloadTarball, |
| 25 | DownloadZipball, |
| 26 | } |
| 27 | |
| 28 | impl ArchiveOp { |
| 29 | #[cfg(test)] |
| 30 | pub const ALL: [ArchiveOp; 2] = [ArchiveOp::DownloadTarball, ArchiveOp::DownloadZipball]; |
| 31 | |
| 32 | pub fn name(self) -> &'static str { |
| 33 | match self { |
| 34 | ArchiveOp::DownloadTarball => "download_tarball_archive", |
| 35 | ArchiveOp::DownloadZipball => "download_zipball_archive", |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | /// For the API reference. |
| 40 | pub fn title(self) -> &'static str { |
| 41 | match self { |
| 42 | ArchiveOp::DownloadTarball => "Download a repository archive (tar)", |
| 43 | ArchiveOp::DownloadZipball => "Download a repository archive (zip)", |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | pub fn description(self) -> &'static str { |
| 48 | match self { |
| 49 | ArchiveOp::DownloadTarball => "A link to download a branch, tag or commit as a gzipped tar archive (the default branch without ref), good for 10 minutes and needing no token. Over REST, GET …/tarball/{ref} answers 302 with the link in Location, as GitHub does: `curl -L` follows it. Over MCP the link is returned as url, with commit and expires_at. The archive holds one folder, <owner>-<repo>-<first 7 of the commit>/, with executable files and symlinks as git has them and the commit in its pax header. Up to 10,000 files and 24 MB; clone a larger repository. Needs the Read role; a public repository's are open to anyone.", |
| 50 | ArchiveOp::DownloadZipball => "A link to download a branch, tag or commit as a zip archive (the default branch without ref), good for 10 minutes and needing no token. Over REST, GET …/zipball/{ref} answers 302 with the link in Location, as GitHub does: `curl -L` follows it. Over MCP the link is returned as url, with commit and expires_at. The archive holds one folder, <owner>-<repo>-<first 7 of the commit>/. Up to 10,000 files and 24 MB; clone a larger repository. Needs the Read role; a public repository's are open to anyone.", |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | fn suffix(self) -> &'static str { |
| 55 | match self { |
| 56 | ArchiveOp::DownloadTarball => "tar.gz", |
| 57 | ArchiveOp::DownloadZipball => "zip", |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | pub fn input(self) -> Value { |
| 62 | json!({ |
| 63 | "type": "object", |
| 64 | "properties": { |
| 65 | "repo": { "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }, |
| 66 | "ref": { "type": "string", "description": "A branch, tag or commit; the default branch when left out. A branch with slashes is sent URL-encoded as one path segment." }, |
| 67 | }, |
| 68 | "required": ["repo"], |
| 69 | }) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Where the site serves the archive of `link`. |
| 74 | pub fn archive_url(site: &str, link: &ArchiveLink, op: ArchiveOp) -> String { |
| 75 | format!( |
| 76 | "{site}/{}/{}/archive/{}.{}?token={}", |
| 77 | link.repo.namespace, |
| 78 | link.repo.name, |
| 79 | link.commit, |
| 80 | op.suffix(), |
| 81 | link.token |
| 82 | ) |
| 83 | } |
| 84 | |
| 85 | pub async fn run(op: ArchiveOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 86 | let Some(path) = repo_path(input) else { |
| 87 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); |
| 88 | }; |
| 89 | let git_ref = input["ref"].as_str().map(str::trim).filter(|git_ref| !git_ref.is_empty()).map(str::to_owned); |
| 90 | let found: Outcome<ArchiveLink> = g1t_kit::call(&services.repos, "archive_link", &ArchiveLinkArgs { path, viewer: viewer.clone(), git_ref }).await?; |
| 91 | Ok(match found { |
| 92 | Outcome::Ok(link) => Outcome::Ok(json!({ |
| 93 | "url": archive_url(&services.addresses.site, &link, op), |
| 94 | "commit": link.commit, |
| 95 | "expires_at": link.expires_at, |
| 96 | })), |
| 97 | Outcome::Fail(refused) => Outcome::Fail(refused), |
| 98 | }) |
| 99 | } |
| 100 | |
| 101 | #[cfg(test)] |
| 102 | mod tests { |
| 103 | use super::*; |
| 104 | |
| 105 | fn link() -> ArchiveLink { |
| 106 | let repo: g1t_contracts::repos::Repo = serde_json::from_value(json!({ |
| 107 | "id": "repo_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true, |
| 108 | "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-10-01T00:00:00.000Z", |
| 109 | })) |
| 110 | .unwrap(); |
| 111 | ArchiveLink { repo, commit: "4807077b296e6edbf410d55e72749d3e1170c291".into(), token: "abc_-123".into(), expires_at: "2026-10-12T00:10:00.000Z".into() } |
| 112 | } |
| 113 | |
| 114 | #[test] |
| 115 | fn the_link_is_the_sites_archive_of_the_commit() { |
| 116 | let link = link(); |
| 117 | assert_eq!( |
| 118 | archive_url("https://g1t.sh", &link, ArchiveOp::DownloadTarball), |
| 119 | "https://g1t.sh/acme/web/archive/4807077b296e6edbf410d55e72749d3e1170c291.tar.gz?token=abc_-123" |
| 120 | ); |
| 121 | assert_eq!( |
| 122 | archive_url("http://localhost:8787", &link, ArchiveOp::DownloadZipball), |
| 123 | "http://localhost:8787/acme/web/archive/4807077b296e6edbf410d55e72749d3e1170c291.zip?token=abc_-123" |
| 124 | ); |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn each_operation_is_described_with_a_schema() { |
| 129 | for op in ArchiveOp::ALL { |
| 130 | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Archives(op)), "{}", op.name()); |
| 131 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); |
| 132 | assert_eq!(op.input()["required"], json!(["repo"])); |
| 133 | assert_eq!(g1t_contracts::scopes::scope_for(op.name()), Some(g1t_contracts::scopes::Scope::CodeRead), "{}", op.name()); |
| 134 | } |
| 135 | } |
| 136 | } |