Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
209 linesCodeBlameRaw
1//! A repository's `actions/cache` entries over REST and MCP, in GitHub's
2//! shapes: listing them, what they hold together against the repository's
3//! quota, and deleting them by key or by id.
4//!
5//! The actions service keeps them and decides who may see and delete them
6//! (services/actions/src/cache.rs); their bytes are in R2, which jobs reach
7//! through `/actions/jobs/{job}/cache` and the toolkit's protocols, never
8//! through these.
9
10use g1t_contracts::actions::{CacheList, CacheUsage, CacheUsageArgs, CachesArgs, DeleteCachesArgs};
11use g1t_contracts::{FailureCode, Outcome, Viewer};
12use serde_json::{Value, json};
13use worker::Result;
14
15use crate::operations::{Services, repo_path};
16
17/// One operation on the cache.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum CachesOp {
20 ListCaches,
21 GetCacheUsage,
22 DeleteCachesByKey,
23 DeleteCache,
24}
25
26impl CachesOp {
27 /// Every one: `Op::ALL` lists each as `Op::Caches(…)`, which a test
28 /// checks against this.
29 #[cfg(test)]
30 pub const ALL: [CachesOp; 4] = [CachesOp::ListCaches, CachesOp::GetCacheUsage, CachesOp::DeleteCachesByKey, CachesOp::DeleteCache];
31
32 pub fn name(self) -> &'static str {
33 match self {
34 CachesOp::ListCaches => "list_caches",
35 CachesOp::GetCacheUsage => "get_cache_usage",
36 CachesOp::DeleteCachesByKey => "delete_caches_by_key",
37 CachesOp::DeleteCache => "delete_cache",
38 }
39 }
40
41 /// For the API reference.
42 pub fn title(self) -> &'static str {
43 match self {
44 CachesOp::ListCaches => "List a repository's caches",
45 CachesOp::GetCacheUsage => "Get a repository's cache usage",
46 CachesOp::DeleteCachesByKey => "Delete caches by key",
47 CachesOp::DeleteCache => "Delete a cache by id",
48 }
49 }
50
51 pub fn description(self) -> &'static str {
52 match self {
53 CachesOp::ListCaches => "List the entries actions/cache has saved in a repository (total_count, actions_caches): each with its id (a number), key, ref (the ref whose run saved it; a pull request from outside saves under untrusted:<ref>), version (the hash of its paths; empty for one g1t's own actions/cache saved), size_in_bytes, created_at and last_accessed_at (its last restore or lookup). Narrow with key (entries whose key starts with it) and ref; order with sort (last_accessed_at, created_at or size_in_bytes; last_accessed_at by default) and direction (asc or desc; desc by default); page with page and per_page (30 by default, at most 100). Needs the Read role.",
54 CachesOp::GetCacheUsage => "What a repository's saved cache entries hold together: active_caches_size_in_bytes and active_caches_count, and quota_bytes, the most they may hold (10 GiB). Saving past it evicts the entries restored longest ago. Needs the Read role.",
55 CachesOp::DeleteCachesByKey => "Delete every cache entry whose key is exactly key, or only ref's when you give ref. Answers with the entries deleted, in list_caches' shape. Their bytes go at once, and a run that restores the key finds nothing until one saves it again. Needs the Write role.",
56 CachesOp::DeleteCache => "Delete one cache entry by its id, as list_caches gives it. Over REST it answers 204 with no body, as GitHub does. Needs the Write role.",
57 }
58 }
59
60 /// Whether it changes anything: what its scope says, which a test holds
61 /// it to.
62 #[cfg(test)]
63 pub fn writes(self) -> bool {
64 matches!(self, CachesOp::DeleteCachesByKey | CachesOp::DeleteCache)
65 }
66
67 pub fn input(self) -> Value {
68 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
69 let git_ref = json!({ "type": "string", "description": "A full ref, e.g. refs/heads/main or refs/pull/7/merge." });
70 let (properties, required): (Value, &[&str]) = match self {
71 CachesOp::ListCaches => (
72 json!({
73 "repo": repo,
74 "key": { "type": "string", "description": "Only entries whose key starts with this." },
75 "ref": git_ref,
76 "sort": { "type": "string", "enum": ["last_accessed_at", "created_at", "size_in_bytes"], "description": "What to order by: last_accessed_at unless you say." },
77 "direction": { "type": "string", "enum": ["asc", "desc"], "description": "desc unless you say." },
78 "page": { "type": "integer", "description": "The page, from 1." },
79 "per_page": { "type": "integer", "description": "Entries a page: 30 unless you say, at most 100." },
80 }),
81 &["repo"],
82 ),
83 CachesOp::GetCacheUsage => (json!({ "repo": repo }), &["repo"]),
84 CachesOp::DeleteCachesByKey => (
85 json!({
86 "repo": repo,
87 "key": { "type": "string", "description": "The key, exactly." },
88 "ref": git_ref,
89 }),
90 &["repo", "key"],
91 ),
92 CachesOp::DeleteCache => (
93 json!({ "repo": repo, "id": { "type": ["integer", "string"], "description": "The entry's id, a number." } }),
94 &["repo", "id"],
95 ),
96 };
97 json!({ "type": "object", "properties": properties, "required": required })
98 }
99}
100
101/// A number from a path segment, a query value or a JSON number.
102fn number(input: &Value, key: &str) -> Option<u64> {
103 match &input[key] {
104 Value::Number(n) => n.as_u64(),
105 Value::String(s) => s.trim().parse().ok(),
106 _ => None,
107 }
108}
109
110fn text(input: &Value, key: &str) -> Option<String> {
111 input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned)
112}
113
114fn shown<T: serde::Serialize>(outcome: Outcome<T>) -> Outcome<Value> {
115 match outcome {
116 Outcome::Ok(value) => Outcome::Ok(serde_json::to_value(value).unwrap_or(Value::Null)),
117 Outcome::Fail(refused) => Outcome::Fail(refused),
118 }
119}
120
121pub async fn run(op: CachesOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
122 let Some(repo) = repo_path(input) else {
123 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
124 };
125 let actions = &services.actions;
126 Ok(match op {
127 CachesOp::ListCaches => {
128 let args = CachesArgs {
129 repo,
130 viewer: viewer.clone(),
131 key: text(input, "key"),
132 git_ref: text(input, "ref"),
133 sort: text(input, "sort"),
134 direction: text(input, "direction"),
135 page: number(input, "page").map(|n| n.min(u64::from(u32::MAX)) as u32),
136 per_page: number(input, "per_page").map(|n| n.min(100) as u32),
137 };
138 let found: Outcome<CacheList> = g1t_kit::call(actions, "caches", &args).await?;
139 shown(found)
140 }
141 CachesOp::GetCacheUsage => {
142 let found: Outcome<CacheUsage> = g1t_kit::call(actions, "cache_usage", &CacheUsageArgs { repo: repo.clone(), viewer: viewer.clone() }).await?;
143 match found {
144 Outcome::Ok(usage) => Outcome::Ok(json!({
145 "full_name": format!("{}/{}", repo.namespace, repo.name),
146 "active_caches_size_in_bytes": usage.active_caches_size_in_bytes,
147 "active_caches_count": usage.active_caches_count,
148 "quota_bytes": usage.quota_bytes,
149 })),
150 Outcome::Fail(refused) => Outcome::Fail(refused),
151 }
152 }
153 CachesOp::DeleteCachesByKey | CachesOp::DeleteCache => {
154 let Some(actor) = viewer.clone() else {
155 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Deleting cache entries needs a g1t access token."));
156 };
157 let args = if op == CachesOp::DeleteCache {
158 let Some(id) = number(input, "id") else {
159 return Ok(Outcome::fail(FailureCode::Invalid, "Name the cache entry by its id, a number."));
160 };
161 DeleteCachesArgs { actor, repo, id: Some(id), key: None, git_ref: None }
162 } else {
163 let Some(key) = text(input, "key") else {
164 return Ok(Outcome::fail(FailureCode::Invalid, "Give the key of the entries to delete."));
165 };
166 DeleteCachesArgs { actor, repo, id: None, key: Some(key), git_ref: text(input, "ref") }
167 };
168 let done: Outcome<CacheList> = g1t_kit::call(actions, "delete_caches", &args).await?;
169 shown(done)
170 }
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use g1t_contracts::actions::CacheEntry;
178
179 #[test]
180 fn a_list_is_in_github_s_shape() {
181 let list = CacheList {
182 total_count: 1,
183 actions_caches: vec![CacheEntry {
184 id: 7,
185 key: "npm-full-Linux-abc".into(),
186 git_ref: "refs/heads/main".into(),
187 version: String::new(),
188 size_in_bytes: 1024,
189 created_at: "2026-10-12T10:00:00.000Z".into(),
190 last_accessed_at: "2026-10-12T11:00:00.000Z".into(),
191 }],
192 };
193 let Outcome::Ok(sent) = shown(Outcome::Ok(list)) else { panic!() };
194 assert_eq!(sent["actions_caches"][0]["ref"], "refs/heads/main");
195 assert_eq!(sent["actions_caches"][0]["id"], 7);
196 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
197 }
198
199 #[test]
200 fn each_operation_is_described_with_a_schema() {
201 for op in CachesOp::ALL {
202 assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Caches(op)), "{}", op.name());
203 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
204 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
205 let level = g1t_contracts::scopes::scope_for(op.name()).unwrap().level();
206 assert_eq!(op.writes(), level == g1t_contracts::scopes::Level::Write, "{}", op.name());
207 }
208 }
209}