Skip to content

Commit

Merge branch 'projects-kind-and-links'

syntaqxcommitted Parentsc6bd895c4677caBrowse files
39 files+1498−560/39 viewed
+1−0
1414 mod oauth;
1515 mod openapi;
1616 mod pins;
17+mod projects;
1718 mod operations;
1819 mod renamed;
1920 #[cfg(test)]
+8−0
4646 &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects],
4747 ),
4848 (
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+ (
4954 "Workspaces",
5055 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
5156 &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace],
538543 Op::PinProject => "Pin a project",
539544 Op::UnpinProject => "Unpin a project",
540545 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",
541549 Op::ListTeams => "List teams",
542550 Op::GetTeam => "Get a team",
543551 Op::CreateTeam => "Create a team",
+73−1
238238 PinProject,
239239 UnpinProject,
240240 ReorderPinnedProjects,
241+ ListProjects,
242+ GetProject,
243+ UpdateProject,
241244 ListTeams,
242245 GetTeam,
243246 CreateTeam,
629632 }
630633
631634 impl Op {
632− pub const ALL: [Op; 219] = [
635+ pub const ALL: [Op; 222] = [
633636 Op::Whoami,
634637 Op::GetWorkspace,
635638 Op::CreateWorkspace,
780783 Op::PinProject,
781784 Op::UnpinProject,
782785 Op::ReorderPinnedProjects,
786+ Op::ListProjects,
787+ Op::GetProject,
788+ Op::UpdateProject,
783789 Op::ListTeams,
784790 Op::GetTeam,
785791 Op::CreateTeam,
10081014 Op::PinProject => "pin_project",
10091015 Op::UnpinProject => "unpin_project",
10101016 Op::ReorderPinnedProjects => "reorder_pinned_projects",
1017+ Op::ListProjects => "list_projects",
1018+ Op::GetProject => "get_project",
1019+ Op::UpdateProject => "update_project",
10111020 Op::ListTeams => "list_teams",
10121021 Op::GetTeam => "get_team",
10131022 Op::CreateTeam => "create_team",
14561465 Op::ReorderPinnedProjects => {
14571466 "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."
14581467 }
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+ }
14591477 Op::ListTeams => {
14601478 "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."
14611479 }
26772695 }),
26782696 &["workspace", "projects"],
26792697 ),
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+ ),
26802742 Op::ListTeams => object(
26812743 json!({
26822744 "workspace": workspace_schema(),
28682930 | Op::ListCheckNames
28692931 | Op::GetMergeQueue
28702932 | Op::GetCodeownersErrors
2933+ | Op::ListProjects
2934+ | Op::GetProject
28712935 | Op::Rules(RulesOp::ListRepoRulesets | RulesOp::GetRepoRuleset | RulesOp::GetBranchRules)
28722936 )
28732937 }
29563020 | Op::PinProject
29573021 | Op::UnpinProject
29583022 | Op::ReorderPinnedProjects
3023+ | Op::ListProjects
3024+ | Op::GetProject
3025+ | Op::UpdateProject
29593026 | Op::ListTeams
29603027 | Op::GetTeam
29613028 | Op::CreateTeam
48444911 Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => {
48454912 crate::pins::run(self, services, viewer, input).await
48464913 }
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+ }
48474919 // The security suite: the security service decides, this gives
48484920 // each answer its public shape.
48494921 Op::Security(op) => crate::security::run(op, services, viewer, input).await,
+262−0
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+}
+221−0
63176317 ],
63186318 "notes": "Name every pinned project once; anything else is refused with `422 invalid`."
63196319 },
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+ },
63206541 "list_secret_scanning_alerts": {
63216542 "params": {
63226543 "owner": "flagon-io",
+4−0
9090 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
9191 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
9292 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, &[]),
9397 route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]),
9498 route(
9599 "GET",
+4−1
275275 Tool {
276276 name: "workspace",
277277 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.",
279279 default_action: None,
280280 actions: &[
281281 a("get", Op::GetWorkspace, "A workspace's details and settings"),
291291 a("test_integration", Op::TestIntegration, "Check its credentials"),
292292 a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"),
293293 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"),
294297 a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"),
295298 a("pin_project", Op::PinProject, "Pin a project, at a position or the end"),
296299 a("unpin_project", Op::UnpinProject, "Unpin a project"),
+11−8
4545 nothing and costs nothing, and the next visit wakes it in milliseconds.
4646
4747 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
5050 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
5151 them off again.
5252
6666 Production starts building at once from the default branch. Every pull
6767 request opened or pushed to from then on gets a preview.
6868
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.
7578
7679 While payments on g1t are in test mode, no real card is charged: use the
7780 test card `4242 4242 4242 4242` with any future date and any code.
+117−41
3535
3636 | Page | Address | |
3737 | --- | --- | --- |
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). |
3939 | **Code** | `…/code` | The repository's files, commits and branches. |
4040 | **Issues**, **Pull requests**, **Merge queue**, **Plan** | `…/issues` and so on | As they always were. |
4141 | **Actions** | `…/actions` | [GitHub Actions workflows](/guides/actions/). |
5353
5454 | Part | What it shows |
5555 | --- | --- |
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. |
5961 | **Right now** | Agents at work, and open pull requests moving from working to landed. |
6062 | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. |
6163 | **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. |
6366 | **Health**, **Dependencies**, **Clone** | How often checks pass, recent builds and open issues by age; what it uses and what uses it; the clone address. |
6467
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+
6587 ### Get to production
6688
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:
7090
7191 | Step | Done when | Links to |
7292 | --- | --- | --- |
7797 | 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). |
7898 | Assign a first issue to g1t | g1t has had a run, a pull request or an issue here. | A new issue. |
7999
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−
83100 ### Ship a release
84101
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:
87104
88105 | Step | Done when | Links to |
89106 | --- | --- | --- |
93110 | Set up repository instructions | As above. | The **Agents** page. |
94111 | Assign a first issue to g1t | As above. | A new issue. |
95112
96−## Apps and libraries
113+## What a project is
97114
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:
103116
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. |
105125
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.
107134 2. If its repository publishes a package other than a container image,
108135 such as a [Composer](/guides/composer/) or [npm](/guides/npm/)
109136 package, it is a library.
110137 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:
112139
113− | File | A library when |
140+ | File | Says |
114141 | --- | --- |
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. |
120149
121150 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.
125153 4. Anything else is an app.
126154
127155 The files are read again on every push to the default branch.
128156
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.
131176
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.
139178
140179 ### Active branches
141180
152191 are read, those with open pull requests first; a project with more says
153192 how many it has.
154193
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+
155213 ## Settings
156214
157215 A project's **Settings** has a tab for each part:
158216
159217 | Tab | What it holds |
160218 | --- | --- |
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. |
162220 | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). |
163221 | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). |
164222 | **Dependencies** | The projects this one uses, and the ones that use it. See [Dependencies](#dependencies). |
259317 Projects keep their repository's routes: `/repos/{workspace}/{project}/…`
260318 reaches the project's repository, and its secrets and variables are the
261319 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+```
+2−1
347347 - **Find a project** matches every word you type in a project's name, its
348348 address or its description. Press <kbd>/</kbd> anywhere on the page to
349349 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
351352 manifests say it is written in; only projects with
352353 [Deployments](/guides/deployments/) on; and archived projects, which are
353354 left out unless you ask for them. Each choice shows how many projects it
+8−3
533533 ## `workspace`
534534
535535 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/).
539541
540542 | Action | What it does | Required | Scope |
541543 | --- | --- | --- | --- |
552554 | [`test_integration`](/reference/api/integrations/test-integration/) | Check its credentials against the system it connects to. Owners only. | `workspace`, `id` | `workspace:admin` |
553555 | [`get_model_routes`](/reference/api/integrations/get-model-routes/) | Which provider and model each kind of work goes to. Members only. | `workspace` | `workspace:read` |
554556 | [`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` |
555560 | [`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` |
556561 | [`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` |
557562 | [`unpin_project`](/reference/api/pinned-projects/unpin-project/) | Unpin it. Returns your pins. | `workspace`, `project` | `account:write` |
+0−0

Binary or large file; its contents are not shown.

+296−0
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+}
+338−0
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+}
+52−0
44 import {
55 type ChecklistFacts,
66 agentWasAssigned,
7+ checklistPlan,
78 dismiss,
89 dismissKey,
910 hasInstructions,
1112 productionChecklist,
1213 progress,
1314 releaseChecklist,
15+ startChecklist,
1416 } from "./checklist.ts";
1517
1618 const fresh: ChecklistFacts = {
120122 // An unknown workflow list counts as not done.
121123 assert.equal(releaseChecklist({ ...facts, hasWorkflow: null }).find((item) => item.key === "checks")?.done, false);
122124 });
125+
126+const start = {
127+ base: "/flagon-io/g1t",
128+ hasCode: true,
129+ instructions: true,
130+ agentAssigned: false,
131+ hasWorkflow: true,
132+ productionUrl: null,
133+ hasLinks: false,
134+ hasDocsLink: false,
135+};
136+
137+test("only what g1t deploys gets production's steps", () => {
138+ assert.deepEqual(checklistPlan({ kind: "app", runs: "g1t" }), { plan: "production", title: "Get to production" });
139+ assert.deepEqual(checklistPlan({ kind: "docs", runs: "g1t" }).plan, "production");
140+ assert.deepEqual(checklistPlan({ kind: "app", runs: "elsewhere" }), { plan: "elsewhere", title: "Get started" });
141+ assert.equal(checklistPlan({ kind: "app", runs: null }).plan, "unknown");
142+ assert.deepEqual(checklistPlan({ kind: "tool", runs: null }), { plan: "release", title: "Ship a release" });
143+ assert.equal(checklistPlan({ kind: "library", runs: null }).plan, "release");
144+ assert.equal(checklistPlan({ kind: "docs", runs: "elsewhere" }).plan, "docs");
145+ assert.equal(checklistPlan({ kind: "other", runs: null }).plan, "other");
146+});
147+
148+test("an app deployed elsewhere is never asked to deploy on g1t", () => {
149+ const items = startChecklist("elsewhere", start);
150+ assert.deepEqual(
151+ items.map((item) => item.key),
152+ ["code", "production", "checks", "instructions", "agent"],
153+ );
154+ assert.ok(items.every((item) => !/deploy to|turn on|domain|preview/i.test(`${item.title} ${item.detail}`)));
155+ assert.equal(items.find((item) => item.key === "production")?.done, false);
156+ assert.equal(startChecklist("elsewhere", { ...start, productionUrl: "https://g1t.sh" }).find((item) => item.key === "production")?.done, true);
157+ // Every step can be done.
158+ const all = startChecklist("elsewhere", { ...start, productionUrl: "https://g1t.sh", agentAssigned: true });
159+ assert.equal(progress(all).complete, true);
160+});
161+
162+test("an app nobody has placed asks where it runs; docs and other ask for links", () => {
163+ assert.deepEqual(
164+ startChecklist("unknown", start).map((item) => item.key),
165+ ["code", "where", "checks", "instructions", "agent"],
166+ );
167+ assert.equal(startChecklist("unknown", start).find((item) => item.key === "where")?.to, "/flagon-io/g1t/settings#kind");
168+ const docs = startChecklist("docs", { ...start, hasDocsLink: true });
169+ assert.deepEqual(docs.map((item) => item.key), ["code", "links", "instructions", "agent"]);
170+ assert.equal(docs.find((item) => item.key === "links")?.done, true);
171+ const other = startChecklist("other", start);
172+ assert.equal(other.find((item) => item.key === "links")?.done, false);
173+ assert.equal(startChecklist("other", { ...start, hasLinks: true, agentAssigned: true }).every((item) => item.done), true);
174+});
+101−1
2525 };
2626
2727 export type ChecklistItem = {
28− key: "code" | "deploy" | "domain" | "preview" | "checks" | "release" | "instructions" | "agent";
28+ key: "code" | "deploy" | "domain" | "preview" | "checks" | "release" | "production" | "where" | "links" | "instructions" | "agent";
2929 title: string;
3030 detail: string;
3131 done: boolean;
129129 ];
130130 }
131131
132+export type StartFacts = Pick<ChecklistFacts, "base" | "hasCode" | "instructions" | "agentAssigned"> & {
133+ /** It has a workflow, whose runs are its pull requests' checks; null when unknown. */
134+ hasWorkflow: boolean | null;
135+ /** Production's address, for an app deployed elsewhere. */
136+ productionUrl: string | null;
137+ /** Its homepage, docs or any other link is set. */
138+ hasLinks: boolean;
139+ /** Its docs address is set, or its homepage. */
140+ hasDocsLink: boolean;
141+};
142+
143+/** Where a project's own settings are, for the steps that are done there. */
144+const settingsAt = (base: string, anchor: string) => `${base}/settings#${anchor}`;
145+
146+/**
147+ * The steps for a project g1t does not deploy, by what it is. Every step
148+ * applies to it and each is done from its own fact: nothing about turning
149+ * on Deployments, domains or previews.
150+ *
151+ * - An app deployed elsewhere: its production address, then checks.
152+ * - An app nobody has said where it runs: saying so, then checks.
153+ * - Docs: where they are read.
154+ * - Anything else: its links.
155+ */
156+export function startChecklist(kind: "elsewhere" | "unknown" | "docs" | "other", facts: StartFacts): ChecklistItem[] {
157+ const shared = productionChecklist({ ...facts, deploysEnabled: false, productionDeployed: false, domains: null, previewOpened: false });
158+ const step = (key: ChecklistItem["key"]) => shared.find((item) => item.key === key)!;
159+ const checks: ChecklistItem = {
160+ key: "checks",
161+ title: "Add checks on pull requests",
162+ detail: "A workflow that builds and tests it. Its runs are every pull request's checks.",
163+ done: facts.hasWorkflow === true,
164+ to: `${facts.base}/actions`,
165+ action: "Add CI",
166+ };
167+ const middle: ChecklistItem[] =
168+ kind === "elsewhere"
169+ ? [
170+ {
171+ key: "production",
172+ title: "Add production's address",
173+ detail: "Where your own pipeline deploys it, so its overview links to production.",
174+ done: facts.productionUrl != null,
175+ to: settingsAt(facts.base, "kind"),
176+ action: "Add",
177+ },
178+ checks,
179+ ]
180+ : kind === "unknown"
181+ ? [
182+ {
183+ key: "where",
184+ title: "Say where it runs",
185+ detail: "Deployed on g1t, deployed elsewhere, or not deployed at all, such as a library.",
186+ done: false,
187+ to: settingsAt(facts.base, "kind"),
188+ action: "Choose",
189+ },
190+ checks,
191+ ]
192+ : kind === "docs"
193+ ? [
194+ {
195+ key: "links",
196+ title: "Add where its docs are read",
197+ detail: "A docs or homepage address, shown on its overview and wherever the project is listed.",
198+ done: facts.hasDocsLink,
199+ to: settingsAt(facts.base, "links"),
200+ action: "Add",
201+ },
202+ ]
203+ : [
204+ {
205+ key: "links",
206+ title: "Add its links",
207+ detail: "A homepage, docs, or any other address people go to for it.",
208+ done: facts.hasLinks,
209+ to: settingsAt(facts.base, "links"),
210+ action: "Add",
211+ },
212+ ];
213+ return [step("code"), ...middle, step("instructions"), step("agent")];
214+}
215+
216+export type ChecklistPlan = "production" | "release" | "elsewhere" | "unknown" | "docs" | "other";
217+
218+/**
219+ * Which steps a project gets, and their heading, from what it is and where
220+ * it runs. Only what g1t deploys gets production's steps.
221+ */
222+export function checklistPlan(project: {
223+ kind: "app" | "library" | "tool" | "docs" | "other";
224+ runs: "g1t" | "elsewhere" | null;
225+}): { plan: ChecklistPlan; title: string } {
226+ if (project.runs === "g1t") return { plan: "production", title: "Get to production" };
227+ if (project.kind === "library" || project.kind === "tool") return { plan: "release", title: "Ship a release" };
228+ if (project.kind === "app") return { plan: project.runs === "elsewhere" ? "elsewhere" : "unknown", title: "Get started" };
229+ return { plan: project.kind, title: "Get started" };
230+}
231+
132232 /** `3/6`, for the card's heading. */
133233 export function progress(items: ChecklistItem[]): { done: number; total: number; complete: boolean } {
134234 const done = items.filter((item) => item.done).length;
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.