About: license, languages, contributors, stars, releases and activity beside the files
The Files page's About now says what GitHub's does: the license and security policy its files hold, activity, stars and watching, releases, packages, contributors and languages, with a slot for deployments. What is read from the default branch is worked out in the background per head commit (repo_stats, one run per repository by lease) and never on the request path. Stars and releases are new, with REST, MCP actions on the repository tool, OpenAPI and docs. New pages: Releases, a release, new release, Contributors, Activity, Stargazers, and a profile's Stars tab.
| 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 | + | } |
| 4 | 4 | //! operations (see [`operations::Op`]), which call the services that own | |
| 5 | 5 | //! the data. This Worker holds none. | |
| 6 | 6 | ||
| 7 | + | mod about; | |
| 7 | 8 | mod addresses; | |
| 8 | 9 | mod alerts; | |
| 9 | 10 | mod audit; |
| 7 | 7 | use g1t_contracts::scopes::scope_for; | |
| 8 | 8 | use serde_json::{Map, Value, json}; | |
| 9 | 9 | ||
| 10 | + | use crate::about::AboutOp; | |
| 10 | 11 | use crate::operations::Op; | |
| 11 | 12 | use crate::rules::RulesOp; | |
| 12 | 13 | use crate::security::SecurityOp; | |
| ⋯ | |||
| 102 | 103 | ], | |
| 103 | 104 | ), | |
| 104 | 105 | ( | |
| 106 | + | "Repository insights", | |
| 107 | + | "What a repository's default branch says about it, read in the background and kept by commit: the languages it is written in, who made it, and its license.", | |
| 108 | + | &[Op::About(AboutOp::GetLanguages), Op::About(AboutOp::ListContributors), Op::About(AboutOp::GetLicense)], | |
| 109 | + | ), | |
| 110 | + | ( | |
| 111 | + | "Stars", | |
| 112 | + | "Starring a repository, to keep it and to say you like it: who starred one, and what you starred.", | |
| 113 | + | &[ | |
| 114 | + | Op::About(AboutOp::ListStargazers), | |
| 115 | + | Op::About(AboutOp::ListStarred), | |
| 116 | + | Op::About(AboutOp::CheckStarred), | |
| 117 | + | Op::About(AboutOp::Star), | |
| 118 | + | Op::About(AboutOp::Unstar), | |
| 119 | + | ], | |
| 120 | + | ), | |
| 121 | + | ( | |
| 122 | + | "Releases", | |
| 123 | + | "A release is a tag published with a title and notes. The latest is the newest published one that is neither a draft nor a prerelease.", | |
| 124 | + | &[ | |
| 125 | + | Op::About(AboutOp::ListReleases), | |
| 126 | + | Op::About(AboutOp::CreateRelease), | |
| 127 | + | Op::About(AboutOp::GetLatestRelease), | |
| 128 | + | Op::About(AboutOp::GetReleaseByTag), | |
| 129 | + | Op::About(AboutOp::GetRelease), | |
| 130 | + | Op::About(AboutOp::UpdateRelease), | |
| 131 | + | Op::About(AboutOp::DeleteRelease), | |
| 132 | + | ], | |
| 133 | + | ), | |
| 134 | + | ( | |
| 105 | 135 | "Access", | |
| 106 | 136 | "Who can do what in a repository: repository roles, people given a role on one repository (outside collaborators when they are not members), invitations, and a workspace's base permission.", | |
| 107 | 137 | &[ | |
| ⋯ | |||
| 557 | 587 | Op::GetCodeownersErrors => "List CODEOWNERS errors", | |
| 558 | 588 | Op::Security(op) => op.title(), | |
| 559 | 589 | Op::Rules(op) => op.title(), | |
| 590 | + | Op::About(op) => op.title(), | |
| 560 | 591 | } | |
| 561 | 592 | } | |
| 562 | 593 | ||
| 26 | 26 | }; | |
| 27 | 27 | ||
| 28 | 28 | use crate::alerts::{AlertKind, SecurityAlert}; | |
| 29 | + | use crate::about::AboutOp; | |
| 29 | 30 | use crate::rules::RulesOp; | |
| 30 | 31 | use crate::security::SecurityOp; | |
| 31 | 32 | use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel}; | |
| ⋯ | |||
| 267 | 268 | Security(SecurityOp), | |
| 268 | 269 | /// Rulesets: rules.rs. | |
| 269 | 270 | Rules(RulesOp), | |
| 271 | + | /// A repository's languages, contributors, license, stars and releases: about.rs. | |
| 272 | + | About(AboutOp), | |
| 270 | 273 | } | |
| 271 | 274 | ||
| 272 | 275 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| ⋯ | |||
| 629 | 632 | } | |
| 630 | 633 | ||
| 631 | 634 | impl Op { | |
| 632 | − | pub const ALL: [Op; 219] = [ | |
| 635 | + | pub const ALL: [Op; 234] = [ | |
| 633 | 636 | Op::Whoami, | |
| 634 | 637 | Op::GetWorkspace, | |
| 635 | 638 | Op::CreateWorkspace, | |
| ⋯ | |||
| 849 | 852 | Op::Rules(RulesOp::UpdateWorkspaceRuleset), | |
| 850 | 853 | Op::Rules(RulesOp::DeleteWorkspaceRuleset), | |
| 851 | 854 | Op::Rules(RulesOp::ListWorkspaceRuleEvaluations), | |
| 855 | + | Op::About(AboutOp::GetLanguages), | |
| 856 | + | Op::About(AboutOp::ListContributors), | |
| 857 | + | Op::About(AboutOp::GetLicense), | |
| 858 | + | Op::About(AboutOp::ListStargazers), | |
| 859 | + | Op::About(AboutOp::ListStarred), | |
| 860 | + | Op::About(AboutOp::CheckStarred), | |
| 861 | + | Op::About(AboutOp::Star), | |
| 862 | + | Op::About(AboutOp::Unstar), | |
| 863 | + | Op::About(AboutOp::ListReleases), | |
| 864 | + | Op::About(AboutOp::GetLatestRelease), | |
| 865 | + | Op::About(AboutOp::GetReleaseByTag), | |
| 866 | + | Op::About(AboutOp::GetRelease), | |
| 867 | + | Op::About(AboutOp::CreateRelease), | |
| 868 | + | Op::About(AboutOp::UpdateRelease), | |
| 869 | + | Op::About(AboutOp::DeleteRelease), | |
| 852 | 870 | ]; | |
| 853 | 871 | ||
| 854 | 872 | pub fn by_name(name: &str) -> Option<Op> { | |
| ⋯ | |||
| 1035 | 1053 | Op::GetCodeownersErrors => "get_codeowners_errors", | |
| 1036 | 1054 | Op::Security(op) => op.name(), | |
| 1037 | 1055 | Op::Rules(op) => op.name(), | |
| 1056 | + | Op::About(op) => op.name(), | |
| 1038 | 1057 | } | |
| 1039 | 1058 | } | |
| 1040 | 1059 | ||
| ⋯ | |||
| 1533 | 1552 | } | |
| 1534 | 1553 | Op::Security(op) => op.description(), | |
| 1535 | 1554 | Op::Rules(op) => op.description(), | |
| 1555 | + | Op::About(op) => op.description(), | |
| 1536 | 1556 | } | |
| 1537 | 1557 | } | |
| 1538 | 1558 | ||
| ⋯ | |||
| 2843 | 2863 | ), | |
| 2844 | 2864 | Op::Security(op) => op.input(), | |
| 2845 | 2865 | Op::Rules(op) => op.input(), | |
| 2866 | + | Op::About(op) => op.input(), | |
| 2846 | 2867 | } | |
| 2847 | 2868 | } | |
| 2848 | 2869 | ||
| 2849 | 2870 | /// Whether the operation refuses an anonymous caller outright. | |
| 2850 | 2871 | pub(crate) fn needs_user(self) -> bool { | |
| 2872 | + | if let Op::About(op) = self { | |
| 2873 | + | return !op.anonymous(); | |
| 2874 | + | } | |
| 2851 | 2875 | !matches!( | |
| 2852 | 2876 | self, | |
| 2853 | 2877 | Op::ListRepos | |
| ⋯ | |||
| 2882 | 2906 | if let Op::Rules(op) = self { | |
| 2883 | 2907 | return op.needs_repo(); | |
| 2884 | 2908 | } | |
| 2909 | + | if let Op::About(op) = self { | |
| 2910 | + | return op.needs_repo(); | |
| 2911 | + | } | |
| 2885 | 2912 | if let Op::Security(op) = self { | |
| 2886 | 2913 | return op.needs_repo(); | |
| 2887 | 2914 | } | |
| ⋯ | |||
| 2985 | 3012 | /// subscriptions and watching) or their pins. Nobody else's business, | |
| 2986 | 3013 | /// so not audited. | |
| 2987 | 3014 | pub(crate) fn personal(self) -> bool { | |
| 3015 | + | if let Op::About(op) = self { | |
| 3016 | + | return op.personal(); | |
| 3017 | + | } | |
| 2988 | 3018 | matches!( | |
| 2989 | 3019 | self, | |
| 2990 | 3020 | Op::ListNotifications | |
| ⋯ | |||
| 4848 | 4878 | // each answer its public shape. | |
| 4849 | 4879 | Op::Security(op) => crate::security::run(op, services, viewer, input).await, | |
| 4850 | 4880 | Op::Rules(op) => crate::rules::run(op, services, viewer, input).await, | |
| 4881 | + | Op::About(op) => crate::about::run(op, services, viewer, input).await, | |
| 4851 | 4882 | Op::ReopenSecurityAlert => { | |
| 4852 | 4883 | let changed: Outcome<AlertChange> = call( | |
| 4853 | 4884 | &services.security, | |
| 9278 | 9278 | ] | |
| 9279 | 9279 | } | |
| 9280 | 9280 | } | |
| 9281 | + | }, | |
| 9282 | + | "get_languages": { | |
| 9283 | + | "response": { | |
| 9284 | + | "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9285 | + | "commit": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9286 | + | "computed_at": "2026-10-07T09:14:03.000Z", | |
| 9287 | + | "pending": false, | |
| 9288 | + | "partial": false, | |
| 9289 | + | "languages": [ | |
| 9290 | + | { | |
| 9291 | + | "name": "Rust", | |
| 9292 | + | "color": "#dea584", | |
| 9293 | + | "bytes": 184213, | |
| 9294 | + | "percent": 71.4 | |
| 9295 | + | }, | |
| 9296 | + | { | |
| 9297 | + | "name": "TypeScript", | |
| 9298 | + | "color": "#3178c6", | |
| 9299 | + | "bytes": 61022, | |
| 9300 | + | "percent": 23.7 | |
| 9301 | + | }, | |
| 9302 | + | { | |
| 9303 | + | "name": "Shell", | |
| 9304 | + | "color": "#89e051", | |
| 9305 | + | "bytes": 12650, | |
| 9306 | + | "percent": 4.9 | |
| 9307 | + | } | |
| 9308 | + | ] | |
| 9309 | + | }, | |
| 9310 | + | "notes": "While `pending` is true, `languages` is empty: the default branch is being read for the first time. When `commit` is not `head`, the answer is for an older commit while the newer one is read." | |
| 9311 | + | }, | |
| 9312 | + | "list_contributors": { | |
| 9313 | + | "response": { | |
| 9314 | + | "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9315 | + | "commit": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9316 | + | "computed_at": "2026-10-07T09:14:03.000Z", | |
| 9317 | + | "pending": false, | |
| 9318 | + | "partial": false, | |
| 9319 | + | "total": 3, | |
| 9320 | + | "commits": 126, | |
| 9321 | + | "contributors": [ | |
| 9322 | + | { | |
| 9323 | + | "kind": "g1t", | |
| 9324 | + | "name": "g1t", | |
| 9325 | + | "username": null, | |
| 9326 | + | "avatar": null, | |
| 9327 | + | "commits": 71, | |
| 9328 | + | "first_at": "2026-09-29T08:00:00.000Z", | |
| 9329 | + | "last_at": "2026-10-07T08:41:00.000Z", | |
| 9330 | + | "weeks": [ | |
| 9331 | + | { | |
| 9332 | + | "week": "2026-09-28", | |
| 9333 | + | "commits": 40 | |
| 9334 | + | }, | |
| 9335 | + | { | |
| 9336 | + | "week": "2026-10-05", | |
| 9337 | + | "commits": 31 | |
| 9338 | + | } | |
| 9339 | + | ] | |
| 9340 | + | }, | |
| 9341 | + | { | |
| 9342 | + | "kind": "user", | |
| 9343 | + | "name": "ada", | |
| 9344 | + | "username": "ada", | |
| 9345 | + | "avatar": "5f2b8c1d9e7a3f6b4c0d2e8a1b9c7d5e3f1a0b2c4d6e8f0a1b3c5d7e9f0a2b4c", | |
| 9346 | + | "commits": 52, | |
| 9347 | + | "first_at": "2026-09-28T14:11:52.000Z", | |
| 9348 | + | "last_at": "2026-10-06T16:02:11.000Z", | |
| 9349 | + | "weeks": [ | |
| 9350 | + | { | |
| 9351 | + | "week": "2026-09-28", | |
| 9352 | + | "commits": 30 | |
| 9353 | + | }, | |
| 9354 | + | { | |
| 9355 | + | "week": "2026-10-05", | |
| 9356 | + | "commits": 22 | |
| 9357 | + | } | |
| 9358 | + | ] | |
| 9359 | + | }, | |
| 9360 | + | { | |
| 9361 | + | "kind": "author", | |
| 9362 | + | "name": "Sam Okafor", | |
| 9363 | + | "username": null, | |
| 9364 | + | "avatar": null, | |
| 9365 | + | "commits": 3, | |
| 9366 | + | "first_at": "2026-10-02T11:20:00.000Z", | |
| 9367 | + | "last_at": "2026-10-03T09:05:00.000Z", | |
| 9368 | + | "weeks": [ | |
| 9369 | + | { | |
| 9370 | + | "week": "2026-09-28", | |
| 9371 | + | "commits": 3 | |
| 9372 | + | } | |
| 9373 | + | ] | |
| 9374 | + | } | |
| 9375 | + | ], | |
| 9376 | + | "weeks": [ | |
| 9377 | + | { | |
| 9378 | + | "week": "2026-09-28", | |
| 9379 | + | "commits": 73 | |
| 9380 | + | }, | |
| 9381 | + | { | |
| 9382 | + | "week": "2026-10-05", | |
| 9383 | + | "commits": 53 | |
| 9384 | + | } | |
| 9385 | + | ] | |
| 9386 | + | }, | |
| 9387 | + | "notes": "Read from the newest 3,000 commits of the default branch. A person's addresses count as one when they confirmed them on their account; their noreply address counts too." | |
| 9388 | + | }, | |
| 9389 | + | "get_license": { | |
| 9390 | + | "response": { | |
| 9391 | + | "spdx_id": "MIT", | |
| 9392 | + | "name": "MIT License", | |
| 9393 | + | "path": "LICENSE" | |
| 9394 | + | } | |
| 9395 | + | }, | |
| 9396 | + | "list_stargazers": { | |
| 9397 | + | "query": { | |
| 9398 | + | "page": 1 | |
| 9399 | + | }, | |
| 9400 | + | "response": [ | |
| 9401 | + | { | |
| 9402 | + | "username": "ada", | |
| 9403 | + | "avatar": null, | |
| 9404 | + | "starred_at": "2026-10-06T18:30:00.000Z" | |
| 9405 | + | }, | |
| 9406 | + | { | |
| 9407 | + | "username": "sam", | |
| 9408 | + | "avatar": null, | |
| 9409 | + | "starred_at": "2026-10-02T09:12:00.000Z" | |
| 9410 | + | } | |
| 9411 | + | ] | |
| 9412 | + | }, | |
| 9413 | + | "list_starred": { | |
| 9414 | + | "response": [ | |
| 9415 | + | { | |
| 9416 | + | "repo": { | |
| 9417 | + | "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 9418 | + | "namespace": "flagon-io", | |
| 9419 | + | "name": "hello", | |
| 9420 | + | "description": "A tiny service that says hello.", | |
| 9421 | + | "is_private": false, | |
| 9422 | + | "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 9423 | + | "default_branch": "main", | |
| 9424 | + | "fork_of": null, | |
| 9425 | + | "protected": true, | |
| 9426 | + | "created_at": "2026-09-28T14:11:52.640Z", | |
| 9427 | + | "topics": [ | |
| 9428 | + | "cli" | |
| 9429 | + | ], | |
| 9430 | + | "website": "https://hello.g1t.page", | |
| 9431 | + | "archived_at": null | |
| 9432 | + | }, | |
| 9433 | + | "starred_at": "2026-10-06T18:30:00.000Z", | |
| 9434 | + | "stars": 12 | |
| 9435 | + | } | |
| 9436 | + | ] | |
| 9437 | + | }, | |
| 9438 | + | "check_starred": { | |
| 9439 | + | "response": { | |
| 9440 | + | "starred": true, | |
| 9441 | + | "stars": 12 | |
| 9442 | + | } | |
| 9443 | + | }, | |
| 9444 | + | "star_repo": { | |
| 9445 | + | "response": { | |
| 9446 | + | "starred": true, | |
| 9447 | + | "stars": 13 | |
| 9448 | + | } | |
| 9449 | + | }, | |
| 9450 | + | "unstar_repo": { | |
| 9451 | + | "response": { | |
| 9452 | + | "starred": false, | |
| 9453 | + | "stars": 12 | |
| 9454 | + | } | |
| 9455 | + | }, | |
| 9456 | + | "list_releases": { | |
| 9457 | + | "response": [ | |
| 9458 | + | { | |
| 9459 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 9460 | + | "tag_name": "v1.2.0", | |
| 9461 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9462 | + | "name": "Greetings by name", | |
| 9463 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 9464 | + | "draft": false, | |
| 9465 | + | "prerelease": false, | |
| 9466 | + | "author": "ada", | |
| 9467 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 9468 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 9469 | + | "latest": true | |
| 9470 | + | }, | |
| 9471 | + | { | |
| 9472 | + | "id": "rel_01m3x8q2w4e6r8t0y2u4i6o8p0", | |
| 9473 | + | "tag_name": "v1.1.0", | |
| 9474 | + | "target": "3b2a19f8e7d6c5b4a392817069f3c2a1b7e5d4c3", | |
| 9475 | + | "name": "First release", | |
| 9476 | + | "body": "The first release.", | |
| 9477 | + | "draft": false, | |
| 9478 | + | "prerelease": false, | |
| 9479 | + | "author": "ada", | |
| 9480 | + | "created_at": "2026-09-30T10:00:00.000Z", | |
| 9481 | + | "published_at": "2026-09-30T10:00:00.000Z", | |
| 9482 | + | "latest": false | |
| 9483 | + | } | |
| 9484 | + | ], | |
| 9485 | + | "notes": "Drafts are listed only to those with the Write role; their `published_at` is null." | |
| 9486 | + | }, | |
| 9487 | + | "get_latest_release": { | |
| 9488 | + | "response": { | |
| 9489 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 9490 | + | "tag_name": "v1.2.0", | |
| 9491 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9492 | + | "name": "Greetings by name", | |
| 9493 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 9494 | + | "draft": false, | |
| 9495 | + | "prerelease": false, | |
| 9496 | + | "author": "ada", | |
| 9497 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 9498 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 9499 | + | "latest": true | |
| 9500 | + | } | |
| 9501 | + | }, | |
| 9502 | + | "get_release_by_tag": { | |
| 9503 | + | "params": { | |
| 9504 | + | "tag": "v1.2.0" | |
| 9505 | + | }, | |
| 9506 | + | "response": { | |
| 9507 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 9508 | + | "tag_name": "v1.2.0", | |
| 9509 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9510 | + | "name": "Greetings by name", | |
| 9511 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 9512 | + | "draft": false, | |
| 9513 | + | "prerelease": false, | |
| 9514 | + | "author": "ada", | |
| 9515 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 9516 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 9517 | + | "latest": true | |
| 9518 | + | } | |
| 9519 | + | }, | |
| 9520 | + | "get_release": { | |
| 9521 | + | "params": { | |
| 9522 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7" | |
| 9523 | + | }, | |
| 9524 | + | "response": { | |
| 9525 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 9526 | + | "tag_name": "v1.2.0", | |
| 9527 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9528 | + | "name": "Greetings by name", | |
| 9529 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 9530 | + | "draft": false, | |
| 9531 | + | "prerelease": false, | |
| 9532 | + | "author": "ada", | |
| 9533 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 9534 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 9535 | + | "latest": true | |
| 9536 | + | } | |
| 9537 | + | }, | |
| 9538 | + | "create_release": { | |
| 9539 | + | "request": { | |
| 9540 | + | "tag_name": "v1.2.0", | |
| 9541 | + | "target": "main", | |
| 9542 | + | "release_name": "Greetings by name", | |
| 9543 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 9544 | + | "draft": false, | |
| 9545 | + | "prerelease": false | |
| 9546 | + | }, | |
| 9547 | + | "response": { | |
| 9548 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 9549 | + | "tag_name": "v1.2.0", | |
| 9550 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9551 | + | "name": "Greetings by name", | |
| 9552 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 9553 | + | "draft": false, | |
| 9554 | + | "prerelease": false, | |
| 9555 | + | "author": "ada", | |
| 9556 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 9557 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 9558 | + | "latest": true | |
| 9559 | + | }, | |
| 9560 | + | "notes": "A tag that already exists is released as it is, and `target` is ignored. One release per tag: releasing a tag again is refused with `conflict`." | |
| 9561 | + | }, | |
| 9562 | + | "update_release": { | |
| 9563 | + | "params": { | |
| 9564 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7" | |
| 9565 | + | }, | |
| 9566 | + | "request": { | |
| 9567 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)\n- Docs for `--name`" | |
| 9568 | + | }, | |
| 9569 | + | "response": { | |
| 9570 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 9571 | + | "tag_name": "v1.2.0", | |
| 9572 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9573 | + | "name": "Greetings by name", | |
| 9574 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)\n- Docs for `--name`", | |
| 9575 | + | "draft": false, | |
| 9576 | + | "prerelease": false, | |
| 9577 | + | "author": "ada", | |
| 9578 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 9579 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 9580 | + | "latest": true | |
| 9581 | + | } | |
| 9582 | + | }, | |
| 9583 | + | "delete_release": { | |
| 9584 | + | "params": { | |
| 9585 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7" | |
| 9586 | + | }, | |
| 9587 | + | "response": { | |
| 9588 | + | "deleted": true | |
| 9589 | + | } | |
| 9281 | 9590 | } | |
| 9282 | 9591 | } |
| 2 | 2 | ||
| 3 | 3 | use serde_json::{Map, Value}; | |
| 4 | 4 | ||
| 5 | + | use crate::about::AboutOp; | |
| 5 | 6 | use crate::operations::Op; | |
| 6 | 7 | use crate::rules::RulesOp; | |
| 7 | 8 | use crate::security::SecurityOp; | |
| ⋯ | |||
| 82 | 83 | route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]), | |
| 83 | 84 | route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]), | |
| 84 | 85 | route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]), | |
| 86 | + | // Stars: yours, and who starred a repository. | |
| 87 | + | route("GET", "/user/starred", Op::About(AboutOp::ListStarred), &[]), | |
| 88 | + | route("GET", "/user/starred/:owner/:name", Op::About(AboutOp::CheckStarred), &[]), | |
| 89 | + | route("PUT", "/user/starred/:owner/:name", Op::About(AboutOp::Star), &[]), | |
| 90 | + | route("DELETE", "/user/starred/:owner/:name", Op::About(AboutOp::Unstar), &[]), | |
| 91 | + | route("GET", "/repos/:owner/:name/stargazers", Op::About(AboutOp::ListStargazers), &[("page", "page")]), | |
| 92 | + | // What the default branch says about a repository, kept by commit. | |
| 93 | + | route("GET", "/repos/:owner/:name/languages", Op::About(AboutOp::GetLanguages), &[]), | |
| 94 | + | route("GET", "/repos/:owner/:name/contributors", Op::About(AboutOp::ListContributors), &[]), | |
| 95 | + | route("GET", "/repos/:owner/:name/license", Op::About(AboutOp::GetLicense), &[]), | |
| 96 | + | // Releases: `latest` and `tags/…` before an id. | |
| 97 | + | route("GET", "/repos/:owner/:name/releases", Op::About(AboutOp::ListReleases), &[]), | |
| 98 | + | route("POST", "/repos/:owner/:name/releases", Op::About(AboutOp::CreateRelease), &[]), | |
| 99 | + | route("GET", "/repos/:owner/:name/releases/latest", Op::About(AboutOp::GetLatestRelease), &[]), | |
| 100 | + | route("GET", "/repos/:owner/:name/releases/tags/:tag", Op::About(AboutOp::GetReleaseByTag), &[]), | |
| 101 | + | route("GET", "/repos/:owner/:name/releases/:id", Op::About(AboutOp::GetRelease), &[]), | |
| 102 | + | route("PATCH", "/repos/:owner/:name/releases/:id", Op::About(AboutOp::UpdateRelease), &[]), | |
| 103 | + | route("DELETE", "/repos/:owner/:name/releases/:id", Op::About(AboutOp::DeleteRelease), &[]), | |
| 85 | 104 | // Your pinned projects in a workspace, in your order. | |
| 86 | 105 | route("GET", "/user/pinned_projects/:workspace", Op::ListPinnedProjects, &[]), | |
| 87 | 106 | route("PUT", "/user/pinned_projects/:workspace", Op::ReorderPinnedProjects, &[]), | |
| 18 | 18 | use g1t_contracts::scopes::{Level, NO_SCOPE, TokenAccess, scope_for}; | |
| 19 | 19 | use serde_json::{Map, Value, json}; | |
| 20 | 20 | ||
| 21 | + | use crate::about::AboutOp; | |
| 21 | 22 | use crate::operations::Op; | |
| 22 | 23 | use crate::rules::RulesOp; | |
| 23 | 24 | use crate::security::SecurityOp; | |
| ⋯ | |||
| 59 | 60 | Tool { | |
| 60 | 61 | name: "repository", | |
| 61 | 62 | title: "Repositories", | |
| 62 | − | description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, and see and dismiss their security alerts (secrets and vulnerable dependencies). Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.", | |
| 63 | + | description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, see and dismiss their security alerts (secrets and vulnerable dependencies), read what their default branch says (languages, contributors, license), star them, and publish releases. Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.", | |
| 63 | 64 | default_action: None, | |
| 64 | 65 | actions: &[ | |
| 65 | 66 | a("list", Op::ListRepos, "Repositories you can see"), | |
| ⋯ | |||
| 88 | 89 | a("update_milestone", Op::UpdateMilestone, "Change a milestone's title, description, due date or state"), | |
| 89 | 90 | a("delete_milestone", Op::DeleteMilestone, "Delete a milestone"), | |
| 90 | 91 | a("list_events", Op::ListEvents, "Timeline: pushes, issues, pull requests, comments"), | |
| 92 | + | a("languages", Op::About(AboutOp::GetLanguages), "Its languages by bytes, with colors and percentages"), | |
| 93 | + | a("contributors", Op::About(AboutOp::ListContributors), "Who made it: commits per person, agent and author, by week"), | |
| 94 | + | a("license", Op::About(AboutOp::GetLicense), "The license its LICENSE file holds"), | |
| 95 | + | a("stargazers", Op::About(AboutOp::ListStargazers), "Who starred it"), | |
| 96 | + | a("starred", Op::About(AboutOp::CheckStarred), "Whether you starred it, and how many have"), | |
| 97 | + | a("star", Op::About(AboutOp::Star), "Star it"), | |
| 98 | + | a("unstar", Op::About(AboutOp::Unstar), "Take your star back"), | |
| 99 | + | a("list_starred", Op::About(AboutOp::ListStarred), "Repositories you starred"), | |
| 100 | + | a("list_releases", Op::About(AboutOp::ListReleases), "Releases, newest first"), | |
| 101 | + | a("latest_release", Op::About(AboutOp::GetLatestRelease), "The latest release"), | |
| 102 | + | a("get_release", Op::About(AboutOp::GetRelease), "One release by id"), | |
| 103 | + | a("get_release_by_tag", Op::About(AboutOp::GetReleaseByTag), "The release of a tag"), | |
| 104 | + | a("create_release", Op::About(AboutOp::CreateRelease), "Publish a release of a tag, making the tag if needed"), | |
| 105 | + | a("update_release", Op::About(AboutOp::UpdateRelease), "Change a release's title, notes, draft or prerelease"), | |
| 106 | + | a("delete_release", Op::About(AboutOp::DeleteRelease), "Delete a release; its tag stays"), | |
| 91 | 107 | a("rename_branch", Op::RenameBranch, "Rename a branch"), | |
| 92 | 108 | a("rename", Op::RenameRepo, "Rename it; old addresses redirect"), | |
| 93 | 109 | a("transfer", Op::TransferRepo, "Move it to another workspace you own"), | |
| 144 | 144 | { label: 'Access and roles', slug: 'guides/access-and-roles' }, | |
| 145 | 145 | { label: 'Teams', slug: 'guides/teams' }, | |
| 146 | 146 | { label: 'Managing a repository', slug: 'guides/managing-repositories' }, | |
| 147 | + | { label: 'Releases', slug: 'guides/releases' }, | |
| 147 | 148 | { label: 'Transferring a repository', slug: 'guides/transferring-repositories' }, | |
| 148 | 149 | { label: 'Audit log', slug: 'guides/audit-log' }, | |
| 149 | 150 | { label: 'Usage and billing', slug: 'guides/usage-and-billing' }, |
| 337 | 337 | ||
| 338 | 338 | | Scope | What it lets a token do | | |
| 339 | 339 | | --- | --- | | |
| 340 | − | | `repo:read` | See repositories, their settings, labels, timelines and security alerts, and search | | |
| 341 | − | | `repo:write` | Create repositories, rename branches and change how pull requests merge | | |
| 340 | + | | `repo:read` | See repositories, their settings, labels, timelines, releases, languages, contributors and security alerts, and search | | |
| 341 | + | | `repo:write` | Create repositories, rename branches, change how pull requests merge and publish releases | | |
| 342 | 342 | | `repo:admin` | Rename, archive, transfer, delete or change who can see a repository, and dismiss security alerts | | |
| 343 | 343 | | `code:read` | Clone and fetch private repositories with git | | |
| 344 | 344 | | `code:write` | Push commits with git | | |
| ⋯ | |||
| 356 | 356 | | `workflows:write` | Run, cancel, rerun and turn workflows on or off | | |
| 357 | 357 | | `memory:read` | Recall memory and search the workspace's context | | |
| 358 | 358 | | `memory:write` | Save memory for the next agent | | |
| 359 | − | | `account:read` | Read your email addresses, invites, invitations and pinned projects | | |
| 360 | − | | `account:write` | Change your email addresses, make invites, answer invitations and pin projects | | |
| 359 | + | | `account:read` | Read your email addresses, invites, invitations, pinned projects and stars | | |
| 360 | + | | `account:write` | Change your email addresses, make invites, answer invitations, pin projects and star repositories | | |
| 361 | 361 | | `notifications:read` | See your [inbox](/guides/inbox/), its threads, and what you subscribe to and watch | | |
| 362 | 362 | | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories | | |
| 363 | 363 | | `workspace:read` | Read workspace settings, invites, integrations, model routes and [teams](/guides/teams/) | | |
| 1 | 1 | --- | |
| 2 | 2 | title: Managing a repository | |
| 3 | − | description: Rename a repository or a branch, change its default branch, website and topics, make it public or private, archive it, and delete and restore it. | |
| 3 | + | description: What a repository's About shows, and how to rename it or a branch, change its default branch, website and topics, make it public or private, archive it, and delete and restore it. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | A repository's details and its lifecycle are managed from its | |
| ⋯ | |||
| 9 | 9 | server. This guide covers each change, who can make it, and what happens | |
| 10 | 10 | when you do. | |
| 11 | 11 | ||
| 12 | + | ## The About beside the files | |
| 13 | + | ||
| 14 | + | A repository's **Code** page shows its files with an **About** beside them, | |
| 15 | + | the way most code hosts lay it out. On a phone it comes after the files. | |
| 16 | + | ||
| 17 | + | | Part | What it shows | | |
| 18 | + | | --- | --- | | |
| 19 | + | | Description, website, topics | What you set under [Edit the details](#edit-the-details). | | |
| 20 | + | | **Readme** | A link to the README shown under the files. | | |
| 21 | + | | **License** | The license its `LICENSE` file holds, such as **MIT license**, linked to the file. **View license** when the text is not one g1t recognizes. `LICENCE`, `COPYING` and `UNLICENSE` are read too, with or without an extension, and an `SPDX-License-Identifier` line says it outright. | | |
| 22 | + | | **Security policy** | A link to `SECURITY.md` at the root, or in `.g1t`, `.github` or `docs`. | | |
| 23 | + | | **Activity** | The repository's [activity](#activity): pushes, merges, new branches and tags. | | |
| 24 | + | | **Stars**, **watching** | How many people [starred](#stars) it, and how many watch all or some of its activity from the [Watch menu](/guides/inbox/). | | |
| 25 | + | | **Releases** | How many [releases](/guides/releases/) it has and the latest, or **Create a new release** for people who can push. | | |
| 26 | + | | **Packages** | [Packages](/guides/packages/) linked to it, or how to publish the first. | | |
| 27 | + | | **Contributors** | How many people and agents made it, and the most active. See [contributors](#contributors). | | |
| 28 | + | | **Languages** | The languages it is written in, by bytes. See [languages](#languages). | | |
| 29 | + | ||
| 30 | + | The license, security policy, languages and contributors are read from the | |
| 31 | + | default branch in the background each time it moves, and kept by commit, so | |
| 32 | + | the page never waits for them. A repository pushed to for the first time | |
| 33 | + | shows **Reading the default branch…** for a few seconds. | |
| 34 | + | ||
| 35 | + | ### Languages | |
| 36 | + | ||
| 37 | + | The bar counts the bytes of each language's files on the default branch. | |
| 38 | + | Programming and markup languages count; data such as JSON and YAML, and | |
| 39 | + | prose such as Markdown, do not. Neither do: | |
| 40 | + | ||
| 41 | + | | Files | Such as | | |
| 42 | + | | --- | --- | | |
| 43 | + | | Vendored | `node_modules/`, `vendor/`, `third_party/`, minified jQuery, and anything under a dot-directory such as `.github/` | | |
| 44 | + | | Generated | `dist/`, `*.min.js`, `*.pb.go`, lockfiles | | |
| 45 | + | | Documentation | `docs/`, `doc/`, `examples/` | | |
| 46 | + | ||
| 47 | + | Change what counts with `linguist-*` attributes in the repository's | |
| 48 | + | `.gitattributes` file at the root. A later line wins over an earlier one. | |
| 49 | + | ||
| 50 | + | ```text | |
| 51 | + | vendor/ours/** -linguist-vendored | |
| 52 | + | *.gen.ts linguist-generated | |
| 53 | + | docs/** -linguist-documentation | |
| 54 | + | *.inc linguist-language=PHP | |
| 55 | + | *.sql linguist-detectable | |
| 56 | + | ``` | |
| 57 | + | ||
| 58 | + | A repository too large to read in full (more than 10,000 files) counts the | |
| 59 | + | files read. | |
| 60 | + | ||
| 61 | + | ### Contributors | |
| 62 | + | ||
| 63 | + | **Insights → Contributors** lists everyone whose commits are on the default | |
| 64 | + | branch, most commits first, with their commits by week, and the | |
| 65 | + | repository's commits per week over the last year. | |
| 66 | + | ||
| 67 | + | | Who | How they are matched | | |
| 68 | + | | --- | --- | | |
| 69 | + | | A person | By an address they confirmed on their account, or their noreply address. Several addresses of one account count as one. | | |
| 70 | + | | g1t | Its own commits, by its address. | | |
| 71 | + | | Anyone else | By the name on their commits. | | |
| 72 | + | ||
| 73 | + | The newest 3,000 commits are counted. | |
| 74 | + | ||
| 75 | + | ### Activity | |
| 76 | + | ||
| 77 | + | **Insights → Activity** lists, newest first, who pushed to which branch, | |
| 78 | + | created a branch or tag, merged a pull request, renamed a branch or changed | |
| 79 | + | the default branch, person or agent. | |
| 80 | + | ||
| 81 | + | ### Stars | |
| 82 | + | ||
| 83 | + | Choose **Star** in the repository's header to keep it in your profile's | |
| 84 | + | **Stars** tab, at `g1t.sh/u/<you>?tab=stars`. The number beside it leads | |
| 85 | + | to who starred it. Anyone signed in who can read a repository can star it; | |
| 86 | + | stars on a private repository are seen only by people who can read it. | |
| 87 | + | ||
| 12 | 88 | ## The settings page | |
| 13 | 89 | ||
| 14 | 90 | | Section | What it holds | | |
| ⋯ | |||
| 382 | 458 | ||
| 383 | 459 | Each refusal comes with a message that says what to do. | |
| 384 | 460 | ||
| 461 | + | ## From the API and MCP | |
| 462 | + | ||
| 463 | + | | Route | MCP | What it does | | |
| 464 | + | | --- | --- | --- | | |
| 465 | + | | [`GET /repos/{owner}/{name}/languages`](/reference/api/repository-insights/get-languages/) | `repository` `languages` | Its languages by bytes, with `color` and `percent`. | | |
| 466 | + | | [`GET /repos/{owner}/{name}/contributors`](/reference/api/repository-insights/list-contributors/) | `repository` `contributors` | Its contributors with `kind`, `commits` and `weeks`. | | |
| 467 | + | | [`GET /repos/{owner}/{name}/license`](/reference/api/repository-insights/get-license/) | `repository` `license` | Its license's `spdx_id`, `name` and `path`. | | |
| 468 | + | | [`GET /repos/{owner}/{name}/stargazers`](/reference/api/stars/list-stargazers/) | `repository` `stargazers` | Who starred it, newest first. | | |
| 469 | + | | [`PUT /user/starred/{owner}/{name}`](/reference/api/stars/star-repo/) | `repository` `star` | Star it. `DELETE` takes the star back; `GET` says whether you did. | | |
| 470 | + | | [`GET /user/starred`](/reference/api/stars/list-starred/) | `repository` `list_starred` | What you starred. | | |
| 471 | + | ||
| 472 | + | Answers read from the default branch say which `commit` they are for and | |
| 473 | + | the `head` now; `pending` is true until the first is read. Stars take the | |
| 474 | + | `account:read` and `account:write` scopes; the rest `repo:read`. | |
| 475 | + | ||
| 385 | 476 | ## Events and the audit log | |
| 386 | 477 | ||
| 387 | 478 | Each change is sent to [webhooks](/guides/webhooks/#events) and recorded | |
| 1 | + | --- | |
| 2 | + | title: Releases | |
| 3 | + | description: Publish a tag with a title and notes, keep drafts and pre-releases, and read the latest release from the API. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | A release is a tag published with a title and notes, for people to see what | |
| 7 | + | changed and download it. A repository's releases are at | |
| 8 | + | `g1t.sh/<workspace>/<repo>/releases`, and the latest one is in the **About** | |
| 9 | + | beside its files. | |
| 10 | + | ||
| 11 | + | ## Publish a release | |
| 12 | + | ||
| 13 | + | You need the Write role on the repository. | |
| 14 | + | ||
| 15 | + | 1. Open the repository's **Code → Releases**, or choose **Create a new | |
| 16 | + | release** under **Releases** in the About beside its files. | |
| 17 | + | 2. Choose **Draft a new release**. | |
| 18 | + | 3. Under **Tag**, pick an existing tag or type a new one, such as `v1.2.0`. | |
| 19 | + | 4. For a new tag, under **Target**, choose the branch or commit to make it | |
| 20 | + | at. The default branch is used when you leave it empty. | |
| 21 | + | 5. Give it a **Title** and **Notes** in Markdown. | |
| 22 | + | 6. Choose **Publish release**, or **Save draft** to finish it later. | |
| 23 | + | ||
| 24 | + | A tag that does not exist yet is made as a lightweight tag at the target, | |
| 25 | + | under the repository's [tag rulesets](/guides/rules/). A tag that exists is | |
| 26 | + | released as it is. Each tag has at most one release. | |
| 27 | + | ||
| 28 | + | | Field | Rules | | |
| 29 | + | | --- | --- | | |
| 30 | + | | Tag | A valid git ref name. Required. | | |
| 31 | + | | Target | A branch or commit. Used only for a new tag. | | |
| 32 | + | | Title | Up to 200 characters. The tag's name is shown when it has none. | | |
| 33 | + | | Notes | Markdown, up to 125,000 characters. | | |
| 34 | + | | Pre-release | Not ready for everyone: it is never the latest release. | | |
| 35 | + | ||
| 36 | + | ## Drafts, pre-releases and the latest release | |
| 37 | + | ||
| 38 | + | | State | Who sees it | Latest? | | |
| 39 | + | | --- | --- | --- | | |
| 40 | + | | Draft | People with the Write role | Never | | |
| 41 | + | | Pre-release | Everyone who can read the repository | Never | | |
| 42 | + | | Published | Everyone who can read the repository | When it is the newest | | |
| 43 | + | ||
| 44 | + | The **latest release** is the newest published release that is neither a | |
| 45 | + | draft nor a pre-release. To publish a draft, open it and choose **Publish | |
| 46 | + | release**. | |
| 47 | + | ||
| 48 | + | ## Delete a release | |
| 49 | + | ||
| 50 | + | Open the release and choose **Delete release**. The tag stays: delete it | |
| 51 | + | with git. | |
| 52 | + | ||
| 53 | + | ```sh | |
| 54 | + | git push origin :refs/tags/v1.2.0 | |
| 55 | + | ``` | |
| 56 | + | ||
| 57 | + | ## From the API and MCP | |
| 58 | + | ||
| 59 | + | | Route | MCP | What it does | | |
| 60 | + | | --- | --- | --- | | |
| 61 | + | | [`GET /repos/{owner}/{name}/releases`](/reference/api/releases/list-releases/) | `repository` `list_releases` | Releases, newest first, at most 100. Drafts only for the Write role. | | |
| 62 | + | | [`POST /repos/{owner}/{name}/releases`](/reference/api/releases/create-release/) | `repository` `create_release` | Publish a release: `tag_name`, `target`, `release_name`, `body`, `draft`, `prerelease`. | | |
| 63 | + | | [`GET /repos/{owner}/{name}/releases/latest`](/reference/api/releases/get-latest-release/) | `repository` `latest_release` | The latest release. | | |
| 64 | + | | [`GET /repos/{owner}/{name}/releases/tags/{tag}`](/reference/api/releases/get-release-by-tag/) | `repository` `get_release_by_tag` | The release of one tag. | | |
| 65 | + | | [`GET /repos/{owner}/{name}/releases/{id}`](/reference/api/releases/get-release/) | `repository` `get_release` | One release by its id (`rel_…`). | | |
| 66 | + | | [`PATCH /repos/{owner}/{name}/releases/{id}`](/reference/api/releases/update-release/) | `repository` `update_release` | Change its `release_name`, `body`, `draft` or `prerelease`. `draft: false` publishes it. | | |
| 67 | + | | [`DELETE /repos/{owner}/{name}/releases/{id}`](/reference/api/releases/delete-release/) | `repository` `delete_release` | Delete it; the tag stays. | | |
| 68 | + | ||
| 69 | + | Under a repository's address `name` is the repository's, so a release's | |
| 70 | + | title is sent as `release_name` and comes back as `name`. | |
| 71 | + | ||
| 72 | + | ```sh | |
| 73 | + | curl -X POST https://api.g1t.sh/repos/flagon-io/hello/releases \ | |
| 74 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 75 | + | -d '{"tag_name": "v1.2.0", "release_name": "Greetings by name", "body": "## What changed\n\n- Greets the caller by name"}' | |
| 76 | + | ``` | |
| 77 | + | ||
| 78 | + | Reading releases takes the `repo:read` scope; publishing, changing and | |
| 79 | + | deleting them take `repo:write`. |
| 78 | 78 | <CardGrid> | |
| 79 | 79 | <LinkCard | |
| 80 | 80 | title="Managing a repository" | |
| 81 | − | description="Rename it or a branch, change its default branch, make it public or private, archive it, and delete and restore it." | |
| 81 | + | description="See its languages, contributors, license and stars, rename it or a branch, change its default branch, make it public or private, archive it, and delete and restore it." | |
| 82 | 82 | href="/guides/managing-repositories/" | |
| 83 | 83 | /> | |
| 84 | 84 | <LinkCard | |
| 85 | + | title="Releases" | |
| 86 | + | description="Publish a tag with a title and notes, keep drafts and pre-releases, and read the latest release from the API." | |
| 87 | + | href="/guides/releases/" | |
| 88 | + | /> | |
| 89 | + | <LinkCard | |
| 85 | 90 | title="Required checks" | |
| 86 | 91 | description="Choose which workflow checks must pass before anything merges into the default branch, or add CI to a repository that has none." | |
| 87 | 92 | href="/guides/pull-requests/#required-status-checks" |
| 226 | 226 | ## `repository` | |
| 227 | 227 | ||
| 228 | 228 | Repositories: find, read and create them, change their settings, manage | |
| 229 | − | their [labels](/guides/labels/) and [milestones](/guides/milestones/), and | |
| 230 | − | see and dismiss their [security alerts](/guides/security/). Deleting, purging | |
| 229 | + | their [labels](/guides/labels/) and [milestones](/guides/milestones/), | |
| 230 | + | see and dismiss their [security alerts](/guides/security/), read what their | |
| 231 | + | default branch says (languages, contributors, license), star them, and | |
| 232 | + | publish [releases](/guides/releases/). Deleting, purging | |
| 231 | 233 | and changing visibility need `confirm`, the repository's full name typed | |
| 232 | 234 | out. | |
| 233 | 235 | ||
| ⋯ | |||
| 259 | 261 | | [`update_milestone`](/reference/api/labels-and-milestones/update-milestone/) | Change its `title`, `description`, `due_on` (`""` clears it) or `state` (`open` or `closed`). Triage role. | `repo`, `milestone` | `issues:write` | | |
| 260 | 262 | | [`delete_milestone`](/reference/api/labels-and-milestones/delete-milestone/) | Delete a milestone; what was in it is in none. Triage role. | `repo`, `milestone` | `issues:write` | | |
| 261 | 263 | | [`list_events`](/reference/api/repositories/list-events/) | Its timeline, newest first. `before` pages back. | `repo` | `repo:read` | | |
| 264 | + | | [`languages`](/reference/api/repository-insights/get-languages/) | Its [languages](/guides/managing-repositories/#languages) by bytes, each with `color` and `percent`, for the default branch's `commit`; `pending` while it is first read. | `repo` | `repo:read` | | |
| 265 | + | | [`contributors`](/reference/api/repository-insights/list-contributors/) | Its [contributors](/guides/managing-repositories/#contributors): `kind` (`user`, `g1t` or `author`), `commits` and `weeks`, and the repository's commits by week. | `repo` | `repo:read` | | |
| 266 | + | | [`license`](/reference/api/repository-insights/get-license/) | The license its `LICENSE` file holds: `spdx_id`, `name`, `path`. | `repo` | `repo:read` | | |
| 267 | + | | [`stargazers`](/reference/api/stars/list-stargazers/) | Who starred it, newest first, 100 a `page`. | `repo` | `repo:read` | | |
| 268 | + | | [`starred`](/reference/api/stars/check-starred/) | Whether you starred it, and how many have. | `repo` | `account:read` | | |
| 269 | + | | [`star`](/reference/api/stars/star-repo/) | Star it. People only. | `repo` | `account:write` | | |
| 270 | + | | [`unstar`](/reference/api/stars/unstar-repo/) | Take your star back. | `repo` | `account:write` | | |
| 271 | + | | [`list_starred`](/reference/api/stars/list-starred/) | Repositories you starred that you can still see. | None | `account:read` | | |
| 272 | + | | [`list_releases`](/reference/api/releases/list-releases/) | Its [releases](/guides/releases/), newest first; drafts only for the Write role. | `repo` | `repo:read` | | |
| 273 | + | | [`latest_release`](/reference/api/releases/get-latest-release/) | The newest published release that is neither a draft nor a prerelease. | `repo` | `repo:read` | | |
| 274 | + | | [`get_release`](/reference/api/releases/get-release/) | One release by `id`. | `repo`, `id` | `repo:read` | | |
| 275 | + | | [`get_release_by_tag`](/reference/api/releases/get-release-by-tag/) | The release of a `tag`. | `repo`, `tag` | `repo:read` | | |
| 276 | + | | [`create_release`](/reference/api/releases/create-release/) | Publish a release of `tag_name` with `release_name` and `body`; a new tag is made at `target`. `draft`, `prerelease`. Write role. | `repo`, `tag_name` | `repo:write` | | |
| 277 | + | | [`update_release`](/reference/api/releases/update-release/) | Change its `release_name`, `body`, `draft` or `prerelease`. Write role. | `repo`, `id` | `repo:write` | | |
| 278 | + | | [`delete_release`](/reference/api/releases/delete-release/) | Delete a release; its tag stays. Write role. | `repo`, `id` | `repo:write` | | |
| 262 | 279 | | [`rename_branch`](/reference/api/repositories/rename-branch/) | Rename a branch; its pull requests follow, and web addresses that name the old branch redirect. Write role; the default branch needs Admin. | `repo`, `branch`, `new_name` | `repo:write` | | |
| 263 | 280 | | [`rename`](/reference/api/repositories/rename-repo/) | Give it a new name in its workspace; the old address redirects. Admin role. | `repo`, `name` | `repo:admin` | | |
| 264 | 281 | | [`transfer`](/reference/api/repositories/transfer-repo/) | Move it to another workspace, keeping its name; the old address redirects. Owners of both workspaces only. See [transferring a repository](/guides/transferring-repositories/). | `repo`, `to` | `repo:admin` | | |
Binary or large file; its contents are not shown.
| 1 | + | /** | |
| 2 | + | * A release as the Releases page and its own page show it: when and of | |
| 3 | + | * which tag and commit on the left, its title, notes and downloads on the | |
| 4 | + | * right; one above the other on a phone. | |
| 5 | + | */ | |
| 6 | + | import { FileArchive, GitCommitHorizontal, Tag as TagIcon } from "lucide-react"; | |
| 7 | + | import { Link } from "react-router"; | |
| 8 | + | ||
| 9 | + | import type { Release, RepoPath } from "@g1t/contracts"; | |
| 10 | + | ||
| 11 | + | import { Markdown } from "./markdown"; | |
| 12 | + | import { Pill, TimeAgo } from "./ui"; | |
| 13 | + | ||
| 14 | + | export function encodeTag(tag: string): string { | |
| 15 | + | return tag.split("/").map(encodeURIComponent).join("/"); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export function ReleaseCard({ release, repo, linkTitle = true }: { release: Release; repo: RepoPath; linkTitle?: boolean }) { | |
| 19 | + | const base = `/${repo.namespace}/${repo.name}`; | |
| 20 | + | const title = release.name || release.tagName; | |
| 21 | + | const when = release.publishedAt ?? release.createdAt; | |
| 22 | + | return ( | |
| 23 | + | <article className="grid gap-4 md:grid-cols-[10rem_minmax(0,1fr)] md:gap-6"> | |
| 24 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm text-muted md:flex-col md:items-start md:pt-4"> | |
| 25 | + | <span className="text-faint"> | |
| 26 | + | <TimeAgo at={when} /> | |
| 27 | + | </span> | |
| 28 | + | <Link to={`${base}/tree/${encodeTag(release.tagName)}`} className="inline-flex min-w-0 items-center gap-1.5 font-mono text-[0.8125rem] hover:text-accent"> | |
| 29 | + | <TagIcon size={14} className="shrink-0 text-faint" /> | |
| 30 | + | <span className="truncate">{release.tagName}</span> | |
| 31 | + | </Link> | |
| 32 | + | <Link to={`${base}/commit/${release.target}`} className="inline-flex items-center gap-1.5 font-mono text-[0.8125rem] hover:text-accent"> | |
| 33 | + | <GitCommitHorizontal size={14} className="shrink-0 text-faint" /> | |
| 34 | + | {release.target.slice(0, 7)} | |
| 35 | + | </Link> | |
| 36 | + | </div> | |
| 37 | + | <div className="min-w-0 overflow-hidden rounded-xl border border-line bg-surface"> | |
| 38 | + | <div className="border-b border-line px-5 py-4"> | |
| 39 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 40 | + | <h3 className="min-w-0 text-xl font-semibold tracking-tight break-words"> | |
| 41 | + | {linkTitle ? ( | |
| 42 | + | <Link to={`${base}/releases/tag/${encodeTag(release.tagName)}`} className="hover:text-accent hover:underline"> | |
| 43 | + | {title} | |
| 44 | + | </Link> | |
| 45 | + | ) : ( | |
| 46 | + | title | |
| 47 | + | )} | |
| 48 | + | </h3> | |
| 49 | + | {release.latest && <Pill>Latest</Pill>} | |
| 50 | + | {release.prerelease && <Pill>Pre-release</Pill>} | |
| 51 | + | {release.draft && <Pill>Draft</Pill>} | |
| 52 | + | </div> | |
| 53 | + | {release.author && ( | |
| 54 | + | <p className="mt-1 text-xs text-faint"> | |
| 55 | + | <Link to={`/u/${release.author}`} className="font-medium text-muted hover:text-accent"> | |
| 56 | + | {release.author} | |
| 57 | + | </Link>{" "} | |
| 58 | + | {release.draft ? "drafted this" : "released this"} <TimeAgo at={when} /> | |
| 59 | + | </p> | |
| 60 | + | )} | |
| 61 | + | </div> | |
| 62 | + | <div className="px-5 py-4"> | |
| 63 | + | {release.body.trim() ? ( | |
| 64 | + | <Markdown source={release.body} repo={repo} base={`${base}/blob/${encodeTag(release.tagName)}`} /> | |
| 65 | + | ) : ( | |
| 66 | + | <p className="text-sm text-faint">No notes.</p> | |
| 67 | + | )} | |
| 68 | + | </div> | |
| 69 | + | <div className="border-t border-line px-5 py-3"> | |
| 70 | + | <h4 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Downloads</h4> | |
| 71 | + | <a | |
| 72 | + | href={`${base}/archive/${encodeTag(release.tagName)}.zip`} | |
| 73 | + | download | |
| 74 | + | className="inline-flex items-center gap-2 text-sm text-muted hover:text-accent" | |
| 75 | + | > | |
| 76 | + | <FileArchive size={14} className="text-faint" /> | |
| 77 | + | Source code (zip) | |
| 78 | + | </a> | |
| 79 | + | </div> | |
| 80 | + | </div> | |
| 81 | + | </article> | |
| 82 | + | ); | |
| 83 | + | } |
| 1 | + | /** | |
| 2 | + | * The About beside a repository's files: what it is (description, home | |
| 3 | + | * page, topics), what its files say (readme, license, security policy), | |
| 4 | + | * how people follow it (activity, stars, watching), then its releases, | |
| 5 | + | * deployments, packages, contributors and languages, each a section. | |
| 6 | + | * | |
| 7 | + | * What is read from the default branch (license, languages, contributors) | |
| 8 | + | * is worked out in the background and kept by commit; it streams in after | |
| 9 | + | * the files, and while it is first being worked out the panel asks for it | |
| 10 | + | * again by itself (`about.json`). | |
| 11 | + | */ | |
| 12 | + | import { | |
| 13 | + | Activity, | |
| 14 | + | BookOpen, | |
| 15 | + | Eye, | |
| 16 | + | Link2, | |
| 17 | + | Package as PackageIcon, | |
| 18 | + | Plus, | |
| 19 | + | Scale, | |
| 20 | + | ShieldCheck, | |
| 21 | + | Star, | |
| 22 | + | Tag as TagIcon, | |
| 23 | + | } from "lucide-react"; | |
| 24 | + | import { type ReactNode, Suspense, useEffect, useState } from "react"; | |
| 25 | + | import { Await, Link } from "react-router"; | |
| 26 | + | ||
| 27 | + | import type { PackageSummary, Repo, RepoAbout } from "@g1t/contracts"; | |
| 28 | + | ||
| 29 | + | import { compact, contributorHref, count, languageBar, licenseLabel } from "../lib/about"; | |
| 30 | + | import { PackageIcon as EcosystemIcon } from "./package-icon"; | |
| 31 | + | import { Topics } from "./topics"; | |
| 32 | + | import { Avatar, Pill, TimeAgo } from "./ui"; | |
| 33 | + | import { Hint } from "./ui/hint"; | |
| 34 | + | import { SkeletonLine } from "./ui/skeleton"; | |
| 35 | + | ||
| 36 | + | /** How many contributors' avatars the About shows. */ | |
| 37 | + | const AVATARS = 14; | |
| 38 | + | /** How often, and how many times, it asks again while the default branch is first read. */ | |
| 39 | + | const RETRY_MS = 3_000; | |
| 40 | + | const RETRIES = 6; | |
| 41 | + | ||
| 42 | + | function encodePath(path: string): string { | |
| 43 | + | return path.split("/").map(encodeURIComponent).join("/"); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | function Section({ title, count: n, to, children }: { title: string; count?: number; to?: string; children: ReactNode }) { | |
| 47 | + | const heading = ( | |
| 48 | + | <> | |
| 49 | + | {title} | |
| 50 | + | {n != null && n > 0 && <span className="rounded-full bg-raised px-1.5 text-xs font-medium text-muted tabular-nums">{n.toLocaleString("en-US")}</span>} | |
| 51 | + | </> | |
| 52 | + | ); | |
| 53 | + | return ( | |
| 54 | + | <section className="border-t border-line pt-4"> | |
| 55 | + | <h2 className="mb-2.5 text-sm font-semibold"> | |
| 56 | + | {to ? ( | |
| 57 | + | <Link to={to} className="inline-flex items-center gap-1.5 hover:text-accent"> | |
| 58 | + | {heading} | |
| 59 | + | </Link> | |
| 60 | + | ) : ( | |
| 61 | + | <span className="inline-flex items-center gap-1.5">{heading}</span> | |
| 62 | + | )} | |
| 63 | + | </h2> | |
| 64 | + | {children} | |
| 65 | + | </section> | |
| 66 | + | ); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | function Row({ icon, children }: { icon: ReactNode; children: ReactNode }) { | |
| 70 | + | return ( | |
| 71 | + | <li className="flex min-w-0 items-center gap-2"> | |
| 72 | + | <span className="flex size-4 shrink-0 items-center justify-center text-faint">{icon}</span> | |
| 73 | + | <span className="min-w-0 truncate">{children}</span> | |
| 74 | + | </li> | |
| 75 | + | ); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | const ROW_LINK = "hover:text-accent"; | |
| 79 | + | ||
| 80 | + | /** The colored bar and each language's share. */ | |
| 81 | + | export function LanguageBar({ languages }: { languages: RepoAbout["languages"] }) { | |
| 82 | + | const bar = languageBar(languages); | |
| 83 | + | return ( | |
| 84 | + | <div> | |
| 85 | + | <div className="flex h-2 overflow-hidden rounded-full bg-raised" role="img" aria-label={bar.map((l) => `${l.name} ${l.percent}%`).join(", ")}> | |
| 86 | + | {bar.map((language) => ( | |
| 87 | + | <span | |
| 88 | + | key={language.name} | |
| 89 | + | className="h-full border-r border-bg last:border-r-0" | |
| 90 | + | style={{ width: `${Math.max(language.percent, 0.6)}%`, background: language.color ?? "var(--color-faint)" }} | |
| 91 | + | /> | |
| 92 | + | ))} | |
| 93 | + | </div> | |
| 94 | + | <ul className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1.5 text-xs"> | |
| 95 | + | {bar.map((language) => ( | |
| 96 | + | <li key={language.name} className="inline-flex items-center gap-1.5"> | |
| 97 | + | <span className="size-2 shrink-0 rounded-full" style={{ background: language.color ?? "var(--color-faint)" }} /> | |
| 98 | + | <span className="font-medium text-fg">{language.name}</span> | |
| 99 | + | <span className="text-faint tabular-nums">{language.percent.toFixed(1)}%</span> | |
| 100 | + | </li> | |
| 101 | + | ))} | |
| 102 | + | </ul> | |
| 103 | + | </div> | |
| 104 | + | ); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /** The About of a repository that is read from its default branch: it asks again while that is pending. */ | |
| 108 | + | function useLive(base: string, first: RepoAbout): RepoAbout { | |
| 109 | + | const [about, setAbout] = useState(first); | |
| 110 | + | useEffect(() => setAbout(first), [first]); | |
| 111 | + | useEffect(() => { | |
| 112 | + | if (!about.pending) return; | |
| 113 | + | let tries = 0; | |
| 114 | + | let stopped = false; | |
| 115 | + | const timer = setInterval(async () => { | |
| 116 | + | tries += 1; | |
| 117 | + | if (tries > RETRIES) return clearInterval(timer); | |
| 118 | + | try { | |
| 119 | + | const response = await fetch(`${base}/about.json`, { headers: { accept: "application/json" } }); | |
| 120 | + | if (!response.ok || stopped) return; | |
| 121 | + | const next = (await response.json()) as RepoAbout; | |
| 122 | + | if (!next.pending) { | |
| 123 | + | clearInterval(timer); | |
| 124 | + | setAbout(next); | |
| 125 | + | } | |
| 126 | + | } catch { | |
| 127 | + | // Asked again on the next tick. | |
| 128 | + | } | |
| 129 | + | }, RETRY_MS); | |
| 130 | + | return () => { | |
| 131 | + | stopped = true; | |
| 132 | + | clearInterval(timer); | |
| 133 | + | }; | |
| 134 | + | }, [base, about.pending]); | |
| 135 | + | return about; | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /** Reading the default branch for the first time. */ | |
| 139 | + | function Reading() { | |
| 140 | + | return <p className="text-xs text-faint">Reading the default branch…</p>; | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | function Releases({ base, about, canPush }: { base: string; about: RepoAbout; canPush: boolean }) { | |
| 144 | + | const latest = about.latestRelease; | |
| 145 | + | return ( | |
| 146 | + | <Section title="Releases" count={about.releases} to={`${base}/releases`}> | |
| 147 | + | {latest ? ( | |
| 148 | + | <div className="text-sm"> | |
| 149 | + | <Link to={`${base}/releases/tag/${encodePath(latest.tagName)}`} className="flex items-start gap-2 hover:text-accent"> | |
| 150 | + | <TagIcon size={15} className="mt-0.5 shrink-0 text-success" /> | |
| 151 | + | <span className="min-w-0"> | |
| 152 | + | <span className="flex flex-wrap items-center gap-1.5"> | |
| 153 | + | <span className="truncate font-medium">{latest.name || latest.tagName}</span> | |
| 154 | + | <Pill>Latest</Pill> | |
| 155 | + | </span> | |
| 156 | + | <span className="text-xs text-faint">{latest.publishedAt && <TimeAgo at={latest.publishedAt} />}</span> | |
| 157 | + | </span> | |
| 158 | + | </Link> | |
| 159 | + | {about.releases > 1 && ( | |
| 160 | + | <Link to={`${base}/releases`} className="mt-2 block text-xs text-muted hover:text-accent"> | |
| 161 | + | + {count(about.releases - 1, "release")} | |
| 162 | + | </Link> | |
| 163 | + | )} | |
| 164 | + | </div> | |
| 165 | + | ) : about.releases > 0 ? ( | |
| 166 | + | <Link to={`${base}/releases`} className="text-sm text-muted hover:text-accent"> | |
| 167 | + | {count(about.releases, "release")} | |
| 168 | + | </Link> | |
| 169 | + | ) : ( | |
| 170 | + | <div className="text-sm text-muted"> | |
| 171 | + | <p>No releases published</p> | |
| 172 | + | {canPush && ( | |
| 173 | + | <Link to={`${base}/releases/new`} className="mt-1 inline-flex items-center gap-1 text-accent hover:underline"> | |
| 174 | + | <Plus size={13} /> | |
| 175 | + | Create a new release | |
| 176 | + | </Link> | |
| 177 | + | )} | |
| 178 | + | </div> | |
| 179 | + | )} | |
| 180 | + | </Section> | |
| 181 | + | ); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | function Packages({ packages }: { packages: PackageSummary[] | null }) { | |
| 185 | + | return ( | |
| 186 | + | <Section title="Packages" count={packages?.length}> | |
| 187 | + | {packages && packages.length > 0 ? ( | |
| 188 | + | <ul className="space-y-2 text-sm"> | |
| 189 | + | {packages.slice(0, 5).map((pkg) => ( | |
| 190 | + | <li key={pkg.id}> | |
| 191 | + | <Link to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`} className="flex min-w-0 items-center gap-2 hover:text-accent"> | |
| 192 | + | <EcosystemIcon ecosystem={pkg.ecosystem} size={16} /> | |
| 193 | + | <span className="truncate">{pkg.name}</span> | |
| 194 | + | {pkg.latest && <span className="ml-auto shrink-0 font-mono text-xs text-faint">{pkg.latest}</span>} | |
| 195 | + | </Link> | |
| 196 | + | </li> | |
| 197 | + | ))} | |
| 198 | + | </ul> | |
| 199 | + | ) : ( | |
| 200 | + | <div className="text-sm text-muted"> | |
| 201 | + | <p>No packages published</p> | |
| 202 | + | <a href="https://docs.g1t.sh/guides/packages/" className="mt-1 inline-flex items-center gap-1 text-accent hover:underline"> | |
| 203 | + | <PackageIcon size={13} /> | |
| 204 | + | Publish your first package | |
| 205 | + | </a> | |
| 206 | + | </div> | |
| 207 | + | )} | |
| 208 | + | </Section> | |
| 209 | + | ); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | function Contributors({ base, about }: { base: string; about: RepoAbout }) { | |
| 213 | + | const shown = about.topContributors.slice(0, AVATARS); | |
| 214 | + | return ( | |
| 215 | + | <Section title="Contributors" count={about.contributors} to={`${base}/contributors`}> | |
| 216 | + | {about.pending ? ( | |
| 217 | + | <Reading /> | |
| 218 | + | ) : shown.length === 0 ? ( | |
| 219 | + | <p className="text-sm text-muted">No commits yet</p> | |
| 220 | + | ) : ( | |
| 221 | + | <> | |
| 222 | + | <ul className="flex flex-wrap gap-1.5"> | |
| 223 | + | {shown.map((contributor) => { | |
| 224 | + | const href = contributorHref(contributor); | |
| 225 | + | const label = `${contributor.name} · ${count(contributor.commits, "commit")}`; | |
| 226 | + | const face = <Avatar name={contributor.name} image={contributor.avatar} size={30} system={contributor.kind === "g1t"} />; | |
| 227 | + | return ( | |
| 228 | + | <li key={`${contributor.kind}:${contributor.name}`}> | |
| 229 | + | <Hint label={label}> | |
| 230 | + | {href ? ( | |
| 231 | + | <Link to={href} aria-label={label} className="block rounded-full"> | |
| 232 | + | {face} | |
| 233 | + | </Link> | |
| 234 | + | ) : ( | |
| 235 | + | <span aria-label={label} className="block rounded-full"> | |
| 236 | + | {face} | |
| 237 | + | </span> | |
| 238 | + | )} | |
| 239 | + | </Hint> | |
| 240 | + | </li> | |
| 241 | + | ); | |
| 242 | + | })} | |
| 243 | + | </ul> | |
| 244 | + | {about.contributors > shown.length && ( | |
| 245 | + | <Link to={`${base}/contributors`} className="mt-2 block text-xs text-muted hover:text-accent"> | |
| 246 | + | + {count(about.contributors - shown.length, "contributor")} | |
| 247 | + | </Link> | |
| 248 | + | )} | |
| 249 | + | </> | |
| 250 | + | )} | |
| 251 | + | </Section> | |
| 252 | + | ); | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /** What the default branch says and the counts, once they have streamed in. */ | |
| 256 | + | function Facts({ base, gitRef, about, watchers }: { base: string; gitRef: string; about: RepoAbout | null; watchers: number | null }) { | |
| 257 | + | const activity = ( | |
| 258 | + | <Row icon={<Activity size={15} />}> | |
| 259 | + | <Link to={`${base}/activity`} className={ROW_LINK}> | |
| 260 | + | Activity | |
| 261 | + | </Link> | |
| 262 | + | </Row> | |
| 263 | + | ); | |
| 264 | + | if (!about) return activity; | |
| 265 | + | const blob = (path: string) => `${base}/blob/${encodePath(gitRef)}/${encodePath(path)}`; | |
| 266 | + | return ( | |
| 267 | + | <> | |
| 268 | + | {about.license && ( | |
| 269 | + | <Row icon={<Scale size={15} />}> | |
| 270 | + | <Link to={blob(about.license.path)} className={ROW_LINK}> | |
| 271 | + | {licenseLabel(about.license)} | |
| 272 | + | </Link> | |
| 273 | + | </Row> | |
| 274 | + | )} | |
| 275 | + | {about.securityPolicy && ( | |
| 276 | + | <Row icon={<ShieldCheck size={15} />}> | |
| 277 | + | <Link to={blob(about.securityPolicy)} className={ROW_LINK}> | |
| 278 | + | Security policy | |
| 279 | + | </Link> | |
| 280 | + | </Row> | |
| 281 | + | )} | |
| 282 | + | {activity} | |
| 283 | + | <Row icon={<Star size={15} />}> | |
| 284 | + | <Link to={`${base}/stargazers`} className={ROW_LINK}> | |
| 285 | + | <span className="font-medium text-fg">{compact(about.stars)}</span> {about.stars === 1 ? "star" : "stars"} | |
| 286 | + | </Link> | |
| 287 | + | </Row> | |
| 288 | + | {watchers != null && ( | |
| 289 | + | <Row icon={<Eye size={15} />}> | |
| 290 | + | <span> | |
| 291 | + | <span className="font-medium text-fg">{watchers.toLocaleString("en-US")}</span> watching | |
| 292 | + | </span> | |
| 293 | + | </Row> | |
| 294 | + | )} | |
| 295 | + | </> | |
| 296 | + | ); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | function FactsSkeleton() { | |
| 300 | + | return ( | |
| 301 | + | <> | |
| 302 | + | {[0, 1, 2].map((key) => ( | |
| 303 | + | <li key={key} aria-hidden="true" className="flex items-center gap-2"> | |
| 304 | + | <span className="size-4 shrink-0" /> | |
| 305 | + | <SkeletonLine barClassName="w-24" /> | |
| 306 | + | </li> | |
| 307 | + | ))} | |
| 308 | + | </> | |
| 309 | + | ); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | function LoadedSections({ | |
| 313 | + | base, | |
| 314 | + | first, | |
| 315 | + | canPush, | |
| 316 | + | packages, | |
| 317 | + | deployments, | |
| 318 | + | }: { | |
| 319 | + | base: string; | |
| 320 | + | first: RepoAbout; | |
| 321 | + | canPush: boolean; | |
| 322 | + | packages: Promise<PackageSummary[] | null> | PackageSummary[] | null; | |
| 323 | + | deployments: ReactNode; | |
| 324 | + | }) { | |
| 325 | + | const about = useLive(base, first); | |
| 326 | + | return ( | |
| 327 | + | <> | |
| 328 | + | <Releases base={base} about={about} canPush={canPush} /> | |
| 329 | + | {deployments} | |
| 330 | + | <Suspense fallback={<SectionSkeleton title="Packages" />}> | |
| 331 | + | <Await resolve={packages} errorElement={<Packages packages={null} />}> | |
| 332 | + | {(found) => <Packages packages={found} />} | |
| 333 | + | </Await> | |
| 334 | + | </Suspense> | |
| 335 | + | <Contributors base={base} about={about} /> | |
| 336 | + | <Section title="Languages"> | |
| 337 | + | {about.pending ? <Reading /> : about.languages.length > 0 ? <LanguageBar languages={about.languages} /> : <p className="text-sm text-muted">No code to count</p>} | |
| 338 | + | </Section> | |
| 339 | + | </> | |
| 340 | + | ); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | function SectionSkeleton({ title }: { title: string }) { | |
| 344 | + | return ( | |
| 345 | + | <section className="border-t border-line pt-4" aria-busy="true"> | |
| 346 | + | <h2 className="mb-2.5 text-sm font-semibold">{title}</h2> | |
| 347 | + | <SkeletonLine barClassName="w-32" /> | |
| 348 | + | </section> | |
| 349 | + | ); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | export type AboutData = { | |
| 353 | + | /** What the default branch says, stars and releases; null when it could not be read. */ | |
| 354 | + | about: Promise<RepoAbout | null> | RepoAbout | null; | |
| 355 | + | /** How many watch it; null when it could not be read. */ | |
| 356 | + | watchers: Promise<number | null> | number | null; | |
| 357 | + | /** Packages published from it; null when they could not be read. */ | |
| 358 | + | packages: Promise<PackageSummary[] | null> | PackageSummary[] | null; | |
| 359 | + | }; | |
| 360 | + | ||
| 361 | + | export function RepoAboutPanel({ | |
| 362 | + | repo, | |
| 363 | + | gitRef, | |
| 364 | + | readme, | |
| 365 | + | data, | |
| 366 | + | canPush, | |
| 367 | + | homepage, | |
| 368 | + | deployments = null, | |
| 369 | + | }: { | |
| 370 | + | repo: Repo; | |
| 371 | + | gitRef: string; | |
| 372 | + | readme: boolean; | |
| 373 | + | data: AboutData; | |
| 374 | + | canPush: boolean; | |
| 375 | + | /** | |
| 376 | + | * The project's home page. The repository's website until project links | |
| 377 | + | * are wired in; the project's own link takes its place then. | |
| 378 | + | */ | |
| 379 | + | homepage?: string | null; | |
| 380 | + | /** | |
| 381 | + | * Slot: the Deployments section (production and previews with their | |
| 382 | + | * times), built beside this panel. Null leaves no section. | |
| 383 | + | */ | |
| 384 | + | deployments?: ReactNode; | |
| 385 | + | }) { | |
| 386 | + | const base = `/${repo.namespace}/${repo.name}`; | |
| 387 | + | const site = homepage ?? repo.website ?? null; | |
| 388 | + | const facts = (about: RepoAbout | null, watchers: number | null) => <Facts base={base} gitRef={gitRef} about={about} watchers={watchers} />; | |
| 389 | + | return ( | |
| 390 | + | <aside className="space-y-4"> | |
| 391 | + | <div> | |
| 392 | + | <h2 className="text-base font-semibold">About</h2> | |
| 393 | + | <p className="mt-2.5 text-sm text-fg-soft">{repo.description ?? (site || repo.topics.length > 0 ? "No description." : "No description, website, or topics provided.")}</p> | |
| 394 | + | {site && ( | |
| 395 | + | <a href={site} rel="noopener nofollow" className="mt-2.5 flex min-w-0 items-center gap-2 text-sm font-medium text-accent hover:underline"> | |
| 396 | + | <Link2 size={15} className="shrink-0" /> | |
| 397 | + | <span className="truncate">{site.replace(/^https?:\/\//, "").replace(/\/$/, "")}</span> | |
| 398 | + | </a> | |
| 399 | + | )} | |
| 400 | + | <Topics topics={repo.topics} className="mt-3" /> | |
| 401 | + | <ul className="mt-4 space-y-2.5 text-sm text-muted"> | |
| 402 | + | {readme && ( | |
| 403 | + | <Row icon={<BookOpen size={15} />}> | |
| 404 | + | <a href="#readme" className={ROW_LINK}> | |
| 405 | + | Readme | |
| 406 | + | </a> | |
| 407 | + | </Row> | |
| 408 | + | )} | |
| 409 | + | <Suspense fallback={<FactsSkeleton />}> | |
| 410 | + | <Await resolve={data.about} errorElement={facts(null, null)}> | |
| 411 | + | {(about) => ( | |
| 412 | + | <Suspense fallback={facts(about, null)}> | |
| 413 | + | <Await resolve={data.watchers} errorElement={facts(about, null)}> | |
| 414 | + | {(watchers) => facts(about, watchers)} | |
| 415 | + | </Await> | |
| 416 | + | </Suspense> | |
| 417 | + | )} | |
| 418 | + | </Await> | |
| 419 | + | </Suspense> | |
| 420 | + | </ul> | |
| 421 | + | <p className="mt-4 text-xs text-faint"> | |
| 422 | + | Created <TimeAgo at={repo.createdAt} /> | |
| 423 | + | </p> | |
| 424 | + | </div> | |
| 425 | + | <Suspense | |
| 426 | + | fallback={ | |
| 427 | + | <> | |
| 428 | + | <SectionSkeleton title="Releases" /> | |
| 429 | + | {deployments} | |
| 430 | + | <SectionSkeleton title="Packages" /> | |
| 431 | + | <SectionSkeleton title="Contributors" /> | |
| 432 | + | <SectionSkeleton title="Languages" /> | |
| 433 | + | </> | |
| 434 | + | } | |
| 435 | + | > | |
| 436 | + | <Await resolve={data.about} errorElement={<>{deployments}</>}> | |
| 437 | + | {(about) => | |
| 438 | + | about ? ( | |
| 439 | + | <LoadedSections base={base} first={about} canPush={canPush} packages={data.packages} deployments={deployments} /> | |
| 440 | + | ) : ( | |
| 441 | + | <>{deployments}</> | |
| 442 | + | ) | |
| 443 | + | } | |
| 444 | + | </Await> | |
| 445 | + | </Suspense> | |
| 446 | + | </aside> | |
| 447 | + | ); | |
| 448 | + | } |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.