| 1 | //! 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 | |
| 6 | use g1t_contracts::about::*; |
| 7 | use g1t_contracts::repos::RepoPath; |
| 8 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 9 | use serde::Serialize; |
| 10 | use serde::de::DeserializeOwned; |
| 11 | use serde_json::{Value, json}; |
| 12 | use worker::Result; |
| 13 | |
| 14 | use crate::operations::Services; |
| 15 | |
| 16 | /// One operation on a repository's About, stars or releases. |
| 17 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 18 | pub 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 | |
| 36 | impl 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 | |
| 191 | fn 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. |
| 196 | fn given(input: &Value, key: &str) -> Option<String> { |
| 197 | input[key].as_str().map(str::to_owned) |
| 198 | } |
| 199 | |
| 200 | fn 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 | |
| 212 | fn 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 | |
| 220 | async 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 | |
| 224 | fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> { |
| 225 | Ok(Outcome::Ok(serde_json::to_value(value)?)) |
| 226 | } |
| 227 | |
| 228 | fn 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 | |
| 235 | pub 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)] |
| 324 | mod 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 | } |