Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
2 commits
| 14 | 14 | mod oauth; | |
| 15 | 15 | mod openapi; | |
| 16 | 16 | mod pins; | |
| 17 | + | mod projects; | |
| 17 | 18 | mod operations; | |
| 18 | 19 | mod renamed; | |
| 19 | 20 | #[cfg(test)] |
| 46 | 46 | &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects], | |
| 47 | 47 | ), | |
| 48 | 48 | ( | |
| 49 | + | "Projects", | |
| 50 | + | "A project is what a workspace builds and runs, from a repository or a root directory in one. Each says what it is, where it runs and where to find it: its homepage, docs and other links.", | |
| 51 | + | &[Op::ListProjects, Op::GetProject, Op::UpdateProject], | |
| 52 | + | ), | |
| 53 | + | ( | |
| 49 | 54 | "Workspaces", | |
| 50 | 55 | "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.", | |
| 51 | 56 | &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace], | |
| 538 | 543 | Op::PinProject => "Pin a project", | |
| 539 | 544 | Op::UnpinProject => "Unpin a project", | |
| 540 | 545 | Op::ReorderPinnedProjects => "Reorder your pinned projects", | |
| 546 | + | Op::ListProjects => "List a workspace's projects", | |
| 547 | + | Op::GetProject => "Get a project", | |
| 548 | + | Op::UpdateProject => "Update a project", | |
| 541 | 549 | Op::ListTeams => "List teams", | |
| 542 | 550 | Op::GetTeam => "Get a team", | |
| 543 | 551 | Op::CreateTeam => "Create a team", |
| 238 | 238 | PinProject, | |
| 239 | 239 | UnpinProject, | |
| 240 | 240 | ReorderPinnedProjects, | |
| 241 | + | ListProjects, | |
| 242 | + | GetProject, | |
| 243 | + | UpdateProject, | |
| 241 | 244 | ListTeams, | |
| 242 | 245 | GetTeam, | |
| 243 | 246 | CreateTeam, | |
| 629 | 632 | } | |
| 630 | 633 | ||
| 631 | 634 | impl Op { | |
| 632 | − | pub const ALL: [Op; 219] = [ | |
| 635 | + | pub const ALL: [Op; 222] = [ | |
| 633 | 636 | Op::Whoami, | |
| 634 | 637 | Op::GetWorkspace, | |
| 635 | 638 | Op::CreateWorkspace, | |
| 780 | 783 | Op::PinProject, | |
| 781 | 784 | Op::UnpinProject, | |
| 782 | 785 | Op::ReorderPinnedProjects, | |
| 786 | + | Op::ListProjects, | |
| 787 | + | Op::GetProject, | |
| 788 | + | Op::UpdateProject, | |
| 783 | 789 | Op::ListTeams, | |
| 784 | 790 | Op::GetTeam, | |
| 785 | 791 | Op::CreateTeam, | |
| 1008 | 1014 | Op::PinProject => "pin_project", | |
| 1009 | 1015 | Op::UnpinProject => "unpin_project", | |
| 1010 | 1016 | Op::ReorderPinnedProjects => "reorder_pinned_projects", | |
| 1017 | + | Op::ListProjects => "list_projects", | |
| 1018 | + | Op::GetProject => "get_project", | |
| 1019 | + | Op::UpdateProject => "update_project", | |
| 1011 | 1020 | Op::ListTeams => "list_teams", | |
| 1012 | 1021 | Op::GetTeam => "get_team", | |
| 1013 | 1022 | Op::CreateTeam => "create_team", | |
| 1456 | 1465 | Op::ReorderPinnedProjects => { | |
| 1457 | 1466 | "Put your pins in a workspace in a new order: `projects` names every pinned project's slug, once, in the order you want them. Returns your pins, in order." | |
| 1458 | 1467 | } | |
| 1468 | + | Op::ListProjects => { | |
| 1469 | + | "A workspace's projects that you can see, by name. A project is what a workspace builds and runs, from a repository or a root directory in one; every repository has a project of its own name. Each has what it is (`kind`: app, library, tool, docs or other) and why (`kind_reason`), where it runs (`runs`: `g1t` when g1t deploys it, `elsewhere` when it is deployed by other means, at `production_url`), and its `links`." | |
| 1470 | + | } | |
| 1471 | + | Op::GetProject => { | |
| 1472 | + | "A project: what it is (`kind`, and `kind_reason` saying why), where it runs (`runs` and `production_url`), what you set and what detection decides (`setting` and `detected`), its repository and `root_dir`, and its homepage, docs and other `links`. A private repository's project is found only by those who can see the repository." | |
| 1473 | + | } | |
| 1474 | + | Op::UpdateProject => { | |
| 1475 | + | "Change a project: its name, description, root directory, what it is, where it runs and its links. Only what you give changes. kind auto and runs auto leave each to detection. Setting runs makes it an app unless it is docs; making it a library, tool or other while Deployments are on is refused, so turn Deployments off first. Give description or homepage as null or \"\" to follow the repository's again, and production_url or docs_url as null or \"\" to clear it. links replaces its other links: at most 10, each a label of up to 40 characters and an http or https address (https:// is added when you leave the scheme out). Needs the Maintain role or higher on its repository." | |
| 1476 | + | } | |
| 1459 | 1477 | Op::ListTeams => { | |
| 1460 | 1478 | "A workspace's teams that you can see, yours first, then by name. A team is a group of the workspace's members, given roles on repositories together, mentioned as @workspace/team and asked to review together. A secret team is seen only by its own people and the workspace's owners. Each team has its `slug`, `name`, `description`, `visibility` (`visible` or `secret`), `parent`, whether its people are notified when it is mentioned (`notify`), its `review_assignment`, how many people, repositories and child teams it has (`members_count`, `repos_count`, `child_teams_count`), your own `viewer_role` in it, and whether you may change it (`can_manage`). `query` narrows them by name or slug. Members of the workspace only." | |
| 1461 | 1479 | } | |
| 2677 | 2695 | }), | |
| 2678 | 2696 | &["workspace", "projects"], | |
| 2679 | 2697 | ), | |
| 2698 | + | Op::ListProjects => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 2699 | + | Op::GetProject => object( | |
| 2700 | + | json!({ | |
| 2701 | + | "workspace": workspace_schema(), | |
| 2702 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2703 | + | }), | |
| 2704 | + | &["workspace", "project"], | |
| 2705 | + | ), | |
| 2706 | + | Op::UpdateProject => object( | |
| 2707 | + | json!({ | |
| 2708 | + | "workspace": workspace_schema(), | |
| 2709 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2710 | + | "name": { "type": "string", "description": "Its name." }, | |
| 2711 | + | "description": { "type": ["string", "null"], "description": "Its own description. null or \"\" follows its repository's again." }, | |
| 2712 | + | "root_dir": { "type": "string", "description": "Where in the repository it lives, such as apps/web; \"\" for the whole repository." }, | |
| 2713 | + | "kind": { | |
| 2714 | + | "type": "string", | |
| 2715 | + | "enum": ["auto", "app", "library", "tool", "docs", "other"], | |
| 2716 | + | "description": "What it is. auto leaves it to detection. A library, tool or other runs nowhere.", | |
| 2717 | + | }, | |
| 2718 | + | "runs": { | |
| 2719 | + | "type": "string", | |
| 2720 | + | "enum": ["auto", "g1t", "elsewhere"], | |
| 2721 | + | "description": "Where it runs: g1t when g1t deploys it, elsewhere when it is deployed by other means. auto leaves it to Deployments.", | |
| 2722 | + | }, | |
| 2723 | + | "production_url": { "type": ["string", "null"], "description": "Production's address when it runs elsewhere. null or \"\" clears it." }, | |
| 2724 | + | "homepage": { "type": ["string", "null"], "description": "Its homepage. null or \"\" follows its repository's website again." }, | |
| 2725 | + | "docs_url": { "type": ["string", "null"], "description": "Where its documentation is read. null or \"\" clears it." }, | |
| 2726 | + | "links": { | |
| 2727 | + | "type": "array", | |
| 2728 | + | "maxItems": 10, | |
| 2729 | + | "items": { | |
| 2730 | + | "type": "object", | |
| 2731 | + | "properties": { | |
| 2732 | + | "label": { "type": "string", "maxLength": 40 }, | |
| 2733 | + | "url": { "type": "string", "description": "An http or https address; https:// is added when you leave the scheme out." }, | |
| 2734 | + | }, | |
| 2735 | + | "required": ["label", "url"], | |
| 2736 | + | }, | |
| 2737 | + | "description": "Its other links, replacing the ones it has. [] removes them all.", | |
| 2738 | + | }, | |
| 2739 | + | }), | |
| 2740 | + | &["workspace", "project"], | |
| 2741 | + | ), | |
| 2680 | 2742 | Op::ListTeams => object( | |
| 2681 | 2743 | json!({ | |
| 2682 | 2744 | "workspace": workspace_schema(), | |
| 2868 | 2930 | | Op::ListCheckNames | |
| 2869 | 2931 | | Op::GetMergeQueue | |
| 2870 | 2932 | | Op::GetCodeownersErrors | |
| 2933 | + | | Op::ListProjects | |
| 2934 | + | | Op::GetProject | |
| 2871 | 2935 | | Op::Rules(RulesOp::ListRepoRulesets | RulesOp::GetRepoRuleset | RulesOp::GetBranchRules) | |
| 2872 | 2936 | ) | |
| 2873 | 2937 | } | |
| 2956 | 3020 | | Op::PinProject | |
| 2957 | 3021 | | Op::UnpinProject | |
| 2958 | 3022 | | Op::ReorderPinnedProjects | |
| 3023 | + | | Op::ListProjects | |
| 3024 | + | | Op::GetProject | |
| 3025 | + | | Op::UpdateProject | |
| 2959 | 3026 | | Op::ListTeams | |
| 2960 | 3027 | | Op::GetTeam | |
| 2961 | 3028 | | Op::CreateTeam | |
| 4844 | 4911 | Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => { | |
| 4845 | 4912 | crate::pins::run(self, services, viewer, input).await | |
| 4846 | 4913 | } | |
| 4914 | + | // What a project is, where it runs and its links: the projects | |
| 4915 | + | // service keeps them and decides who may change them. | |
| 4916 | + | Op::ListProjects | Op::GetProject | Op::UpdateProject => { | |
| 4917 | + | crate::projects::run(self, services, viewer, input).await | |
| 4918 | + | } | |
| 4847 | 4919 | // The security suite: the security service decides, this gives | |
| 4848 | 4920 | // each answer its public shape. | |
| 4849 | 4921 | Op::Security(op) => crate::security::run(op, services, viewer, input).await, |
| 1 | + | //! Projects: what a workspace builds and runs, where each runs, and its | |
| 2 | + | //! links. The projects service keeps them and decides who may see or change | |
| 3 | + | //! them; this is their public shape, in snake_case. | |
| 4 | + | ||
| 5 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 6 | + | use serde_json::{Map, Value, json}; | |
| 7 | + | use worker::Result; | |
| 8 | + | ||
| 9 | + | use crate::operations::{Op, Services}; | |
| 10 | + | ||
| 11 | + | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 12 | + | Ok(Outcome::fail(code, message)) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | fn slug(input: &Value, key: &str) -> Option<String> { | |
| 16 | + | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// A reason a kind was decided, as the API shows it. | |
| 20 | + | fn reason_json(reason: &Value) -> Value { | |
| 21 | + | json!({ "by": reason["by"], "detail": reason["detail"] }) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// A project as the projects service answers (camelCase), as the API shows it. | |
| 25 | + | pub(crate) fn project_json(project: &Value, site: &str) -> Value { | |
| 26 | + | let workspace = project["workspace"].as_str().unwrap_or_default(); | |
| 27 | + | let slug = project["slug"].as_str().unwrap_or_default(); | |
| 28 | + | let source = &project["source"]; | |
| 29 | + | let repository = match (source["repo"]["namespace"].as_str(), source["repo"]["name"].as_str()) { | |
| 30 | + | (Some(namespace), Some(name)) => json!(format!("{namespace}/{name}")), | |
| 31 | + | _ => Value::Null, | |
| 32 | + | }; | |
| 33 | + | let links = &project["links"]; | |
| 34 | + | let custom: Vec<Value> = links["custom"] | |
| 35 | + | .as_array() | |
| 36 | + | .map(|items| items.iter().map(|link| json!({ "label": link["label"], "url": link["url"] })).collect()) | |
| 37 | + | .unwrap_or_default(); | |
| 38 | + | json!({ | |
| 39 | + | "id": project["id"], | |
| 40 | + | "workspace": workspace, | |
| 41 | + | "slug": slug, | |
| 42 | + | "name": project["name"], | |
| 43 | + | "description": project["description"], | |
| 44 | + | "description_inherited": project["descriptionInherited"].as_bool().unwrap_or(false), | |
| 45 | + | "url": format!("{}/{workspace}/{slug}", site.trim_end_matches('/')), | |
| 46 | + | "repository": repository, | |
| 47 | + | "root_dir": source["rootDir"].as_str().unwrap_or_default(), | |
| 48 | + | "default_branch": source["defaultBranch"], | |
| 49 | + | "private": project["private"], | |
| 50 | + | "archived": project["archived"], | |
| 51 | + | "primary": project["primary"], | |
| 52 | + | "kind": project["kind"], | |
| 53 | + | "kind_reason": reason_json(&project["kindReason"]), | |
| 54 | + | "runs": project["runs"], | |
| 55 | + | "production_url": project["productionUrl"], | |
| 56 | + | "setting": { "kind": project["setting"]["kind"], "runs": project["setting"]["runs"] }, | |
| 57 | + | "detected": { | |
| 58 | + | "kind": project["detected"]["kind"], | |
| 59 | + | "reason": reason_json(&project["detected"]["reason"]), | |
| 60 | + | }, | |
| 61 | + | "ecosystem": project["ecosystem"], | |
| 62 | + | "links": { | |
| 63 | + | "homepage": links["homepage"], | |
| 64 | + | "homepage_inherited": links["homepageInherited"].as_bool().unwrap_or(false), | |
| 65 | + | "docs": links["docs"], | |
| 66 | + | "custom": custom, | |
| 67 | + | }, | |
| 68 | + | "created_at": project["createdAt"], | |
| 69 | + | "updated_at": project["updatedAt"], | |
| 70 | + | "pushed_at": project["pushedAt"], | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// The fields a change may set: (input, service, whether null clears it). | |
| 75 | + | const CHANGES: &[(&str, &str, bool)] = &[ | |
| 76 | + | ("name", "name", false), | |
| 77 | + | ("description", "description", true), | |
| 78 | + | ("root_dir", "rootDir", false), | |
| 79 | + | ("kind", "kind", false), | |
| 80 | + | ("runs", "runs", false), | |
| 81 | + | ("production_url", "productionUrl", true), | |
| 82 | + | ("homepage", "homepage", true), | |
| 83 | + | ("docs_url", "docsUrl", true), | |
| 84 | + | ]; | |
| 85 | + | ||
| 86 | + | /// The change `input` asks for, in the service's spelling: only what it | |
| 87 | + | /// gives, with null passed on for what null clears. | |
| 88 | + | pub(crate) fn changes(input: &Value) -> std::result::Result<Value, String> { | |
| 89 | + | let mut out = Map::new(); | |
| 90 | + | for (key, field, clearable) in CHANGES { | |
| 91 | + | match input.get(*key) { | |
| 92 | + | None => {} | |
| 93 | + | Some(Value::Null) if *clearable => { | |
| 94 | + | out.insert((*field).to_owned(), Value::Null); | |
| 95 | + | } | |
| 96 | + | Some(Value::Null) => {} | |
| 97 | + | Some(Value::String(text)) => { | |
| 98 | + | out.insert((*field).to_owned(), json!(text)); | |
| 99 | + | } | |
| 100 | + | Some(_) => { | |
| 101 | + | let kind = if *clearable { "a string or null" } else { "a string" }; | |
| 102 | + | return Err(format!("{key} must be {kind}.")); | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | match input.get("links") { | |
| 107 | + | None | Some(Value::Null) => {} | |
| 108 | + | Some(Value::Array(items)) => { | |
| 109 | + | let mut links = Vec::with_capacity(items.len()); | |
| 110 | + | for item in items { | |
| 111 | + | match (item["label"].as_str(), item["url"].as_str()) { | |
| 112 | + | (Some(label), Some(url)) => links.push(json!({ "label": label, "url": url })), | |
| 113 | + | _ => return Err("Each of links needs a label and a url.".to_owned()), | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | out.insert("links".to_owned(), Value::Array(links)); | |
| 117 | + | } | |
| 118 | + | Some(_) => return Err("links must be a list of { label, url }.".to_owned()), | |
| 119 | + | } | |
| 120 | + | Ok(Value::Object(out)) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Runs one of the project operations. | |
| 124 | + | pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 125 | + | let Some(workspace) = slug(input, "workspace") else { | |
| 126 | + | return failed(FailureCode::Invalid, "Give the workspace's slug."); | |
| 127 | + | }; | |
| 128 | + | let site = services.addresses.site.as_str(); | |
| 129 | + | let one = |outcome: Outcome<Value>| -> Outcome<Value> { | |
| 130 | + | match outcome { | |
| 131 | + | Outcome::Ok(project) => Outcome::Ok(project_json(&project, site)), | |
| 132 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 133 | + | } | |
| 134 | + | }; | |
| 135 | + | if op == Op::ListProjects { | |
| 136 | + | let listed: Outcome<Vec<Value>> = | |
| 137 | + | g1t_kit::call(&services.projects, "list", &json!({ "workspace": workspace, "viewer": viewer })).await?; | |
| 138 | + | return Ok(match listed { | |
| 139 | + | Outcome::Ok(projects) => { | |
| 140 | + | Outcome::Ok(Value::Array(projects.iter().map(|project| project_json(project, site)).collect())) | |
| 141 | + | } | |
| 142 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 143 | + | }); | |
| 144 | + | } | |
| 145 | + | let Some(project) = slug(input, "project") else { | |
| 146 | + | return failed(FailureCode::Invalid, "Give the project's slug."); | |
| 147 | + | }; | |
| 148 | + | match op { | |
| 149 | + | Op::GetProject => Ok(one( | |
| 150 | + | g1t_kit::call( | |
| 151 | + | &services.projects, | |
| 152 | + | "get", | |
| 153 | + | &json!({ "workspace": workspace, "slug": project, "viewer": viewer }), | |
| 154 | + | ) | |
| 155 | + | .await?, | |
| 156 | + | )), | |
| 157 | + | Op::UpdateProject => { | |
| 158 | + | let Some(actor) = viewer else { | |
| 159 | + | return failed(FailureCode::Unauthenticated, "This needs a g1t access token."); | |
| 160 | + | }; | |
| 161 | + | let changes = match changes(input) { | |
| 162 | + | Ok(changes) => changes, | |
| 163 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 164 | + | }; | |
| 165 | + | Ok(one( | |
| 166 | + | g1t_kit::call( | |
| 167 | + | &services.projects, | |
| 168 | + | "update", | |
| 169 | + | &json!({ "actor": actor, "workspace": workspace, "slug": project, "changes": changes }), | |
| 170 | + | ) | |
| 171 | + | .await?, | |
| 172 | + | )) | |
| 173 | + | } | |
| 174 | + | _ => failed(FailureCode::Invalid, "Not a project operation."), | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[cfg(test)] | |
| 179 | + | mod tests { | |
| 180 | + | use super::*; | |
| 181 | + | use g1t_kit::wire; | |
| 182 | + | ||
| 183 | + | fn project() -> Value { | |
| 184 | + | json!({ | |
| 185 | + | "id": "prj_1", "workspace": "flagon-io", "slug": "g1t", "name": "g1t", | |
| 186 | + | "description": "Git hosting for people and agents.", "descriptionInherited": true, | |
| 187 | + | "source": { | |
| 188 | + | "kind": "hosted", "repoId": "repo_1", "repo": { "namespace": "flagon-io", "name": "g1t" }, | |
| 189 | + | "rootDir": "", "defaultBranch": "main", | |
| 190 | + | }, | |
| 191 | + | "private": false, "archived": false, "primary": true, | |
| 192 | + | "setting": { "kind": "app", "runs": "elsewhere" }, | |
| 193 | + | "kind": "app", "kindReason": { "by": "set", "detail": "Set to an app." }, | |
| 194 | + | "runs": "elsewhere", "productionUrl": "https://g1t.sh", | |
| 195 | + | "detected": { "kind": "app", "reason": { "by": "files", "detail": "It has a wrangler.jsonc." } }, | |
| 196 | + | "ecosystem": null, | |
| 197 | + | "links": { | |
| 198 | + | "homepage": "https://g1t.sh", "homepageInherited": true, "docs": "https://docs.g1t.sh", | |
| 199 | + | "custom": [{ "label": "Status", "url": "https://status.g1t.sh" }], | |
| 200 | + | }, | |
| 201 | + | "createdBy": "usr_1", "createdAt": "2026-09-01T10:00:00.000Z", "updatedAt": "2026-10-07T10:00:00.000Z", | |
| 202 | + | "pushedAt": "2026-10-07T09:00:00.000Z", "activity": 12.5, | |
| 203 | + | }) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn a_project_is_snake_case_with_its_address() { | |
| 208 | + | let shown = project_json(&project(), "https://g1t.sh/"); | |
| 209 | + | assert!(wire::camel_case_keys(&shown).is_empty(), "{:?}", wire::camel_case_keys(&shown)); | |
| 210 | + | assert_eq!(shown["url"], "https://g1t.sh/flagon-io/g1t"); | |
| 211 | + | assert_eq!(shown["repository"], "flagon-io/g1t"); | |
| 212 | + | assert_eq!(shown["root_dir"], ""); | |
| 213 | + | assert_eq!(shown["default_branch"], "main"); | |
| 214 | + | assert_eq!(shown["description_inherited"], true); | |
| 215 | + | assert_eq!(shown["kind_reason"]["by"], "set"); | |
| 216 | + | assert_eq!(shown["production_url"], "https://g1t.sh"); | |
| 217 | + | assert_eq!(shown["setting"]["runs"], "elsewhere"); | |
| 218 | + | assert_eq!(shown["detected"]["reason"]["by"], "files"); | |
| 219 | + | assert_eq!(shown["links"]["homepage_inherited"], true); | |
| 220 | + | assert_eq!(shown["links"]["custom"][0]["label"], "Status"); | |
| 221 | + | assert_eq!(shown["pushed_at"], "2026-10-07T09:00:00.000Z"); | |
| 222 | + | // What is the service's own business stays there. | |
| 223 | + | assert!(shown.get("source").is_none()); | |
| 224 | + | assert!(shown.get("activity").is_none()); | |
| 225 | + | assert!(shown.get("created_by").is_none()); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn a_change_is_only_what_is_given_in_the_service_s_spelling() { | |
| 230 | + | let asked = json!({ | |
| 231 | + | "workspace": "flagon-io", "project": "g1t", | |
| 232 | + | "kind": "app", "runs": "elsewhere", "production_url": "https://g1t.sh", | |
| 233 | + | "root_dir": "apps/web", "docs_url": "docs.g1t.sh", | |
| 234 | + | "links": [{ "label": "Status", "url": "https://status.g1t.sh", "extra": 1 }], | |
| 235 | + | }); | |
| 236 | + | let sent = changes(&asked).unwrap(); | |
| 237 | + | assert_eq!( | |
| 238 | + | sent, | |
| 239 | + | json!({ | |
| 240 | + | "kind": "app", "runs": "elsewhere", "productionUrl": "https://g1t.sh", "rootDir": "apps/web", | |
| 241 | + | "docsUrl": "docs.g1t.sh", "links": [{ "label": "Status", "url": "https://status.g1t.sh" }], | |
| 242 | + | }) | |
| 243 | + | ); | |
| 244 | + | assert!(sent.get("workspace").is_none()); | |
| 245 | + | assert!(sent.get("name").is_none()); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn null_clears_what_it_can_and_absent_changes_nothing() { | |
| 250 | + | let sent = changes(&json!({ "description": null, "homepage": null, "production_url": null, "docs_url": null, "name": null, "kind": null })).unwrap(); | |
| 251 | + | assert_eq!(sent, json!({ "description": null, "homepage": null, "productionUrl": null, "docsUrl": null })); | |
| 252 | + | assert_eq!(changes(&json!({})).unwrap(), json!({})); | |
| 253 | + | assert_eq!(changes(&json!({ "links": [] })).unwrap(), json!({ "links": [] })); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[test] | |
| 257 | + | fn a_change_of_the_wrong_type_is_refused() { | |
| 258 | + | assert!(changes(&json!({ "kind": 3 })).is_err()); | |
| 259 | + | assert!(changes(&json!({ "links": "https://g1t.sh" })).is_err()); | |
| 260 | + | assert!(changes(&json!({ "links": [{ "url": "https://g1t.sh" }] })).is_err()); | |
| 261 | + | } | |
| 262 | + | } |
| 6317 | 6317 | ], | |
| 6318 | 6318 | "notes": "Name every pinned project once; anything else is refused with `422 invalid`." | |
| 6319 | 6319 | }, | |
| 6320 | + | "list_projects": { | |
| 6321 | + | "params": { | |
| 6322 | + | "workspace": "flagon-io" | |
| 6323 | + | }, | |
| 6324 | + | "response": [ | |
| 6325 | + | { | |
| 6326 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 6327 | + | "workspace": "flagon-io", | |
| 6328 | + | "slug": "g1t", | |
| 6329 | + | "name": "g1t", | |
| 6330 | + | "description": "Git hosting where people and agents work together.", | |
| 6331 | + | "description_inherited": true, | |
| 6332 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 6333 | + | "repository": "flagon-io/g1t", | |
| 6334 | + | "root_dir": "", | |
| 6335 | + | "default_branch": "main", | |
| 6336 | + | "private": false, | |
| 6337 | + | "archived": false, | |
| 6338 | + | "primary": true, | |
| 6339 | + | "kind": "app", | |
| 6340 | + | "kind_reason": { | |
| 6341 | + | "by": "set", | |
| 6342 | + | "detail": "Set to an app that runs elsewhere." | |
| 6343 | + | }, | |
| 6344 | + | "runs": "elsewhere", | |
| 6345 | + | "production_url": "https://g1t.sh", | |
| 6346 | + | "setting": { | |
| 6347 | + | "kind": "app", | |
| 6348 | + | "runs": "elsewhere" | |
| 6349 | + | }, | |
| 6350 | + | "detected": { | |
| 6351 | + | "kind": "app", | |
| 6352 | + | "reason": { | |
| 6353 | + | "by": "files", | |
| 6354 | + | "detail": "wrangler.jsonc at its root makes it an app." | |
| 6355 | + | } | |
| 6356 | + | }, | |
| 6357 | + | "ecosystem": null, | |
| 6358 | + | "links": { | |
| 6359 | + | "homepage": "https://g1t.sh", | |
| 6360 | + | "homepage_inherited": true, | |
| 6361 | + | "docs": "https://docs.g1t.sh", | |
| 6362 | + | "custom": [ | |
| 6363 | + | { | |
| 6364 | + | "label": "Status", | |
| 6365 | + | "url": "https://status.g1t.sh" | |
| 6366 | + | } | |
| 6367 | + | ] | |
| 6368 | + | }, | |
| 6369 | + | "created_at": "2026-09-01T10:00:00.000Z", | |
| 6370 | + | "updated_at": "2026-10-07T11:20:00.000Z", | |
| 6371 | + | "pushed_at": "2026-10-07T10:00:00.000Z" | |
| 6372 | + | }, | |
| 6373 | + | { | |
| 6374 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 6375 | + | "workspace": "flagon-io", | |
| 6376 | + | "slug": "hello", | |
| 6377 | + | "name": "hello", | |
| 6378 | + | "description": "Greets people from the command line.", | |
| 6379 | + | "description_inherited": false, | |
| 6380 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 6381 | + | "repository": "flagon-io/hello", | |
| 6382 | + | "root_dir": "", | |
| 6383 | + | "default_branch": "main", | |
| 6384 | + | "private": false, | |
| 6385 | + | "archived": false, | |
| 6386 | + | "primary": true, | |
| 6387 | + | "kind": "library", | |
| 6388 | + | "kind_reason": { | |
| 6389 | + | "by": "files", | |
| 6390 | + | "detail": "composer.json says it is a library." | |
| 6391 | + | }, | |
| 6392 | + | "runs": null, | |
| 6393 | + | "production_url": null, | |
| 6394 | + | "setting": { | |
| 6395 | + | "kind": null, | |
| 6396 | + | "runs": null | |
| 6397 | + | }, | |
| 6398 | + | "detected": { | |
| 6399 | + | "kind": "library", | |
| 6400 | + | "reason": { | |
| 6401 | + | "by": "files", | |
| 6402 | + | "detail": "composer.json says it is a library." | |
| 6403 | + | } | |
| 6404 | + | }, | |
| 6405 | + | "ecosystem": "composer", | |
| 6406 | + | "links": { | |
| 6407 | + | "homepage": null, | |
| 6408 | + | "homepage_inherited": false, | |
| 6409 | + | "docs": null, | |
| 6410 | + | "custom": [] | |
| 6411 | + | }, | |
| 6412 | + | "created_at": "2026-09-12T14:30:00.000Z", | |
| 6413 | + | "updated_at": "2026-10-01T09:12:00.000Z", | |
| 6414 | + | "pushed_at": null | |
| 6415 | + | } | |
| 6416 | + | ], | |
| 6417 | + | "notes": "By name. Projects whose repository you cannot see are left out; without a token you see public ones only. `kind` is what the project is, from `setting` where a person set it and from detection otherwise (`detected`, shown beside it); `kind_reason` says why. `runs` is null for what is not deployed. `homepage_inherited` is true while the homepage is its repository's website. `pushed_at` is null until its repository is pushed to. See [Projects](/guides/projects/)." | |
| 6418 | + | }, | |
| 6419 | + | "get_project": { | |
| 6420 | + | "params": { | |
| 6421 | + | "workspace": "flagon-io", | |
| 6422 | + | "project": "g1t" | |
| 6423 | + | }, | |
| 6424 | + | "response": { | |
| 6425 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 6426 | + | "workspace": "flagon-io", | |
| 6427 | + | "slug": "g1t", | |
| 6428 | + | "name": "g1t", | |
| 6429 | + | "description": "Git hosting where people and agents work together.", | |
| 6430 | + | "description_inherited": true, | |
| 6431 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 6432 | + | "repository": "flagon-io/g1t", | |
| 6433 | + | "root_dir": "", | |
| 6434 | + | "default_branch": "main", | |
| 6435 | + | "private": false, | |
| 6436 | + | "archived": false, | |
| 6437 | + | "primary": true, | |
| 6438 | + | "kind": "app", | |
| 6439 | + | "kind_reason": { | |
| 6440 | + | "by": "set", | |
| 6441 | + | "detail": "Set to an app that runs elsewhere." | |
| 6442 | + | }, | |
| 6443 | + | "runs": "elsewhere", | |
| 6444 | + | "production_url": "https://g1t.sh", | |
| 6445 | + | "setting": { | |
| 6446 | + | "kind": "app", | |
| 6447 | + | "runs": "elsewhere" | |
| 6448 | + | }, | |
| 6449 | + | "detected": { | |
| 6450 | + | "kind": "app", | |
| 6451 | + | "reason": { | |
| 6452 | + | "by": "files", | |
| 6453 | + | "detail": "wrangler.jsonc at its root makes it an app." | |
| 6454 | + | } | |
| 6455 | + | }, | |
| 6456 | + | "ecosystem": null, | |
| 6457 | + | "links": { | |
| 6458 | + | "homepage": "https://g1t.sh", | |
| 6459 | + | "homepage_inherited": true, | |
| 6460 | + | "docs": "https://docs.g1t.sh", | |
| 6461 | + | "custom": [ | |
| 6462 | + | { | |
| 6463 | + | "label": "Status", | |
| 6464 | + | "url": "https://status.g1t.sh" | |
| 6465 | + | } | |
| 6466 | + | ] | |
| 6467 | + | }, | |
| 6468 | + | "created_at": "2026-09-01T10:00:00.000Z", | |
| 6469 | + | "updated_at": "2026-10-07T11:20:00.000Z", | |
| 6470 | + | "pushed_at": "2026-10-07T10:00:00.000Z" | |
| 6471 | + | }, | |
| 6472 | + | "notes": "`404` for a project that does not exist or whose repository you cannot see. `setting` holds what a person set, each part null while it is left to detection. `ecosystem` is where a library's files say it is published (`composer`, `npm`, `cargo`, `go` or `python`), or null. See [What a project is](/guides/projects/#what-a-project-is)." | |
| 6473 | + | }, | |
| 6474 | + | "update_project": { | |
| 6475 | + | "params": { | |
| 6476 | + | "workspace": "flagon-io", | |
| 6477 | + | "project": "g1t" | |
| 6478 | + | }, | |
| 6479 | + | "request": { | |
| 6480 | + | "kind": "app", | |
| 6481 | + | "runs": "elsewhere", | |
| 6482 | + | "production_url": "https://g1t.sh", | |
| 6483 | + | "docs_url": "https://docs.g1t.sh", | |
| 6484 | + | "links": [ | |
| 6485 | + | { | |
| 6486 | + | "label": "Status", | |
| 6487 | + | "url": "https://status.g1t.sh" | |
| 6488 | + | } | |
| 6489 | + | ] | |
| 6490 | + | }, | |
| 6491 | + | "response": { | |
| 6492 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 6493 | + | "workspace": "flagon-io", | |
| 6494 | + | "slug": "g1t", | |
| 6495 | + | "name": "g1t", | |
| 6496 | + | "description": "Git hosting where people and agents work together.", | |
| 6497 | + | "description_inherited": true, | |
| 6498 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 6499 | + | "repository": "flagon-io/g1t", | |
| 6500 | + | "root_dir": "", | |
| 6501 | + | "default_branch": "main", | |
| 6502 | + | "private": false, | |
| 6503 | + | "archived": false, | |
| 6504 | + | "primary": true, | |
| 6505 | + | "kind": "app", | |
| 6506 | + | "kind_reason": { | |
| 6507 | + | "by": "set", | |
| 6508 | + | "detail": "Set to an app that runs elsewhere." | |
| 6509 | + | }, | |
| 6510 | + | "runs": "elsewhere", | |
| 6511 | + | "production_url": "https://g1t.sh", | |
| 6512 | + | "setting": { | |
| 6513 | + | "kind": "app", | |
| 6514 | + | "runs": "elsewhere" | |
| 6515 | + | }, | |
| 6516 | + | "detected": { | |
| 6517 | + | "kind": "app", | |
| 6518 | + | "reason": { | |
| 6519 | + | "by": "files", | |
| 6520 | + | "detail": "wrangler.jsonc at its root makes it an app." | |
| 6521 | + | } | |
| 6522 | + | }, | |
| 6523 | + | "ecosystem": null, | |
| 6524 | + | "links": { | |
| 6525 | + | "homepage": "https://g1t.sh", | |
| 6526 | + | "homepage_inherited": true, | |
| 6527 | + | "docs": "https://docs.g1t.sh", | |
| 6528 | + | "custom": [ | |
| 6529 | + | { | |
| 6530 | + | "label": "Status", | |
| 6531 | + | "url": "https://status.g1t.sh" | |
| 6532 | + | } | |
| 6533 | + | ] | |
| 6534 | + | }, | |
| 6535 | + | "created_at": "2026-09-01T10:00:00.000Z", | |
| 6536 | + | "updated_at": "2026-10-07T11:20:00.000Z", | |
| 6537 | + | "pushed_at": "2026-10-07T10:00:00.000Z" | |
| 6538 | + | }, | |
| 6539 | + | "notes": "Only the fields given change. `kind` and `runs` take `auto` to go back to detection. Setting `runs` makes the project an app unless it is docs. Making it a `library`, `tool` or `other` while [Deployments](/guides/deployments/) are on is refused with `409 conflict`: turn them off first. `description` and `homepage` given as null or `\"\"` follow the repository's again; `production_url` and `docs_url` given as null or `\"\"` are cleared. `links` replaces the project's other links: at most 10, each with a `label` of up to 40 characters and an http or https `url` (`https://` is added when you leave the scheme out); anything else is refused with `422 invalid`. Needs the Maintain role or higher on the project's repository. Recorded in the [audit log](/guides/audit-log/). See [Settings](/guides/projects/#settings)." | |
| 6540 | + | }, | |
| 6320 | 6541 | "list_secret_scanning_alerts": { | |
| 6321 | 6542 | "params": { | |
| 6322 | 6543 | "owner": "flagon-io", |
| 90 | 90 | route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]), | |
| 91 | 91 | route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]), | |
| 92 | 92 | route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]), | |
| 93 | + | // A workspace's projects: what each is, where it runs, its links. | |
| 94 | + | route("GET", "/workspaces/:workspace/projects", Op::ListProjects, &[]), | |
| 95 | + | route("GET", "/workspaces/:workspace/projects/:project", Op::GetProject, &[]), | |
| 96 | + | route("PATCH", "/workspaces/:workspace/projects/:project", Op::UpdateProject, &[]), | |
| 93 | 97 | route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]), | |
| 94 | 98 | route( | |
| 95 | 99 | "GET", |
| 275 | 275 | Tool { | |
| 276 | 276 | name: "workspace", | |
| 277 | 277 | title: "Workspaces", | |
| 278 | − | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, set rulesets that hold across its repositories, and keep your own pinned projects at the top of its sidebar.", | |
| 278 | + | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, set rulesets that hold across its repositories, read and change its projects (what each is, where it runs, its links), and keep your own pinned projects at the top of its sidebar.", | |
| 279 | 279 | default_action: None, | |
| 280 | 280 | actions: &[ | |
| 281 | 281 | a("get", Op::GetWorkspace, "A workspace's details and settings"), | |
| 291 | 291 | a("test_integration", Op::TestIntegration, "Check its credentials"), | |
| 292 | 292 | a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"), | |
| 293 | 293 | a("set_model_routes", Op::SetModelRoutes, "Replace them"), | |
| 294 | + | a("list_projects", Op::ListProjects, "Its projects you can see: what each is, where it runs, its links"), | |
| 295 | + | a("get_project", Op::GetProject, "One project"), | |
| 296 | + | a("update_project", Op::UpdateProject, "Change a project's name, description, kind, where it runs or its links"), | |
| 294 | 297 | a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"), | |
| 295 | 298 | a("pin_project", Op::PinProject, "Pin a project, at a position or the end"), | |
| 296 | 299 | a("unpin_project", Op::UnpinProject, "Unpin a project"), |
| 45 | 45 | nothing and costs nothing, and the next visit wakes it in milliseconds. | |
| 46 | 46 | ||
| 47 | 47 | Deployments are part of the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), | |
| 48 | − | and are opt-in per project. A project with deployments off says | |
| 49 | − | **Deployments are off** on its overview, with **Turn on deployments** for | |
| 48 | + | and are opt-in per project. A project set to deploy on g1t, with | |
| 49 | + | deployments off, says **Deployments are off** on its overview, with **Turn on deployments** for | |
| 50 | 50 | people with the Admin [role](/guides/access-and-roles/) on its repository. Nothing builds or runs until you turn them on, and one click turns | |
| 51 | 51 | them off again. | |
| 52 | 52 | ||
| 66 | 66 | Production starts building at once from the default branch. Every pull | |
| 67 | 67 | request opened or pushed to from then on gets a preview. | |
| 68 | 68 | ||
| 69 | − | A project g1t takes for a library or a tool, such as a Composer package, | |
| 70 | − | does not offer to deploy on its overview; it shows its packages instead. | |
| 71 | − | Its **Deployments** page still turns them on, and doing so makes it an | |
| 72 | − | app. A project set to **Doesn't deploy** cannot have deployments turned | |
| 73 | − | on until the setting changes. See | |
| 74 | − | [apps and libraries](/guides/projects/#apps-and-libraries). | |
| 69 | + | Deployments are for projects g1t runs. Not every project is one: a | |
| 70 | + | library, a tool or documentation is published rather than deployed, and | |
| 71 | + | an app may be deployed by its own pipeline somewhere else. Only a project | |
| 72 | + | that is [an app or site deployed on g1t](/guides/projects/#what-a-project-is) | |
| 73 | + | is asked to turn deployments on. Any other project's **Deployments** page | |
| 74 | + | still turns them on; doing so makes it an app deployed on g1t, including | |
| 75 | + | one that was deployed elsewhere. A project set to be a library, a tool or | |
| 76 | + | something else cannot have deployments turned on until that setting | |
| 77 | + | changes. | |
| 75 | 78 | ||
| 76 | 79 | While payments on g1t are in test mode, no real card is charged: use the | |
| 77 | 80 | test card `4242 4242 4242 4242` with any future date and any code. |
| 35 | 35 | ||
| 36 | 36 | | Page | Address | | | |
| 37 | 37 | | --- | --- | --- | | |
| 38 | − | | **Overview** | `g1t.sh/<workspace>/<project>` | Production with a screenshot, or for a library its packages; the steps left to get to production or to a first release; what is in progress, active branches, live previews, recent builds, and the source. See [the overview](#the-overview). | | |
| 38 | + | | **Overview** | `g1t.sh/<workspace>/<project>` | What it is and its links; production wherever it is deployed, or for a library its packages; the steps that apply to it; what is in progress, active branches, the latest commits, and its About. See [the overview](#the-overview). | | |
| 39 | 39 | | **Code** | `…/code` | The repository's files, commits and branches. | | |
| 40 | 40 | | **Issues**, **Pull requests**, **Merge queue**, **Plan** | `…/issues` and so on | As they always were. | | |
| 41 | 41 | | **Actions** | `…/actions` | [GitHub Actions workflows](/guides/actions/). | | |
| 53 | 53 | ||
| 54 | 54 | | Part | What it shows | | |
| 55 | 55 | | --- | --- | | |
| 56 | − | | **Production** | For an app: a screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. See [the production screenshot](/guides/deployments/#the-production-screenshot). | | |
| 57 | − | | **Packages** | For a [library or a tool](#apps-and-libraries), in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. | | |
| 58 | − | | **Get to production**, or **Ship a release** for a library | The checklists below, until every step is done or you dismiss it. | | |
| 56 | + | | **What it is** | A badge, such as **App, deployed elsewhere** or **Library**, beside its homepage and docs. People who can change its settings open the badge to change [what it is](#what-a-project-is) in one click. | | |
| 57 | + | | **Production** | For an app [deployed on g1t](/guides/deployments/): a screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. | | |
| 58 | + | | **Packages** | For a library or a tool, in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. | | |
| 59 | + | | **Documentation**, **Homepage** | For docs, where they are read; for anything else, its homepage. | | |
| 60 | + | | **Get to production**, **Ship a release** or **Get started** | The [checklist](#the-checklist) for what the project is, until every step is done or you dismiss it. | | |
| 59 | 61 | | **Right now** | Agents at work, and open pull requests moving from working to landed. | | |
| 60 | 62 | | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. | | |
| 61 | 63 | | **Active branches** | Branches other than the default, newest first. See [active branches](#active-branches). | | |
| 62 | − | | **Recent changes**, **Activity**, **Previews** | What landed, everything that happened, and the previews that are up. | | |
| 64 | + | | **Recent changes**, **Latest on main**, **Activity**, **Previews** | What landed, the default branch's latest commits, everything that happened, and the previews that are up. | | |
| 65 | + | | **About** | Its description, its [links](#links), what it is, and its latest release (its newest tag). People who can change its settings edit the description and links from here. | | |
| 63 | 66 | | **Health**, **Dependencies**, **Clone** | How often checks pass, recent builds and open issues by age; what it uses and what uses it; the clone address. | | |
| 64 | 67 | ||
| 68 | + | ### The checklist | |
| 69 | + | ||
| 70 | + | People with a role on its repository see a card that counts what the | |
| 71 | + | project has done, such as **3/5**. Its steps follow | |
| 72 | + | [what the project is](#what-a-project-is): only an app deployed on g1t is | |
| 73 | + | asked to deploy, add a domain or open a preview. Each step is worked out | |
| 74 | + | from the project itself, and each links to where you do it. The card goes | |
| 75 | + | away when every step is done. To hide it sooner, choose **×** on it. That | |
| 76 | + | hides it for this project in this browser only. | |
| 77 | + | ||
| 78 | + | | What it is | Card | Steps | | |
| 79 | + | | --- | --- | --- | | |
| 80 | + | | App or site, deployed on g1t | **Get to production** | [Below](#get-to-production). | | |
| 81 | + | | Library, tool | **Ship a release** | [Below](#ship-a-release). | | |
| 82 | + | | App, deployed elsewhere | **Get started** | Push code; add production's address; add checks on pull requests; repository instructions; a first issue for g1t. | | |
| 83 | + | | App nobody has placed yet | **Get started** | Push code; say where it runs; add checks on pull requests; repository instructions; a first issue for g1t. | | |
| 84 | + | | Documentation | **Get started** | Push code; add where its docs are read (a docs or homepage address); repository instructions; a first issue for g1t. | | |
| 85 | + | | Something else | **Get started** | Push code; add its links; repository instructions; a first issue for g1t. | | |
| 86 | + | ||
| 65 | 87 | ### Get to production | |
| 66 | 88 | ||
| 67 | − | People with a role on its repository see a card that counts what the project has done towards | |
| 68 | − | production, such as **3/6**. Each step is worked out from the project | |
| 69 | − | itself, and each links to where you do it: | |
| 89 | + | For an app or site deployed on g1t: | |
| 70 | 90 | ||
| 71 | 91 | | Step | Done when | Links to | | |
| 72 | 92 | | --- | --- | --- | | |
| 77 | 97 | | Set up repository instructions | `AGENTS.md` or `CLAUDE.md` is at the root of the default branch. | The instructions on the **Agents** page. See [repository instructions](/guides/working-with-g1t/#repository-instructions). | | |
| 78 | 98 | | Assign a first issue to g1t | g1t has had a run, a pull request or an issue here. | A new issue. | | |
| 79 | 99 | ||
| 80 | − | The card goes away when every step is done. To hide it sooner, choose | |
| 81 | − | **×** on it. That hides it for this project in this browser only. | |
| 82 | − | ||
| 83 | 100 | ### Ship a release | |
| 84 | 101 | ||
| 85 | − | A [library or a tool](#apps-and-libraries) does not deploy, so its card | |
| 86 | − | counts the steps to a first release instead: | |
| 102 | + | A library or a tool does not deploy, so its card counts the steps to a | |
| 103 | + | first release instead: | |
| 87 | 104 | ||
| 88 | 105 | | Step | Done when | Links to | | |
| 89 | 106 | | --- | --- | --- | | |
| 93 | 110 | | Set up repository instructions | As above. | The **Agents** page. | | |
| 94 | 111 | | Assign a first issue to g1t | As above. | A new issue. | | |
| 95 | 112 | ||
| 96 | − | ## Apps and libraries | |
| 113 | + | ## What a project is | |
| 97 | 114 | ||
| 98 | − | Every project is either an **app**, which deploys, or a **library or a | |
| 99 | − | tool**, which is published and installed. An app's overview shows | |
| 100 | − | production and the steps to get there; a library's shows its packages | |
| 101 | − | and the steps to a first release, and never offers to turn on | |
| 102 | − | deployments. Its **Deployments** page stays in the sidebar either way. | |
| 115 | + | A project is one of these, and its overview follows: | |
| 103 | 116 | ||
| 104 | − | g1t works it out for itself, in this order: | |
| 117 | + | | What it is | Its overview shows | | |
| 118 | + | | --- | --- | | |
| 119 | + | | **App or site, deployed on g1t** | Production on g1t.page, with previews and domains, and **Get to production**. | | |
| 120 | + | | **App or site, deployed elsewhere** | Production at the address you give, deployed by your own pipeline, with **Visit**. It is never asked to turn on Deployments; Deployment settings stay one link away. | | |
| 121 | + | | **Library or package** | The packages its repository publishes and how to install them, and **Ship a release**. | | |
| 122 | + | | **Tool or CLI** | The same as a library: its packages and releases. | | |
| 123 | + | | **Documentation** | Where its docs are read. Docs can be published on g1t.page too, by turning on Deployments. | | |
| 124 | + | | **Something else** | Its homepage and links. | | |
| 105 | 125 | ||
| 106 | − | 1. If [Deployments](/guides/deployments/) are on for the project, it is an app. | |
| 126 | + | Its **Deployments** page stays in the sidebar whatever it is. | |
| 127 | + | ||
| 128 | + | ### How g1t works it out | |
| 129 | + | ||
| 130 | + | Until you say, g1t works it out for itself, in this order: | |
| 131 | + | ||
| 132 | + | 1. If [Deployments](/guides/deployments/) are on for the project, it is | |
| 133 | + | an app deployed on g1t. | |
| 107 | 134 | 2. If its repository publishes a package other than a container image, | |
| 108 | 135 | such as a [Composer](/guides/composer/) or [npm](/guides/npm/) | |
| 109 | 136 | package, it is a library. | |
| 110 | 137 | 3. If the files at the root of its default branch (or of its root | |
| 111 | − | directory) say it is a library, it is one: | |
| 138 | + | directory) say what it is, it is that: | |
| 112 | 139 | ||
| 113 | − | | File | A library when | | |
| 140 | + | | File | Says | | |
| 114 | 141 | | --- | --- | | |
| 115 | − | | `composer.json` | Its `type` is anything but `project`, such as `library`; or it has no `type`, has `autoload`, and has no `public/index.php`. | | |
| 116 | − | | `Cargo.toml` | It builds a library (`[lib]` or `src/lib.rs`) and no binary (`[[bin]]` or `src/main.rs`). | | |
| 117 | − | | `go.mod` | No `.go` file at the root is `package main`. | | |
| 118 | − | | `pyproject.toml` | It has a build backend and depends on no app framework, such as Django, Flask or FastAPI. | | |
| 119 | − | | `package.json` | It has `main`, `exports`, `module`, `files` or `bin`, no `start` or `dev` script, and no app framework such as Next.js, Astro, Nuxt, Remix or SvelteKit. | | |
| 142 | + | | `wrangler.jsonc`, `wrangler.json` or `wrangler.toml` | An app. | | |
| 143 | + | | `mkdocs.yml`, `book.toml`, `docusaurus.config.js` (or `.ts`, `.mjs`) or `antora.yml` | Documentation. | | |
| 144 | + | | `composer.json` | A library when its `type` is anything but `project`, such as `library`; or it has no `type`, has `autoload`, and has no `public/index.php`. | | |
| 145 | + | | `Cargo.toml` | A library when it builds a library (`[lib]` or `src/lib.rs`) and no binary (`[[bin]]` or `src/main.rs`). | | |
| 146 | + | | `go.mod` | A library when no `.go` file at the root is `package main`. | | |
| 147 | + | | `pyproject.toml` | A library when it has a build backend and depends on no app framework, such as Django, Flask or FastAPI. | | |
| 148 | + | | `package.json` | A tool when it has `bin` and nothing to import (no `main`, `module` or `exports`); a library when it has `main`, `exports`, `module`, `files` or `bin`; either way only with no `start` or `dev` script and no app framework such as Next.js, Astro, Nuxt, Remix or SvelteKit. | | |
| 120 | 149 | ||
| 121 | 150 | The language's own manifest is read before `package.json`, which many | |
| 122 | − | projects carry only for tooling. A Workers config (`wrangler.jsonc`, | |
| 123 | − | `wrangler.json` or `wrangler.toml`) or an `index.html` at the root | |
| 124 | − | makes it an app. | |
| 151 | + | projects carry only for tooling. An `index.html` at the root makes it | |
| 152 | + | an app. | |
| 125 | 153 | 4. Anything else is an app. | |
| 126 | 154 | ||
| 127 | 155 | The files are read again on every push to the default branch. | |
| 128 | 156 | ||
| 129 | − | To decide for yourself, open **Settings**, then **General**, and under | |
| 130 | − | **Deployments for this project** choose: | |
| 157 | + | An app found this way runs on g1t only while Deployments are on. Until | |
| 158 | + | you say where it runs, its overview asks, in place of production: | |
| 159 | + | **Deploy on g1t**, **It's deployed elsewhere** (with production's | |
| 160 | + | address), or **It isn't deployed** (a library, a tool, documentation or | |
| 161 | + | something else). | |
| 162 | + | ||
| 163 | + | ### Choosing it yourself | |
| 164 | + | ||
| 165 | + | From the overview, open the badge beside its name, such as **App**, and | |
| 166 | + | choose one. Or open **Settings**, then **General**, and under **What it | |
| 167 | + | is** choose **Detect automatically**, which shows what was detected and | |
| 168 | + | why, or one of the kinds above. **App or site, deployed elsewhere** asks | |
| 169 | + | for production's address. | |
| 170 | + | ||
| 171 | + | Choosing a library, a tool or something else while Deployments are on is | |
| 172 | + | refused: turn them off first under **Settings**, then **Deployments**; | |
| 173 | + | g1t does not turn them off for you. While it is set that way, Deployments | |
| 174 | + | cannot be turned on. Turning Deployments on for an app deployed elsewhere | |
| 175 | + | makes it an app deployed on g1t. | |
| 131 | 176 | ||
| 132 | − | - **Detect automatically**: the rules above. It shows what was detected | |
| 133 | − | and why. | |
| 134 | − | - **Deploys**: an app, whatever its files say. | |
| 135 | − | - **Doesn't deploy**: a library or a tool. Its overview offers no | |
| 136 | − | deploying. If Deployments are on for it, turn them off first under | |
| 137 | − | **Settings**, then **Deployments**; g1t does not turn them off for you. | |
| 138 | − | While it is set this way, Deployments cannot be turned on. | |
| 177 | + | Changing what it is needs the role that changes the project's settings. | |
| 139 | 178 | ||
| 140 | 179 | ### Active branches | |
| 141 | 180 | ||
| 152 | 191 | are read, those with open pull requests first; a project with more says | |
| 153 | 192 | how many it has. | |
| 154 | 193 | ||
| 194 | + | ## Links | |
| 195 | + | ||
| 196 | + | A project has a **homepage**, a **docs** address, and up to 10 other | |
| 197 | + | links, each a label and an address, such as a status page or its listing | |
| 198 | + | in a registry. They show in its overview's About and beside what it is, | |
| 199 | + | on its card on its workspace's page, in its workspace's Projects, and on | |
| 200 | + | Explore for a public project. | |
| 201 | + | ||
| 202 | + | To set them, open **Settings**, then **General**, and fill in **Links**; | |
| 203 | + | or choose the pencil on the overview's **About**. Each is an http or | |
| 204 | + | https address; `https://` is added when you leave it out. A label left | |
| 205 | + | empty is the address's host name. Removing every row of other links | |
| 206 | + | clears them. | |
| 207 | + | ||
| 208 | + | A repository's own project shows the repository's website as its | |
| 209 | + | homepage until you give the project one of its own. For an app deployed | |
| 210 | + | on g1t, its g1t.page address still shows as production; set the homepage | |
| 211 | + | to production's own domain if you have one. | |
| 212 | + | ||
| 155 | 213 | ## Settings | |
| 156 | 214 | ||
| 157 | 215 | A project's **Settings** has a tab for each part: | |
| 158 | 216 | ||
| 159 | 217 | | Tab | What it holds | | |
| 160 | 218 | | --- | --- | | |
| 161 | − | | **General** | The project's name and description, its source, its **root directory**, and whether it deploys (see [apps and libraries](#apps-and-libraries)). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. | | |
| 219 | + | | **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), and its [links](#links). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. | | |
| 162 | 220 | | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). | | |
| 163 | 221 | | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). | | |
| 164 | 222 | | **Dependencies** | The projects this one uses, and the ones that use it. See [Dependencies](#dependencies). | | |
| 259 | 317 | Projects keep their repository's routes: `/repos/{workspace}/{project}/…` | |
| 260 | 318 | reaches the project's repository, and its secrets and variables are the | |
| 261 | 319 | project's. See the [API reference](/reference/api/). | |
| 320 | + | ||
| 321 | + | What a project is, where it runs and its links have routes of their own, | |
| 322 | + | and actions on the [MCP](/reference/mcp/) `workspace` tool: | |
| 323 | + | ||
| 324 | + | | Route | MCP action | | | |
| 325 | + | | --- | --- | --- | | |
| 326 | + | | `GET /workspaces/{workspace}/projects` | `list_projects` | The workspace's projects you can see. | | |
| 327 | + | | `GET /workspaces/{workspace}/projects/{project}` | `get_project` | One project, with `kind`, `runs`, `production_url`, `setting`, `detected` and `links`. | | |
| 328 | + | | `PATCH /workspaces/{workspace}/projects/{project}` | `update_project` | Changes `kind`, `runs`, `production_url`, `homepage`, `docs_url`, `links`, or its name, description or root directory. Needs `repo:write`. | | |
| 329 | + | ||
| 330 | + | To say an app is deployed by your own pipeline, with its docs: | |
| 331 | + | ||
| 332 | + | ```sh | |
| 333 | + | curl -X PATCH https://api.g1t.sh/workspaces/acme/projects/web \ | |
| 334 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 335 | + | -H "Content-Type: application/json" \ | |
| 336 | + | -d '{"runs": "elsewhere", "production_url": "https://acme.dev", "docs_url": "https://docs.acme.dev"}' | |
| 337 | + | ``` |
| 347 | 347 | - **Find a project** matches every word you type in a project's name, its | |
| 348 | 348 | address or its description. Press <kbd>/</kbd> anywhere on the page to | |
| 349 | 349 | start typing. | |
| 350 | − | - **Filters**: public or private; apps or libraries; the language its | |
| 350 | + | - **Filters**: public or private; [what it is](/guides/projects/#what-a-project-is) | |
| 351 | + | (apps, libraries, and tools, docs or other when the workspace has any); the language its | |
| 351 | 352 | manifests say it is written in; only projects with | |
| 352 | 353 | [Deployments](/guides/deployments/) on; and archived projects, which are | |
| 353 | 354 | left out unless you ask for them. Each choice shows how many projects it |
| 533 | 533 | ## `workspace` | |
| 534 | 534 | ||
| 535 | 535 | Workspaces own repositories: create, update or delete one, invite members, | |
| 536 | − | connect [integrations](/guides/integrations/) and model providers, and keep | |
| 537 | − | your own [pinned projects](/guides/workspaces/#pinned-and-recent-projects) | |
| 538 | − | at the top of its sidebar. See [workspaces](/guides/workspaces/). | |
| 536 | + | connect [integrations](/guides/integrations/) and model providers, read and | |
| 537 | + | change its [projects](/guides/projects/) (what each is, where it runs, its | |
| 538 | + | links), and keep your own | |
| 539 | + | [pinned projects](/guides/workspaces/#pinned-and-recent-projects) at the top | |
| 540 | + | of its sidebar. See [workspaces](/guides/workspaces/). | |
| 539 | 541 | ||
| 540 | 542 | | Action | What it does | Required | Scope | | |
| 541 | 543 | | --- | --- | --- | --- | | |
| 552 | 554 | | [`test_integration`](/reference/api/integrations/test-integration/) | Check its credentials against the system it connects to. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 553 | 555 | | [`get_model_routes`](/reference/api/integrations/get-model-routes/) | Which provider and model each kind of work goes to. Members only. | `workspace` | `workspace:read` | | |
| 554 | 556 | | [`set_model_routes`](/reference/api/integrations/set-model-routes/) | Replace them: each route has `task`, `connection_id` (null for g1t's models) and `model` (on g1t's models: `small`, `large`, `frontier`, or null for Auto). Owners only. | `workspace`, `routes` | `workspace:admin` | | |
| 557 | + | | [`list_projects`](/reference/api/projects/list-projects/) | Its projects you can see, by name: each with its `kind` and `kind_reason`, where it `runs` and its `production_url`, its repository and `root_dir`, and its `links`. | `workspace` | `repo:read` | | |
| 558 | + | | [`get_project`](/reference/api/projects/get-project/) | One project, with what a person set (`setting`) and what detection decides (`detected`). | `workspace`, `project` | `repo:read` | | |
| 559 | + | | [`update_project`](/reference/api/projects/update-project/) | Change its `name`, `description`, `root_dir`, `kind`, `runs`, `production_url`, `homepage`, `docs_url` or `links`; only what you give changes. `auto` leaves `kind` or `runs` to detection, null clears a link or goes back to the repository's, and `links` replaces its other links (at most 10). Maintain role or higher. | `workspace`, `project` | `repo:write` | | |
| 555 | 560 | | [`list_pinned_projects`](/reference/api/pinned-projects/list-pinned-projects/) | Your pinned projects in it, in your order, each with its `position`. Your own: a personal token or an OAuth sign-in. | `workspace` | `account:read` | | |
| 556 | 561 | | [`pin_project`](/reference/api/pinned-projects/pin-project/) | Pin a project you can see, at `position` (0 first) or at the end; at most 8 a workspace. Returns your pins. | `workspace`, `project` | `account:write` | | |
| 557 | 562 | | [`unpin_project`](/reference/api/pinned-projects/unpin-project/) | Unpin it. Returns your pins. | `workspace`, `project` | `account:write` | |
Binary or large file; its contents are not shown.
| 736 | 736 | <Section | |
| 737 | 737 | id="reset" | |
| 738 | 738 | title="Reset billing (testing)" | |
| 739 | − | description="Wipes this workspace's billing: its ledger and balance, plan, limits, trial, invoices, holds, signals and cost rows. The workspace, its members and its repositories stay. Only while billing is on Stripe's test key." | |
| 739 | + | description="Wipes this workspace's billing: its ledger and balance, plan, limits, trial, invoices, holds, signals and cost rows. What its usage cost g1t is kept, and counted on Costs & margin as given away (testing resets). The workspace, its members and its repositories stay. Only while billing is on Stripe's test key." | |
| 740 | 740 | > | |
| 741 | 741 | <form method="post" action={`${pathname}#reset`} className="space-y-4"> | |
| 742 | 742 | <input type="hidden" name="intent" value="reset" /> |
| 217 | 217 | ["partial discounts", o.givenDiscountMicros ?? 0], | |
| 218 | 218 | ["promotional credit", o.givenCreditPromotionalMicros ?? 0], | |
| 219 | 219 | ["goodwill credit", o.givenCreditGoodwillMicros ?? 0], | |
| 220 | + | ["testing resets", o.givenResetMicros ?? 0], | |
| 220 | 221 | ].filter(([, micros]) => (micros as number) > 0) as [string, number][]; | |
| 221 | 222 | const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [ | |
| 222 | 223 | { |
| 1 | + | import { BookOpen, Check, ChevronDown, Globe, Link2, Pencil, Plus, Rocket, X } from "lucide-react"; | |
| 2 | + | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | + | import { useFetcher } from "react-router"; | |
| 4 | + | ||
| 5 | + | import { MAX_PROJECT_LINKS, type Project, type ProjectLinks } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import { cn } from "../lib/cn"; | |
| 8 | + | import { KIND_CHOICES, type KindChoice, type ShownLink, choiceOf, linksToShow } from "../lib/project-kind"; | |
| 9 | + | import { Input, SubmitButton } from "./ui"; | |
| 10 | + | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 11 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 12 | + | import { Hint } from "./ui/hint"; | |
| 13 | + | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 14 | + | ||
| 15 | + | const LINK_ICON: Record<ShownLink["type"], ReactNode> = { | |
| 16 | + | homepage: <Globe size={14} />, | |
| 17 | + | docs: <BookOpen size={14} />, | |
| 18 | + | production: <Rocket size={14} />, | |
| 19 | + | custom: <Link2 size={14} />, | |
| 20 | + | }; | |
| 21 | + | ||
| 22 | + | /** | |
| 23 | + | * A project's homepage, docs and other links, one to a line, as its About | |
| 24 | + | * shows them. Addresses already on the page are given in `shown` and left out. | |
| 25 | + | */ | |
| 26 | + | export function LinkList({ links, shown = [], className }: { links: ProjectLinks; shown?: (string | null | undefined)[]; className?: string }) { | |
| 27 | + | const list = linksToShow(links, shown); | |
| 28 | + | if (list.length === 0) return null; | |
| 29 | + | return ( | |
| 30 | + | <ul className={cn("space-y-1.5 text-sm", className)}> | |
| 31 | + | {list.map((link) => ( | |
| 32 | + | <li key={link.key} className="min-w-0"> | |
| 33 | + | <a | |
| 34 | + | href={link.url} | |
| 35 | + | rel="noopener noreferrer nofollow" | |
| 36 | + | className="flex min-w-0 items-center gap-2 text-fg-soft hover:text-accent [&_svg]:shrink-0 [&_svg]:text-faint" | |
| 37 | + | > | |
| 38 | + | {LINK_ICON[link.type]} | |
| 39 | + | <span className="truncate">{link.label}</span> | |
| 40 | + | </a> | |
| 41 | + | </li> | |
| 42 | + | ))} | |
| 43 | + | </ul> | |
| 44 | + | ); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | type Row = { id: number; label: string; url: string }; | |
| 48 | + | let nextRow = 1; | |
| 49 | + | const rowsOf = (links: ProjectLinks["custom"]): Row[] => links.map((link) => ({ id: nextRow++, ...link })); | |
| 50 | + | ||
| 51 | + | /** | |
| 52 | + | * The fields for a project's links: its homepage, its docs, and rows of | |
| 53 | + | * others, each a label and an address, added and removed in place. Posts | |
| 54 | + | * `homepage`, `docsUrl`, and `linkLabel`/`linkUrl` per row. | |
| 55 | + | */ | |
| 56 | + | export function LinkFields({ links, compact = false }: { links: ProjectLinks; compact?: boolean }) { | |
| 57 | + | const [rows, setRows] = useState<Row[]>(() => rowsOf(links.custom)); | |
| 58 | + | const label = "mb-1.5 block text-sm font-medium text-muted"; | |
| 59 | + | return ( | |
| 60 | + | <div className="space-y-4"> | |
| 61 | + | {/* Says the rows below are the whole list, so removing every row clears them. */} | |
| 62 | + | <input type="hidden" name="links" value="rows" /> | |
| 63 | + | <div className={cn("grid gap-4", !compact && "md:grid-cols-2")}> | |
| 64 | + | <label className="block"> | |
| 65 | + | <span className={label}>Homepage</span> | |
| 66 | + | <Input | |
| 67 | + | name="homepage" | |
| 68 | + | type="text" | |
| 69 | + | inputMode="url" | |
| 70 | + | defaultValue={links.homepageInherited ? "" : (links.homepage ?? "")} | |
| 71 | + | placeholder={(links.homepageInherited && links.homepage) || "https://example.com"} | |
| 72 | + | maxLength={255} | |
| 73 | + | /> | |
| 74 | + | <span className="mt-1.5 block text-xs text-faint"> | |
| 75 | + | {links.homepageInherited ? "Follows the repository's website. Give one to change it here." : "Its site, or production's domain."} | |
| 76 | + | </span> | |
| 77 | + | </label> | |
| 78 | + | <label className="block"> | |
| 79 | + | <span className={label}>Docs</span> | |
| 80 | + | <Input name="docsUrl" type="text" inputMode="url" defaultValue={links.docs ?? ""} placeholder="https://docs.example.com" maxLength={255} /> | |
| 81 | + | <span className="mt-1.5 block text-xs text-faint">Where its documentation is read.</span> | |
| 82 | + | </label> | |
| 83 | + | </div> | |
| 84 | + | <fieldset> | |
| 85 | + | <legend className={label}>Other links</legend> | |
| 86 | + | {rows.length > 0 && ( | |
| 87 | + | <ul className="space-y-2"> | |
| 88 | + | {rows.map((row, index) => ( | |
| 89 | + | <li key={row.id} className="flex items-center gap-2"> | |
| 90 | + | <div className="w-32 shrink-0 sm:w-40"> | |
| 91 | + | <Input name="linkLabel" defaultValue={row.label} placeholder="Label" maxLength={40} aria-label={`Link ${index + 1} label`} /> | |
| 92 | + | </div> | |
| 93 | + | <div className="min-w-0 grow"> | |
| 94 | + | <Input | |
| 95 | + | name="linkUrl" | |
| 96 | + | type="text" | |
| 97 | + | inputMode="url" | |
| 98 | + | defaultValue={row.url} | |
| 99 | + | placeholder="https://" | |
| 100 | + | maxLength={255} | |
| 101 | + | aria-label={`Link ${index + 1} address`} | |
| 102 | + | /> | |
| 103 | + | </div> | |
| 104 | + | <Hint label="Remove this link"> | |
| 105 | + | <button | |
| 106 | + | type="button" | |
| 107 | + | onClick={() => setRows((current) => current.filter((one) => one.id !== row.id))} | |
| 108 | + | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 109 | + | aria-label={`Remove link ${index + 1}`} | |
| 110 | + | > | |
| 111 | + | <X size={14} /> | |
| 112 | + | </button> | |
| 113 | + | </Hint> | |
| 114 | + | </li> | |
| 115 | + | ))} | |
| 116 | + | </ul> | |
| 117 | + | )} | |
| 118 | + | {rows.length < MAX_PROJECT_LINKS ? ( | |
| 119 | + | <button | |
| 120 | + | type="button" | |
| 121 | + | onClick={() => setRows((current) => [...current, { id: nextRow++, label: "", url: "" }])} | |
| 122 | + | className={cn("inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg", rows.length > 0 && "mt-2")} | |
| 123 | + | > | |
| 124 | + | <Plus size={13} /> | |
| 125 | + | Add a link | |
| 126 | + | </button> | |
| 127 | + | ) : ( | |
| 128 | + | <p className="mt-2 text-xs text-faint">{MAX_PROJECT_LINKS} links is the most a project keeps.</p> | |
| 129 | + | )} | |
| 130 | + | <p className="mt-1.5 text-xs text-faint">A status page, a registry listing, a chat: anything with an http or https address.</p> | |
| 131 | + | </fieldset> | |
| 132 | + | </div> | |
| 133 | + | ); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /** | |
| 137 | + | * What a project is, as a choice of one, with production's address beside | |
| 138 | + | * Deployed elsewhere. Posts `choice` and `productionUrl`. `blocked` says | |
| 139 | + | * why a choice that stops it deploying cannot be saved yet. | |
| 140 | + | */ | |
| 141 | + | export function KindFields({ | |
| 142 | + | project, | |
| 143 | + | choice, | |
| 144 | + | onChoice, | |
| 145 | + | }: { | |
| 146 | + | project: Pick<Project, "detected" | "productionUrl" | "setting">; | |
| 147 | + | choice: KindChoice | null; | |
| 148 | + | onChoice: (choice: KindChoice) => void; | |
| 149 | + | }) { | |
| 150 | + | return ( | |
| 151 | + | <RadioGroup name="choice" value={choice ?? ""} onValueChange={(value) => onChoice(value as KindChoice)} aria-label="What it is" className="gap-3"> | |
| 152 | + | {KIND_CHOICES.map((option) => ( | |
| 153 | + | <div key={option.value}> | |
| 154 | + | <RadioOption | |
| 155 | + | value={option.value} | |
| 156 | + | label={option.label} | |
| 157 | + | description={ | |
| 158 | + | option.value === "auto" ? ( | |
| 159 | + | <> | |
| 160 | + | Detected: {DETECTED[project.detected.kind]}. {project.detected.reason.detail} | |
| 161 | + | </> | |
| 162 | + | ) : ( | |
| 163 | + | option.hint | |
| 164 | + | ) | |
| 165 | + | } | |
| 166 | + | /> | |
| 167 | + | {option.value === "elsewhere" && choice === "elsewhere" && ( | |
| 168 | + | <div className="mt-2 ml-6.5 max-w-md"> | |
| 169 | + | <Input | |
| 170 | + | name="productionUrl" | |
| 171 | + | type="text" | |
| 172 | + | inputMode="url" | |
| 173 | + | defaultValue={project.productionUrl ?? ""} | |
| 174 | + | placeholder="https://example.com" | |
| 175 | + | aria-label="Production's address" | |
| 176 | + | maxLength={255} | |
| 177 | + | /> | |
| 178 | + | <span className="mt-1 block text-xs text-faint">Production's address, shown on its overview and wherever it is listed.</span> | |
| 179 | + | </div> | |
| 180 | + | )} | |
| 181 | + | </div> | |
| 182 | + | ))} | |
| 183 | + | </RadioGroup> | |
| 184 | + | ); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | const DETECTED: Record<Project["kind"], string> = { | |
| 188 | + | app: "an app", | |
| 189 | + | library: "a library", | |
| 190 | + | tool: "a tool", | |
| 191 | + | docs: "documentation", | |
| 192 | + | other: "something else", | |
| 193 | + | }; | |
| 194 | + | ||
| 195 | + | /** The one-click choices the overview offers, without Detect, in order. */ | |
| 196 | + | const MENU: { value: KindChoice; label: string }[] = [ | |
| 197 | + | { value: "g1t", label: "Deployed on g1t" }, | |
| 198 | + | { value: "elsewhere", label: "Deployed elsewhere" }, | |
| 199 | + | { value: "library", label: "Library or package" }, | |
| 200 | + | { value: "tool", label: "Tool or CLI" }, | |
| 201 | + | { value: "docs", label: "Documentation" }, | |
| 202 | + | { value: "other", label: "Something else" }, | |
| 203 | + | ]; | |
| 204 | + | ||
| 205 | + | /** | |
| 206 | + | * The badge of what a project is, which a person who may change its | |
| 207 | + | * settings opens to change it in one click. Posts `intent=kind` and | |
| 208 | + | * `choice` to the page's action. | |
| 209 | + | */ | |
| 210 | + | export function KindMenu({ project, label, canChange }: { project: Pick<Project, "setting" | "detected" | "kindReason">; label: string; canChange: boolean }) { | |
| 211 | + | const fetcher = useFetcher<{ error?: string }>(); | |
| 212 | + | const current = choiceOf(project.setting); | |
| 213 | + | const badge = "inline-flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs font-medium text-fg-soft ring-1 ring-line"; | |
| 214 | + | if (!canChange) { | |
| 215 | + | return ( | |
| 216 | + | <Hint label={project.kindReason.detail}> | |
| 217 | + | <span className={badge}>{label}</span> | |
| 218 | + | </Hint> | |
| 219 | + | ); | |
| 220 | + | } | |
| 221 | + | const choose = (choice: KindChoice) => fetcher.submit({ intent: "kind", choice }, { method: "post" }); | |
| 222 | + | return ( | |
| 223 | + | <span className="inline-flex items-center gap-2"> | |
| 224 | + | <DropdownMenu> | |
| 225 | + | <DropdownMenuTrigger className={cn(badge, "transition-colors hover:ring-line-strong", fetcher.state !== "idle" && "opacity-60")}> | |
| 226 | + | {label} | |
| 227 | + | <ChevronDown size={12} className="text-faint" /> | |
| 228 | + | </DropdownMenuTrigger> | |
| 229 | + | <DropdownMenuContent align="start" className="w-64"> | |
| 230 | + | <DropdownMenuLabel>{project.kindReason.detail}</DropdownMenuLabel> | |
| 231 | + | <DropdownMenuSeparator /> | |
| 232 | + | {MENU.map((option) => ( | |
| 233 | + | <DropdownMenuItem key={option.value} onSelect={() => choose(option.value)}> | |
| 234 | + | <span className="grow">{option.label}</span> | |
| 235 | + | {current === option.value && <Check size={14} />} | |
| 236 | + | </DropdownMenuItem> | |
| 237 | + | ))} | |
| 238 | + | <DropdownMenuSeparator /> | |
| 239 | + | <DropdownMenuItem onSelect={() => choose("auto")}> | |
| 240 | + | <span className="grow">Detect automatically</span> | |
| 241 | + | {current === "auto" && <Check size={14} />} | |
| 242 | + | </DropdownMenuItem> | |
| 243 | + | </DropdownMenuContent> | |
| 244 | + | </DropdownMenu> | |
| 245 | + | {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>} | |
| 246 | + | </span> | |
| 247 | + | ); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /** | |
| 251 | + | * The About editor: a project's description and links, from its overview. | |
| 252 | + | * Posts `intent=about` to the page's action, and closes once saved. | |
| 253 | + | */ | |
| 254 | + | export function AboutEditor({ project }: { project: Pick<Project, "description" | "descriptionInherited" | "links" | "name"> }) { | |
| 255 | + | const fetcher = useFetcher<{ error?: string; notice?: string }>(); | |
| 256 | + | const [open, setOpen] = useState(false); | |
| 257 | + | const saved = fetcher.state === "idle" && fetcher.data != null && !fetcher.data.error; | |
| 258 | + | useEffect(() => { | |
| 259 | + | if (saved) setOpen(false); | |
| 260 | + | }, [saved, fetcher.data]); | |
| 261 | + | return ( | |
| 262 | + | <Dialog open={open} onOpenChange={setOpen}> | |
| 263 | + | <Hint label="Edit the description and links"> | |
| 264 | + | <DialogTrigger className="rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg" aria-label="Edit About"> | |
| 265 | + | <Pencil size={14} /> | |
| 266 | + | </DialogTrigger> | |
| 267 | + | </Hint> | |
| 268 | + | <DialogContent className="max-w-xl"> | |
| 269 | + | <DialogHeader> | |
| 270 | + | <DialogTitle>Edit {project.name}'s About</DialogTitle> | |
| 271 | + | <DialogDescription>Its description and links show here, on its cards, and in its workspace's Projects.</DialogDescription> | |
| 272 | + | </DialogHeader> | |
| 273 | + | <fetcher.Form method="post" className="space-y-5"> | |
| 274 | + | <input type="hidden" name="intent" value="about" /> | |
| 275 | + | <label className="block"> | |
| 276 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Description</span> | |
| 277 | + | <Input | |
| 278 | + | name="description" | |
| 279 | + | defaultValue={project.descriptionInherited ? "" : (project.description ?? "")} | |
| 280 | + | placeholder={(project.descriptionInherited && project.description) || "What it is, in a line"} | |
| 281 | + | maxLength={200} | |
| 282 | + | /> | |
| 283 | + | {project.descriptionInherited && <span className="mt-1.5 block text-xs text-faint">Follows the repository's description while empty.</span>} | |
| 284 | + | </label> | |
| 285 | + | <LinkFields links={project.links} compact /> | |
| 286 | + | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 287 | + | <DialogFooter> | |
| 288 | + | <SubmitButton fetcher={fetcher} pending="Saving…"> | |
| 289 | + | Save | |
| 290 | + | </SubmitButton> | |
| 291 | + | </DialogFooter> | |
| 292 | + | </fetcher.Form> | |
| 293 | + | </DialogContent> | |
| 294 | + | </Dialog> | |
| 295 | + | ); | |
| 296 | + | } |
| 1 | + | /** | |
| 2 | + | * The top of a project's overview for what g1t does not deploy: an app | |
| 3 | + | * deployed elsewhere, an app nobody has said where it runs, docs, and | |
| 4 | + | * anything else. An app on g1t keeps its production card, and a library | |
| 5 | + | * or a tool its packages (routes/repo/overview.tsx). | |
| 6 | + | */ | |
| 7 | + | import { ArrowUpRight, BookOpen, Box, ChevronDown, GitCommitHorizontal, Globe, Rocket } from "lucide-react"; | |
| 8 | + | import { type ReactNode, useState } from "react"; | |
| 9 | + | import { Link, useFetcher } from "react-router"; | |
| 10 | + | ||
| 11 | + | import type { DeployStatus, Project } from "@g1t/contracts"; | |
| 12 | + | ||
| 13 | + | import { host, StatusDot } from "./deploy"; | |
| 14 | + | import { ButtonLink, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 15 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 16 | + | import { Hint } from "./ui/hint"; | |
| 17 | + | ||
| 18 | + | /** | |
| 19 | + | * A deployment reported to g1t from outside it, such as by a pipeline that | |
| 20 | + | * deploys production itself. When there is one for production, the card | |
| 21 | + | * shows it. | |
| 22 | + | */ | |
| 23 | + | export type ExternalDeployment = { | |
| 24 | + | environment: string; | |
| 25 | + | url: string | null; | |
| 26 | + | /** success, failure, error, pending, queued, in_progress or inactive. */ | |
| 27 | + | state: string; | |
| 28 | + | sha: string; | |
| 29 | + | created_at: string; | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | /** A reported deployment's state, as g1t's own deployments show theirs. */ | |
| 33 | + | export function deploymentStatus(state: string): DeployStatus { | |
| 34 | + | switch (state) { | |
| 35 | + | case "success": | |
| 36 | + | return "ready"; | |
| 37 | + | case "failure": | |
| 38 | + | case "error": | |
| 39 | + | return "failed"; | |
| 40 | + | case "in_progress": | |
| 41 | + | return "building"; | |
| 42 | + | case "inactive": | |
| 43 | + | return "replaced"; | |
| 44 | + | default: | |
| 45 | + | return "queued"; | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | type Head = { | |
| 50 | + | project: Project; | |
| 51 | + | base: string; | |
| 52 | + | /** May change the project's settings: what it is, its links. */ | |
| 53 | + | canChange: boolean; | |
| 54 | + | /** May turn on Deployments. */ | |
| 55 | + | canDeploy: boolean; | |
| 56 | + | }; | |
| 57 | + | ||
| 58 | + | export function HeadLabel({ icon, children }: { icon: ReactNode; children: ReactNode }) { | |
| 59 | + | return ( | |
| 60 | + | <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase [&_svg]:size-3.25 [&_svg]:text-accent"> | |
| 61 | + | {icon} | |
| 62 | + | {children} | |
| 63 | + | </p> | |
| 64 | + | ); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | function BigLink({ url }: { url: string }) { | |
| 68 | + | return ( | |
| 69 | + | <a href={url} rel="noopener noreferrer" className="mt-2 flex min-w-0 items-center gap-1.5 font-mono text-lg font-medium hover:text-accent"> | |
| 70 | + | <span className="truncate">{host(url).replace(/\/$/, "")}</span> | |
| 71 | + | <ArrowUpRight size={16} className="shrink-0 text-faint" /> | |
| 72 | + | </a> | |
| 73 | + | ); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** An address, saved for the project in place: production's, or its docs'. */ | |
| 77 | + | function AddressForm({ | |
| 78 | + | field, | |
| 79 | + | placeholder, | |
| 80 | + | label, | |
| 81 | + | extra, | |
| 82 | + | }: { | |
| 83 | + | field: "productionUrl" | "docsUrl"; | |
| 84 | + | placeholder: string; | |
| 85 | + | label: string; | |
| 86 | + | /** Hidden fields sent with it. */ | |
| 87 | + | extra: Record<string, string>; | |
| 88 | + | }) { | |
| 89 | + | const fetcher = useFetcher<{ error?: string }>(); | |
| 90 | + | return ( | |
| 91 | + | <fetcher.Form method="post" className="mt-3 max-w-lg"> | |
| 92 | + | {Object.entries(extra).map(([name, value]) => ( | |
| 93 | + | <input key={name} type="hidden" name={name} value={value} /> | |
| 94 | + | ))} | |
| 95 | + | <div className="flex items-center gap-2"> | |
| 96 | + | <div className="min-w-0 grow"> | |
| 97 | + | <Input name={field} type="text" inputMode="url" required placeholder={placeholder} aria-label={label} maxLength={255} /> | |
| 98 | + | </div> | |
| 99 | + | <SubmitButton fetcher={fetcher} variant="quiet" pending="Saving…"> | |
| 100 | + | Save | |
| 101 | + | </SubmitButton> | |
| 102 | + | </div> | |
| 103 | + | {fetcher.data?.error && <p className="mt-1.5 text-xs text-danger">{fetcher.data.error}</p>} | |
| 104 | + | </fetcher.Form> | |
| 105 | + | ); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /** Deploying on g1t, offered as an option and never as a step. */ | |
| 109 | + | function DeployQuietly({ base, canDeploy, what }: { base: string; canDeploy: boolean; what: string }) { | |
| 110 | + | if (!canDeploy) return null; | |
| 111 | + | return ( | |
| 112 | + | <p className="mt-4 text-xs text-faint"> | |
| 113 | + | g1t can deploy {what} on g1t.page instead, with a preview for every pull request.{" "} | |
| 114 | + | <Link to={`${base}/settings/deployments`} className="underline-offset-4 hover:text-fg hover:underline"> | |
| 115 | + | Deployment settings | |
| 116 | + | </Link> | |
| 117 | + | </p> | |
| 118 | + | ); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /** | |
| 122 | + | * An app deployed by its own pipeline: production at the address it was | |
| 123 | + | * given, with the latest reported deployment, or else the default branch's | |
| 124 | + | * latest commit and its checks. | |
| 125 | + | */ | |
| 126 | + | export function ElsewhereHead({ | |
| 127 | + | project, | |
| 128 | + | base, | |
| 129 | + | canChange, | |
| 130 | + | canDeploy, | |
| 131 | + | deployment, | |
| 132 | + | commit, | |
| 133 | + | checks, | |
| 134 | + | }: Head & { | |
| 135 | + | deployment?: ExternalDeployment | null; | |
| 136 | + | commit: { hash: string; authoredAt: string } | null; | |
| 137 | + | /** The commit's checks, as a badge; left out until there is one to show. */ | |
| 138 | + | checks?: ReactNode; | |
| 139 | + | }) { | |
| 140 | + | const url = deployment?.url ?? project.productionUrl; | |
| 141 | + | return ( | |
| 142 | + | <div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between sm:p-6"> | |
| 143 | + | <div className="min-w-0"> | |
| 144 | + | <HeadLabel icon={<Rocket />}>Production</HeadLabel> | |
| 145 | + | {url ? ( | |
| 146 | + | <> | |
| 147 | + | <BigLink url={url} /> | |
| 148 | + | <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"> | |
| 149 | + | {deployment ? ( | |
| 150 | + | <> | |
| 151 | + | <StatusDot status={deploymentStatus(deployment.state)} /> | |
| 152 | + | <span className="inline-flex items-center gap-1 font-mono"> | |
| 153 | + | <GitCommitHorizontal size={13} className="text-faint" /> | |
| 154 | + | {deployment.sha.slice(0, 7)} | |
| 155 | + | </span> | |
| 156 | + | <span> | |
| 157 | + | deployed <TimeAgo at={deployment.created_at} /> | |
| 158 | + | </span> | |
| 159 | + | </> | |
| 160 | + | ) : ( | |
| 161 | + | <> | |
| 162 | + | <span>Deployed by its own pipeline</span> | |
| 163 | + | {commit && ( | |
| 164 | + | <span className="inline-flex items-center gap-1.5"> | |
| 165 | + | <span className="text-faint">·</span> | |
| 166 | + | <Link to={`${base}/commit/${commit.hash}`} className="inline-flex items-center gap-1 font-mono hover:text-fg"> | |
| 167 | + | <GitCommitHorizontal size={13} className="text-faint" /> | |
| 168 | + | {commit.hash.slice(0, 7)} | |
| 169 | + | </Link> | |
| 170 | + | {checks} | |
| 171 | + | <TimeAgo at={commit.authoredAt} /> | |
| 172 | + | </span> | |
| 173 | + | )} | |
| 174 | + | </> | |
| 175 | + | )} | |
| 176 | + | </p> | |
| 177 | + | </> | |
| 178 | + | ) : canChange ? ( | |
| 179 | + | <> | |
| 180 | + | <p className="mt-2 text-lg font-medium">Where is production?</p> | |
| 181 | + | <p className="mt-1 max-w-lg text-sm text-muted"> | |
| 182 | + | {project.name} is deployed by its own pipeline. Give production's address, and this card links to it. | |
| 183 | + | </p> | |
| 184 | + | <AddressForm field="productionUrl" placeholder="https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} /> | |
| 185 | + | </> | |
| 186 | + | ) : ( | |
| 187 | + | <p className="mt-2 text-sm text-muted">Deployed by its own pipeline.</p> | |
| 188 | + | )} | |
| 189 | + | <DeployQuietly base={base} canDeploy={canDeploy} what="it" /> | |
| 190 | + | </div> | |
| 191 | + | {url && ( | |
| 192 | + | <div className="flex shrink-0 items-center gap-2"> | |
| 193 | + | <ButtonLink to={url} variant="accent" reloadDocument> | |
| 194 | + | Visit | |
| 195 | + | <ArrowUpRight size={14} /> | |
| 196 | + | </ButtonLink> | |
| 197 | + | </div> | |
| 198 | + | )} | |
| 199 | + | </div> | |
| 200 | + | ); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | const NOT_DEPLOYED: { value: string; label: string }[] = [ | |
| 204 | + | { value: "library", label: "A library or package" }, | |
| 205 | + | { value: "tool", label: "A tool or CLI" }, | |
| 206 | + | { value: "docs", label: "Documentation" }, | |
| 207 | + | { value: "other", label: "Something else" }, | |
| 208 | + | ]; | |
| 209 | + | ||
| 210 | + | /** | |
| 211 | + | * An app that nobody has said where it runs, with Deployments off: one | |
| 212 | + | * question, answered in place, instead of a push to turn Deployments on. | |
| 213 | + | */ | |
| 214 | + | export function WhereItRuns({ project, base, canChange, canDeploy }: Head) { | |
| 215 | + | const fetcher = useFetcher<{ error?: string }>(); | |
| 216 | + | const [elsewhere, setElsewhere] = useState(false); | |
| 217 | + | if (!canChange) { | |
| 218 | + | return ( | |
| 219 | + | <div className="p-5 sm:p-6"> | |
| 220 | + | <HeadLabel icon={<Globe />}>Homepage</HeadLabel> | |
| 221 | + | {project.links.homepage ? <BigLink url={project.links.homepage} /> : <p className="mt-2 text-sm text-muted">No homepage given.</p>} | |
| 222 | + | </div> | |
| 223 | + | ); | |
| 224 | + | } | |
| 225 | + | return ( | |
| 226 | + | <div className="p-5 sm:p-6"> | |
| 227 | + | <HeadLabel icon={<Rocket />}>Production</HeadLabel> | |
| 228 | + | <p className="mt-2 text-lg font-medium">Where does {project.name} run?</p> | |
| 229 | + | <p className="mt-1 max-w-xl text-sm text-muted"> | |
| 230 | + | Say once, and this page follows: production where it is deployed, or its packages and releases if it isn't | |
| 231 | + | deployed at all. <span className="text-faint">{project.detected.reason.detail}</span> | |
| 232 | + | </p> | |
| 233 | + | <div className="mt-4 flex flex-wrap items-center gap-2"> | |
| 234 | + | {canDeploy && ( | |
| 235 | + | <ButtonLink to={`${base}/settings/deployments`} variant="quiet"> | |
| 236 | + | <Rocket size={14} /> | |
| 237 | + | Deploy on g1t | |
| 238 | + | </ButtonLink> | |
| 239 | + | )} | |
| 240 | + | <button | |
| 241 | + | type="button" | |
| 242 | + | onClick={() => setElsewhere((open) => !open)} | |
| 243 | + | aria-expanded={elsewhere} | |
| 244 | + | className="inline-flex items-center justify-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg" | |
| 245 | + | > | |
| 246 | + | <Globe size={14} /> | |
| 247 | + | It's deployed elsewhere | |
| 248 | + | </button> | |
| 249 | + | <DropdownMenu> | |
| 250 | + | <DropdownMenuTrigger className="inline-flex items-center justify-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg"> | |
| 251 | + | <Box size={14} /> | |
| 252 | + | It isn't deployed | |
| 253 | + | <ChevronDown size={13} className="text-faint" /> | |
| 254 | + | </DropdownMenuTrigger> | |
| 255 | + | <DropdownMenuContent align="start"> | |
| 256 | + | {NOT_DEPLOYED.map((option) => ( | |
| 257 | + | <DropdownMenuItem key={option.value} onSelect={() => fetcher.submit({ intent: "kind", choice: option.value }, { method: "post" })}> | |
| 258 | + | {option.label} | |
| 259 | + | </DropdownMenuItem> | |
| 260 | + | ))} | |
| 261 | + | </DropdownMenuContent> | |
| 262 | + | </DropdownMenu> | |
| 263 | + | </div> | |
| 264 | + | {elsewhere && ( | |
| 265 | + | <AddressForm field="productionUrl" placeholder="Production's address, such as https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} /> | |
| 266 | + | )} | |
| 267 | + | {fetcher.data?.error && <p className="mt-2 text-xs text-danger">{fetcher.data.error}</p>} | |
| 268 | + | </div> | |
| 269 | + | ); | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /** Documentation: where it is read, or a place to say so. */ | |
| 273 | + | export function DocsHead({ project, base, canChange, canDeploy }: Head) { | |
| 274 | + | const url = project.links.docs ?? project.productionUrl ?? project.links.homepage; | |
| 275 | + | return ( | |
| 276 | + | <div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between sm:p-6"> | |
| 277 | + | <div className="min-w-0"> | |
| 278 | + | <HeadLabel icon={<BookOpen />}>Documentation</HeadLabel> | |
| 279 | + | {url ? ( | |
| 280 | + | <BigLink url={url} /> | |
| 281 | + | ) : canChange ? ( | |
| 282 | + | <> | |
| 283 | + | <p className="mt-2 text-lg font-medium">Where are its docs read?</p> | |
| 284 | + | <p className="mt-1 max-w-lg text-sm text-muted">Give the address, and this card and its listing link to it.</p> | |
| 285 | + | <AddressForm field="docsUrl" placeholder="https://docs.example.com" label="Where its docs are read" extra={{ intent: "about" }} /> | |
| 286 | + | </> | |
| 287 | + | ) : ( | |
| 288 | + | <p className="mt-2 text-sm text-muted">Its docs are in its repository.</p> | |
| 289 | + | )} | |
| 290 | + | <DeployQuietly base={base} canDeploy={canDeploy && project.runs == null} what="its docs as a site" /> | |
| 291 | + | </div> | |
| 292 | + | {url && ( | |
| 293 | + | <div className="flex shrink-0 items-center gap-2"> | |
| 294 | + | <ButtonLink to={url} variant="accent" reloadDocument> | |
| 295 | + | Read | |
| 296 | + | <ArrowUpRight size={14} /> | |
| 297 | + | </ButtonLink> | |
| 298 | + | </div> | |
| 299 | + | )} | |
| 300 | + | </div> | |
| 301 | + | ); | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | /** Anything else: its homepage, or where to add its links. */ | |
| 305 | + | export function OtherHead({ project, base, canChange }: Omit<Head, "canDeploy">) { | |
| 306 | + | const url = project.links.homepage ?? project.links.docs ?? project.links.custom[0]?.url ?? null; | |
| 307 | + | return ( | |
| 308 | + | <div className="p-5 sm:p-6"> | |
| 309 | + | <HeadLabel icon={<Globe />}>Homepage</HeadLabel> | |
| 310 | + | {url ? ( | |
| 311 | + | <BigLink url={url} /> | |
| 312 | + | ) : canChange ? ( | |
| 313 | + | <p className="mt-2 text-sm text-muted"> | |
| 314 | + | No links yet.{" "} | |
| 315 | + | <Link to={`${base}/settings#links`} className="text-accent hover:underline"> | |
| 316 | + | Add a homepage, docs or others | |
| 317 | + | </Link>{" "} | |
| 318 | + | and they show here, on its card and in its workspace's Projects. | |
| 319 | + | </p> | |
| 320 | + | ) : ( | |
| 321 | + | <p className="mt-2 text-sm text-muted">No homepage given.</p> | |
| 322 | + | )} | |
| 323 | + | </div> | |
| 324 | + | ); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /** Production as g1t serves it, with the project's homepage when that is somewhere else. */ | |
| 328 | + | export function AlsoAt({ url }: { url: string | null }) { | |
| 329 | + | if (!url) return null; | |
| 330 | + | return ( | |
| 331 | + | <Hint label="Its homepage"> | |
| 332 | + | <a href={url} rel="noopener noreferrer" className="inline-flex items-center gap-1 text-faint hover:text-fg"> | |
| 333 | + | <Globe size={12} /> | |
| 334 | + | {host(url).replace(/\/$/, "")} | |
| 335 | + | </a> | |
| 336 | + | </Hint> | |
| 337 | + | ); | |
| 338 | + | } |
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.