Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.