Skip to content

g1t/apps/api/src/about.rs

356 lines18,932 bytesCodeBlame

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.

About: license, languages, contributors, stars, releases and activity beside the files1//! A repository's About over REST and MCP: its languages, contributors and
2//! license (read from the default branch in the background and kept by
3//! commit), stars, and releases. The repos service decides who may see
4//! and change each (`g1t_contracts::about`).
5
6use g1t_contracts::about::*;
7use g1t_contracts::repos::RepoPath;
8use g1t_contracts::{FailureCode, Outcome, Viewer};
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11use serde_json::{Value, json};
12use worker::Result;
13
14use crate::operations::Services;
15
16/// One operation on a repository's About, stars or releases.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum AboutOp {
19 GetLanguages,
20 ListContributors,
21 GetLicense,
22 ListStargazers,
23 ListStarred,
24 CheckStarred,
25 Star,
26 Unstar,
27 ListReleases,
28 GetLatestRelease,
29 GetReleaseByTag,
30 GetRelease,
31 CreateRelease,
32 UpdateRelease,
33 DeleteRelease,
34}
35
36impl AboutOp {
37 /// Every one: `Op::ALL` lists each as `Op::About(…)`, which a test checks.
38 #[cfg(test)]
39 pub const ALL: [AboutOp; 15] = [
40 AboutOp::GetLanguages,
41 AboutOp::ListContributors,
42 AboutOp::GetLicense,
43 AboutOp::ListStargazers,
44 AboutOp::ListStarred,
45 AboutOp::CheckStarred,
46 AboutOp::Star,
47 AboutOp::Unstar,
48 AboutOp::ListReleases,
49 AboutOp::GetLatestRelease,
50 AboutOp::GetReleaseByTag,
51 AboutOp::GetRelease,
52 AboutOp::CreateRelease,
53 AboutOp::UpdateRelease,
54 AboutOp::DeleteRelease,
55 ];
56
57 pub fn name(self) -> &'static str {
58 match self {
59 AboutOp::GetLanguages => "get_languages",
60 AboutOp::ListContributors => "list_contributors",
61 AboutOp::GetLicense => "get_license",
62 AboutOp::ListStargazers => "list_stargazers",
63 AboutOp::ListStarred => "list_starred",
64 AboutOp::CheckStarred => "check_starred",
65 AboutOp::Star => "star_repo",
66 AboutOp::Unstar => "unstar_repo",
67 AboutOp::ListReleases => "list_releases",
68 AboutOp::GetLatestRelease => "get_latest_release",
69 AboutOp::GetReleaseByTag => "get_release_by_tag",
70 AboutOp::GetRelease => "get_release",
71 AboutOp::CreateRelease => "create_release",
72 AboutOp::UpdateRelease => "update_release",
73 AboutOp::DeleteRelease => "delete_release",
74 }
75 }
76
77 /// For the API reference: "List a repository's languages".
78 pub fn title(self) -> &'static str {
79 match self {
80 AboutOp::GetLanguages => "Get a repository's languages",
81 AboutOp::ListContributors => "List a repository's contributors",
82 AboutOp::GetLicense => "Get a repository's license",
83 AboutOp::ListStargazers => "List who starred a repository",
84 AboutOp::ListStarred => "List repositories you starred",
85 AboutOp::CheckStarred => "Check whether you starred a repository",
86 AboutOp::Star => "Star a repository",
87 AboutOp::Unstar => "Unstar a repository",
88 AboutOp::ListReleases => "List releases",
89 AboutOp::GetLatestRelease => "Get the latest release",
90 AboutOp::GetReleaseByTag => "Get a release by its tag",
91 AboutOp::GetRelease => "Get a release",
92 AboutOp::CreateRelease => "Create a release",
93 AboutOp::UpdateRelease => "Update a release",
94 AboutOp::DeleteRelease => "Delete a release",
95 }
96 }
97
98 pub fn description(self) -> &'static str {
99 match self {
100 AboutOp::GetLanguages => "The languages a repository's default branch is written in, by bytes, largest first: each with its name, color, bytes and percent. Programming and markup languages count; data (JSON, YAML) and prose (Markdown) do not, nor do vendored, generated and documentation files, unless the repository's .gitattributes says otherwise (linguist-vendored, linguist-generated, linguist-documentation, linguist-language, linguist-detectable). Worked out in the background for the default branch's head and kept by commit: commit says which commit the answer is for, and pending is true while the first is worked out (ask again in a few seconds). partial is true when the repository was too large to read in full.",
101 AboutOp::ListContributors => "Everyone whose commits are on a repository's default branch, most commits first: each with kind (user, matched to an account by an address they confirmed or their noreply address; g1t, g1t itself; author, anyone else, by the name on their commits), name, username and avatar for a user, commits, first_at, last_at, and weeks (commits per week, for the 100 most active). total is how many there are (at most 500 are listed), commits how many were read (the newest 3,000), and weeks the repository's commits by week, oldest first. Worked out in the background and kept by commit, as for get_languages.",
102 AboutOp::GetLicense => "The license in a repository's LICENSE file (or LICENCE, COPYING, UNLICENSE, with or without an extension) on its default branch: its spdx_id (null when the text is not one g1t recognizes), name (\"Other\" then) and path. Not found when it has none, or before the default branch has been read the first time.",
103 AboutOp::ListStargazers => "Who starred a repository, newest first: each username, avatar and starred_at. 100 a page; page from 1.",
104 AboutOp::ListStarred => "The repositories you starred that you can still see, newest first, at most 100: each repository, when you starred it (starred_at) and how many stars it has.",
105 AboutOp::CheckStarred => "Whether you starred a repository (starred), and how many people have (stars).",
106 AboutOp::Star => "Star a repository you can see. Starring one you starred already changes nothing. Returns starred and the count of stars. People only: a workspace's or an agent's token cannot.",
107 AboutOp::Unstar => "Take back your star from a repository. Returns starred (false) and the count of stars.",
108 AboutOp::ListReleases => "A repository's releases, newest first, at most 100: each with its id (rel_…), tag_name, target (the commit the tag named), name, body (Markdown notes), draft, prerelease, author, created_at, published_at and latest (the newest published release that is neither a draft nor a prerelease). Drafts are listed only to those with the Write role.",
109 AboutOp::GetLatestRelease => "The latest release: the newest published release that is neither a draft nor a prerelease. Not found when there is none.",
110 AboutOp::GetReleaseByTag => "The release of one tag. A tag with slashes is URL-encoded in the path.",
111 AboutOp::GetRelease => "One release by its id (rel_…), with its tag, target commit, title, notes and whether it is a draft, a prerelease or the latest. A draft is found only by those with the Write role.",
112 AboutOp::CreateRelease => "Publish a release of a tag, with a title (release_name; name in the answer) and notes (body, Markdown). A tag that does not exist yet is made at target (a branch or commit; the default branch when left out), as a lightweight tag, under the repository's tag rulesets. draft keeps it from everyone without the Write role until it is published; prerelease marks it not ready for everyone, so it is never the latest. One release per tag. Needs the Write role.",
113 AboutOp::UpdateRelease => "Change a release's name, body, draft or prerelease; fields left out stay as they are, and an empty release_name clears it. Setting draft to false publishes it (published_at is set the first time). Needs the Write role.",
114 AboutOp::DeleteRelease => "Delete a release. Its tag stays: delete that with git (git push origin :refs/tags/<tag>). Needs the Write role.",
115 }
116 }
117
118 /// Whether the operation is about one repository named by `repo`.
119 pub fn needs_repo(self) -> bool {
120 self != AboutOp::ListStarred
121 }
122
123 /// Whether an anonymous caller may use it, on a public repository.
124 pub fn anonymous(self) -> bool {
125 matches!(
126 self,
127 AboutOp::GetLanguages
128 | AboutOp::ListContributors
129 | AboutOp::GetLicense
130 | AboutOp::ListStargazers
131 | AboutOp::ListReleases
132 | AboutOp::GetLatestRelease
133 | AboutOp::GetReleaseByTag
134 | AboutOp::GetRelease
135 )
136 }
137
138 /// Whether it is about the caller's own stars: nobody else's business,
139 /// so not audited.
140 pub fn personal(self) -> bool {
141 matches!(self, AboutOp::ListStarred | AboutOp::CheckStarred | AboutOp::Star | AboutOp::Unstar)
142 }
143
144 pub fn input(self) -> Value {
145 let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
146 let id = || json!({ "type": "string", "description": "The release's id: rel_…" });
147 let release_fields = |mut properties: Value| {
148 properties["release_name"] = json!({ "type": "string", "description": "Its title (name in the answer), at most 200 characters; under a repository's address `name` is the repository's. The tag's name is shown when it has none." });
149 properties["body"] = json!({ "type": "string", "description": "Its notes, Markdown, at most 125,000 characters." });
150 properties["draft"] = json!({ "type": "boolean", "description": "Seen only by those with the Write role until published." });
151 properties["prerelease"] = json!({ "type": "boolean", "description": "Not ready for everyone: never the latest release." });
152 properties
153 };
154 let (properties, required): (Value, &[&str]) = match self {
155 AboutOp::GetLanguages
156 | AboutOp::ListContributors
157 | AboutOp::GetLicense
158 | AboutOp::CheckStarred
159 | AboutOp::Star
160 | AboutOp::Unstar
161 | AboutOp::ListReleases
162 | AboutOp::GetLatestRelease => (json!({ "repo": repo() }), &["repo"]),
163 AboutOp::ListStargazers => (
164 json!({ "repo": repo(), "page": { "type": "integer", "description": "The page, from 1; 100 a page." } }),
165 &["repo"],
166 ),
167 AboutOp::ListStarred => (json!({}), &[]),
168 AboutOp::GetReleaseByTag => (
169 json!({ "repo": repo(), "tag": { "type": "string", "description": "The tag's name, such as v1.2.0." } }),
170 &["repo", "tag"],
171 ),
172 AboutOp::GetRelease | AboutOp::DeleteRelease => (json!({ "repo": repo(), "id": id() }), &["repo", "id"]),
173 AboutOp::CreateRelease => (
174 release_fields(json!({
175 "repo": repo(),
176 "tag_name": { "type": "string", "description": "The tag to release, such as v1.2.0. Made at target when it does not exist yet." },
177 "target": { "type": "string", "description": "A branch or commit to make a new tag at. The default branch when left out; ignored for a tag that exists." },
178 })),
179 &["repo", "tag_name"],
180 ),
181 AboutOp::UpdateRelease => (release_fields(json!({ "repo": repo(), "id": id() })), &["repo", "id"]),
182 };
183 let mut schema = json!({ "type": "object", "properties": properties });
184 if !required.is_empty() {
185 schema["required"] = json!(required);
186 }
187 schema
188 }
189}
190
191fn text(input: &Value, key: &str) -> Option<String> {
192 input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned)
193}
194
195/// A string that may be given empty, to clear it.
196fn given(input: &Value, key: &str) -> Option<String> {
197 input[key].as_str().map(str::to_owned)
198}
199
200fn flag(input: &Value, key: &str) -> Option<bool> {
201 match &input[key] {
202 Value::Bool(value) => Some(*value),
203 Value::String(text) => match text.trim() {
204 "true" | "1" => Some(true),
205 "false" | "0" => Some(false),
206 _ => None,
207 },
208 _ => None,
209 }
210}
211
212fn number(input: &Value, key: &str) -> Option<u32> {
213 match &input[key] {
214 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
215 Value::String(digits) => digits.trim().parse().ok(),
216 _ => None,
217 }
218}
219
220async fn call<A: Serialize, T: DeserializeOwned>(services: &Services, method: &str, args: &A) -> Result<Outcome<T>> {
221 g1t_kit::call(&services.repos, method, args).await
222}
223
224fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> {
225 Ok(Outcome::Ok(serde_json::to_value(value)?))
226}
227
228fn out<T: Serialize>(outcome: Outcome<T>) -> Result<Outcome<Value>> {
229 match outcome {
230 Outcome::Ok(value) => ok(&value),
231 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
232 }
233}
234
235pub async fn run(op: AboutOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
236 let actor = || viewer.clone().unwrap_or_default();
237 if op == AboutOp::ListStarred {
238 let username = actor().username;
239 let starred: Vec<StarredRepo> = g1t_kit::call(&services.repos, "starred", &StarredArgs { username, viewer: viewer.clone() }).await?;
240 return ok(&starred);
241 }
242 let Some(path) = crate::operations::repo_path(input) else {
243 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
244 };
245 let view = |path: RepoPath| RepoViewArgs { path, viewer: viewer.clone() };
246 let release = |path: RepoPath, id: Option<String>, tag: Option<String>, latest: bool| ReleaseArgs { path, viewer: viewer.clone(), id, tag, latest };
247 match op {
248 AboutOp::GetLanguages => out(call::<_, Languages>(services, "languages", &view(path)).await?),
249 AboutOp::ListContributors => out(call::<_, Contributors>(services, "contributors", &view(path)).await?),
250 AboutOp::GetLicense => match call::<_, Option<License>>(services, "license", &view(path)).await? {
251 Outcome::Ok(Some(license)) => ok(&license),
252 Outcome::Ok(None) => Ok(Outcome::fail(
253 FailureCode::NotFound,
254 "No license file was found on the default branch. A repository just pushed to is read in the background: try again in a moment.",
255 )),
256 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
257 },
258 AboutOp::ListStargazers => {
259 out(call::<_, Vec<Stargazer>>(services, "stargazers", &StargazersArgs { path, viewer: viewer.clone(), page: number(input, "page") }).await?)
260 }
261 AboutOp::CheckStarred => out(call::<_, Stars>(services, "stars", &view(path)).await?),
262 AboutOp::Star | AboutOp::Unstar => {
263 out(call::<_, Stars>(services, "star", &StarArgs { path, actor: actor(), starred: op == AboutOp::Star }).await?)
264 }
265 AboutOp::ListReleases => out(call::<_, Vec<Release>>(services, "releases", &view(path)).await?),
266 AboutOp::GetLatestRelease => out(call::<_, Release>(services, "release", &release(path, None, None, true)).await?),
267 AboutOp::GetReleaseByTag => {
268 let Some(tag) = text(input, "tag") else {
269 return Ok(Outcome::fail(FailureCode::Invalid, "Name the tag."));
270 };
271 out(call::<_, Release>(services, "release", &release(path, None, Some(tag), false)).await?)
272 }
273 AboutOp::GetRelease => {
274 let Some(id) = text(input, "id") else {
275 return Ok(Outcome::fail(FailureCode::Invalid, "Give the release's id."));
276 };
277 out(call::<_, Release>(services, "release", &release(path, Some(id), None, false)).await?)
278 }
279 AboutOp::CreateRelease => {
280 let Some(tag_name) = text(input, "tag_name") else {
281 return Ok(Outcome::fail(FailureCode::Invalid, "Name the tag to release: tag_name."));
282 };
283 let args = CreateReleaseArgs {
284 path,
285 actor: actor(),
286 tag_name,
287 target: text(input, "target"),
288 name: text(input, "release_name"),
289 body: given(input, "body"),
290 draft: flag(input, "draft").unwrap_or(false),
291 prerelease: flag(input, "prerelease").unwrap_or(false),
292 };
293 out(call::<_, Release>(services, "create_release", &args).await?)
294 }
295 AboutOp::UpdateRelease => {
296 let Some(id) = text(input, "id") else {
297 return Ok(Outcome::fail(FailureCode::Invalid, "Give the release's id."));
298 };
299 let args = UpdateReleaseArgs {
300 path,
301 actor: actor(),
302 id,
303 name: given(input, "release_name"),
304 body: given(input, "body"),
305 draft: flag(input, "draft"),
306 prerelease: flag(input, "prerelease"),
307 };
308 out(call::<_, Release>(services, "update_release", &args).await?)
309 }
310 AboutOp::DeleteRelease => {
311 let Some(id) = text(input, "id") else {
312 return Ok(Outcome::fail(FailureCode::Invalid, "Give the release's id."));
313 };
314 match call::<_, bool>(services, "delete_release", &DeleteReleaseArgs { path, actor: actor(), id }).await? {
315 Outcome::Ok(deleted) => ok(&json!({ "deleted": deleted })),
316 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
317 }
318 }
319 AboutOp::ListStarred => unreachable!("answered above"),
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn each_operation_is_described_with_a_schema() {
329 for op in AboutOp::ALL {
330 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
331 let schema = op.input();
332 assert_eq!(schema["type"], "object");
333 if op.needs_repo() {
334 assert!(schema["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
335 }
336 }
337 }
338
339 #[test]
340 fn every_operation_is_one_of_the_api_s() {
341 for op in AboutOp::ALL {
342 assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::About(op)), "{}", op.name());
343 }
344 }
345
346 #[test]
347 fn inputs_are_read_loosely() {
348 let input = json!({ "draft": "true", "prerelease": false, "page": "2", "name": "" });
349 assert_eq!(flag(&input, "draft"), Some(true));
350 assert_eq!(flag(&input, "prerelease"), Some(false));
351 assert_eq!(flag(&input, "missing"), None);
352 assert_eq!(number(&input, "page"), Some(2));
353 assert_eq!(given(&input, "name").as_deref(), Some(""), "an empty release_name clears it");
354 assert_eq!(text(&input, "name"), None);
355 }
356}