Skip to content

Commit

API: pinned projects over REST and MCP

Four operations, a person's own like the inbox: list_pinned_projects, pin_project (at a position, or the end), unpin_project and reorder_pinned_projects. REST at GET and PUT /user/pinned_projects/ {workspace} and PUT and DELETE /user/pinned_projects/{workspace}/{project}; MCP as four actions of the workspace tool. account:read and account:write gate them; workspace and agent tokens are refused, g1t's agents never use them, and they are not audited. Responses are snake_case pins with their position and address. The API reaches the projects service through a new PROJECTS service binding. OpenAPI copy and reference examples updated.

syntaqxcommitted Parent7e6396cBrowse files
13 files+472−120/13 viewed
+1−0
1212 mod notifications;
1313 mod oauth;
1414 mod openapi;
15+mod pins;
1516 mod operations;
1617 mod renamed;
1718 #[cfg(test)]
+9−0
3939 ],
4040 ),
4141 (
42+ "Pinned projects",
43+ "The projects you keep at the top of a workspace's sidebar, in your order, up to eight a workspace. Your own: personal tokens and sessions only.",
44+ &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects],
45+ ),
46+ (
4247 "Workspaces",
4348 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
4449 &[Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace],
373378 Op::SetRepoSubscription => "Watch a repository",
374379 Op::DeleteRepoSubscription => "Stop watching a repository",
375380 Op::ListWatchedRepos => "List repositories you watch",
381+ Op::ListPinnedProjects => "List your pinned projects",
382+ Op::PinProject => "Pin a project",
383+ Op::UnpinProject => "Unpin a project",
384+ Op::ReorderPinnedProjects => "Reorder your pinned projects",
376385 }
377386 }
378387
+70−3
4444 pub search: Fetcher,
4545 /// Secret and dependency alerts.
4646 pub security: Fetcher,
47+ /// Projects: a person's pinned ones.
48+ pub projects: Fetcher,
4749 /// Where the request came in, for its audit entries.
4850 pub audit: crate::audit::AuditContext,
4951 /// Set for a request made with an agent's token: all it may do.
6769 context: env.service("CONTEXT")?,
6870 search: env.service("SEARCH")?,
6971 security: env.service("SECURITY")?,
72+ projects: env.service("PROJECTS")?,
7073 scope: None,
7174 audit: crate::audit::AuditContext::default(),
7275 addresses: crate::addresses::Addresses::from_env(env),
207210 SetRepoSubscription,
208211 DeleteRepoSubscription,
209212 ListWatchedRepos,
213+ ListPinnedProjects,
214+ PinProject,
215+ UnpinProject,
216+ ReorderPinnedProjects,
210217 }
211218
212219 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
454461 }
455462
456463 impl Op {
457− pub const ALL: [Op; 131] = [
464+ pub const ALL: [Op; 135] = [
458465 Op::Whoami,
459466 Op::CreateWorkspace,
460467 Op::DeleteWorkspace,
586593 Op::SetRepoSubscription,
587594 Op::DeleteRepoSubscription,
588595 Op::ListWatchedRepos,
596+ Op::ListPinnedProjects,
597+ Op::PinProject,
598+ Op::UnpinProject,
599+ Op::ReorderPinnedProjects,
589600 ];
590601
591602 pub fn by_name(name: &str) -> Option<Op> {
726737 Op::SetRepoSubscription => "set_repo_subscription",
727738 Op::DeleteRepoSubscription => "delete_repo_subscription",
728739 Op::ListWatchedRepos => "list_watched_repos",
740+ Op::ListPinnedProjects => "list_pinned_projects",
741+ Op::PinProject => "pin_project",
742+ Op::UnpinProject => "unpin_project",
743+ Op::ReorderPinnedProjects => "reorder_pinned_projects",
729744 }
730745 }
731746
10901105 Op::ListWatchedRepos => {
10911106 "The repositories you watch other than the default way: all activity, custom or ignored, each with its `level` and `events`."
10921107 }
1108+ Op::ListPinnedProjects => {
1109+ "Your pinned projects in a workspace, in your order (`position` 0 first): the ones its sidebar keeps at the top for you. Projects you can no longer see are left out. Your own: a personal access token or a session."
1110+ }
1111+ Op::PinProject => {
1112+ "Pin a project you can see, at `position` (0 first) or at the end; pinning one already pinned moves it. At most 8 a workspace: unpin one first when you have 8. Returns your pins, in order."
1113+ }
1114+ Op::UnpinProject => {
1115+ "Unpin a project. Unpinning one that is not pinned changes nothing. Returns your pins, in order."
1116+ }
1117+ Op::ReorderPinnedProjects => {
1118+ "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."
1119+ }
10931120 }
10941121 }
10951122
20802107 &["repo"],
20812108 ),
20822109 Op::ListWatchedRepos => object(json!({}), &[]),
2110+ Op::ListPinnedProjects => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
2111+ Op::PinProject => object(
2112+ json!({
2113+ "workspace": workspace_schema(),
2114+ "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." },
2115+ "position": { "type": "integer", "description": "Where it goes, 0 first. Left out: at the end." },
2116+ }),
2117+ &["workspace", "project"],
2118+ ),
2119+ Op::UnpinProject => object(
2120+ json!({
2121+ "workspace": workspace_schema(),
2122+ "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." },
2123+ }),
2124+ &["workspace", "project"],
2125+ ),
2126+ Op::ReorderPinnedProjects => object(
2127+ json!({
2128+ "workspace": workspace_schema(),
2129+ "projects": {
2130+ "type": "array",
2131+ "items": { "type": "string" },
2132+ "description": "Every pinned project's slug, once, in the order you want them.",
2133+ },
2134+ }),
2135+ &["workspace", "projects"],
2136+ ),
20832137 }
20842138 }
20852139
21772231 | Op::SetThreadSubscription
21782232 | Op::DeleteThreadSubscription
21792233 | Op::ListWatchedRepos
2234+ | Op::ListPinnedProjects
2235+ | Op::PinProject
2236+ | Op::UnpinProject
2237+ | Op::ReorderPinnedProjects
21802238 )
21812239 }
21822240
2183− /// Whether the operation is about the caller's own inbox: notifications,
2184− /// subscriptions and watching. Nobody else's business, so not audited.
2241+ /// Whether the operation is about the caller's own inbox (notifications,
2242+ /// subscriptions and watching) or their pins. Nobody else's business,
2243+ /// so not audited.
21852244 pub(crate) fn personal(self) -> bool {
21862245 matches!(
21872246 self,
21992258 | Op::SetRepoSubscription
22002259 | Op::DeleteRepoSubscription
22012260 | Op::ListWatchedRepos
2261+ | Op::ListPinnedProjects
2262+ | Op::PinProject
2263+ | Op::UnpinProject
2264+ | Op::ReorderPinnedProjects
22022265 )
22032266 }
22042267
36173680 | Op::SetRepoSubscription
36183681 | Op::DeleteRepoSubscription
36193682 | Op::ListWatchedRepos => crate::notifications::run(self, services, viewer, input).await,
3683+ // A person's pinned projects: the projects service keeps them.
3684+ Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => {
3685+ crate::pins::run(self, services, viewer, input).await
3686+ }
36203687 Op::ReopenSecurityAlert => {
36213688 let changed: Outcome<AlertChange> = call(
36223689 &services.security,
+153−0
1+//! Pinned projects: the projects a person keeps at the top of a
2+//! workspace's sidebar, in their order, up to eight a workspace. The
3+//! projects service keeps them; this is their public shape, in snake_case.
4+//!
5+//! A person's own: a personal access token or a session, never a
6+//! workspace's token or g1t's agents.
7+
8+use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Viewer};
9+use serde_json::{Value, json};
10+use worker::Result;
11+
12+use crate::operations::{Op, Services};
13+
14+fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
15+ Ok(Outcome::fail(code, message))
16+}
17+
18+fn text(input: &Value, key: &str) -> Option<String> {
19+ input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase)
20+}
21+
22+/// A place in the pins, 0 first, given as a number or as digits.
23+fn position(input: &Value) -> Option<u32> {
24+ match &input["position"] {
25+ Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
26+ Value::String(digits) => digits.trim().parse().ok(),
27+ _ => None,
28+ }
29+}
30+
31+/// The slugs `projects` names, in order: strings, or objects with a `slug`.
32+pub(crate) fn slugs(input: &Value) -> Option<Vec<String>> {
33+ input["projects"].as_array().map(|items| {
34+ items
35+ .iter()
36+ .filter_map(|item| item.as_str().or_else(|| item["slug"].as_str()))
37+ .map(|slug| slug.trim().to_lowercase())
38+ .filter(|slug| !slug.is_empty())
39+ .collect()
40+ })
41+}
42+
43+/// A project as the projects service answers (camelCase), as the API shows
44+/// a pin: its place, what it is, and where it is.
45+pub(crate) fn pin_json(project: &Value, position: usize, site: &str) -> Value {
46+ let workspace = project["workspace"].as_str().unwrap_or_default();
47+ let slug = project["slug"].as_str().unwrap_or_default();
48+ json!({
49+ "position": position,
50+ "id": project["id"],
51+ "workspace": workspace,
52+ "slug": slug,
53+ "name": project["name"],
54+ "description": project["description"],
55+ "private": project["private"],
56+ "archived": project["archived"],
57+ "kind": project["kind"],
58+ "url": format!("{}/{workspace}/{slug}", site.trim_end_matches('/')),
59+ "pushed_at": project["pushedAt"],
60+ "updated_at": project["updatedAt"],
61+ })
62+}
63+
64+fn pins_json(projects: &[Value], site: &str) -> Value {
65+ Value::Array(projects.iter().enumerate().map(|(at, project)| pin_json(project, at, site)).collect())
66+}
67+
68+/// Runs one of the pin operations.
69+pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
70+ let user = match viewer {
71+ Some(user) if user.kind == PrincipalKind::User => user,
72+ Some(_) => {
73+ return failed(
74+ FailureCode::Forbidden,
75+ "Pins are a person's own: use a personal access token, not a workspace's or an agent's.",
76+ );
77+ }
78+ None => return failed(FailureCode::Unauthenticated, "This needs a g1t access token."),
79+ };
80+ let Some(workspace) = text(input, "workspace") else {
81+ return failed(FailureCode::Invalid, "Give the workspace's slug.");
82+ };
83+ let site = services.addresses.site.as_str();
84+ let changed = |outcome: Outcome<Vec<Value>>| -> Result<Outcome<Value>> {
85+ Ok(match outcome {
86+ Outcome::Ok(projects) => Outcome::Ok(pins_json(&projects, site)),
87+ Outcome::Fail(failure) => Outcome::Fail(failure),
88+ })
89+ };
90+ match op {
91+ Op::ListPinnedProjects => {
92+ let shortcuts: Value = g1t_kit::call(
93+ &services.projects,
94+ "shortcuts",
95+ &json!({ "workspace": workspace, "viewer": viewer }),
96+ )
97+ .await?;
98+ let pinned = shortcuts["pinned"].as_array().cloned().unwrap_or_default();
99+ Ok(Outcome::Ok(pins_json(&pinned, site)))
100+ }
101+ Op::PinProject | Op::UnpinProject => {
102+ let Some(slug) = text(input, "project") else {
103+ return failed(FailureCode::Invalid, "Give the project's slug.");
104+ };
105+ let (method, args) = if op == Op::PinProject {
106+ ("pin", json!({ "actor": user, "workspace": workspace, "slug": slug, "position": position(input) }))
107+ } else {
108+ ("unpin", json!({ "actor": user, "workspace": workspace, "slug": slug }))
109+ };
110+ changed(g1t_kit::call(&services.projects, method, &args).await?)
111+ }
112+ Op::ReorderPinnedProjects => {
113+ let Some(order) = slugs(input) else {
114+ return failed(FailureCode::Invalid, "Give projects: every pinned project's slug, in the order you want them.");
115+ };
116+ changed(
117+ g1t_kit::call(
118+ &services.projects,
119+ "reorder_pins",
120+ &json!({ "actor": user, "workspace": workspace, "slugs": order }),
121+ )
122+ .await?,
123+ )
124+ }
125+ _ => failed(FailureCode::Invalid, "Not a pin operation."),
126+ }
127+}
128+
129+#[cfg(test)]
130+mod tests {
131+ use super::*;
132+
133+ #[test]
134+ fn a_pin_is_snake_case_with_its_place_and_address() {
135+ let project = json!({
136+ "id": "prj_1", "workspace": "acme", "slug": "web", "name": "Web", "description": null,
137+ "private": true, "archived": false, "kind": "app", "pushedAt": "2026-10-07T10:00:00.000Z",
138+ "updatedAt": "2026-10-01T10:00:00.000Z", "descriptionInherited": false,
139+ });
140+ let pin = pin_json(&project, 2, "https://g1t.sh/");
141+ assert_eq!(pin["position"], 2);
142+ assert_eq!(pin["url"], "https://g1t.sh/acme/web");
143+ assert_eq!(pin["pushed_at"], "2026-10-07T10:00:00.000Z");
144+ assert!(pin.get("pushedAt").is_none());
145+ assert!(pin.get("descriptionInherited").is_none());
146+ }
147+
148+ #[test]
149+ fn the_order_is_read_from_slugs_or_objects() {
150+ assert_eq!(slugs(&json!({ "projects": ["Web", { "slug": "api" }, ""] })), Some(vec!["web".into(), "api".into()]));
151+ assert_eq!(slugs(&json!({})), None);
152+ }
153+}
+204−1
4343 ],
4444 "token": {
4545 "token_id": "tok_01kkntd3p2v8x6ym5r0c1q7a9e",
46− "scopes": ["repo:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write"]
46+ "scopes": [
47+ "repo:read",
48+ "issues:read",
49+ "issues:write",
50+ "pull_requests:read",
51+ "pull_requests:write"
52+ ]
4753 }
4854 },
4955 "notes": "`token` is what the access token you called with may do: its `scopes` (left out for full access) and `legacy` when it was made before tokens had scopes. A token reaches every workspace and repository whoever it acts as can; its scopes say what it may do there. See [Scopes](/guides/authentication/#scopes). `kind` is `user`, `workspace` for a [workspace access token](/guides/workspaces/#workspace-access-tokens), or `agent` for the token a g1t agent works with. For a workspace token, `username` is the workspace's slug and `workspaces` holds only that workspace. Each workspace carries its `base_permission`: what a member gets on each of its repositories (owners have Admin). Roles given on single repositories are in `grants`, each with `repo_id`, `workspace` and `role`; it is left out when there are none. See [Access and roles](/guides/access-and-roles/)."
49774983 "updated_at": "2026-10-05T14:30:00.000Z"
49784984 }
49794985 ]
4986+ },
4987+ "list_pinned_projects": {
4988+ "params": {
4989+ "workspace": "flagon-io"
4990+ },
4991+ "response": [
4992+ {
4993+ "position": 0,
4994+ "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x",
4995+ "workspace": "flagon-io",
4996+ "slug": "g1t",
4997+ "name": "g1t",
4998+ "description": null,
4999+ "private": false,
5000+ "archived": false,
5001+ "kind": "app",
5002+ "url": "https://g1t.sh/flagon-io/g1t",
5003+ "pushed_at": "2026-10-07T10:00:00.000Z",
5004+ "updated_at": "2026-10-01T09:12:00.000Z"
5005+ },
5006+ {
5007+ "position": 1,
5008+ "id": "prj_01kkq0c4d9h2w7rn5f8t3k6p2z",
5009+ "workspace": "flagon-io",
5010+ "slug": "lab-api",
5011+ "name": "lab-api",
5012+ "description": null,
5013+ "private": true,
5014+ "archived": false,
5015+ "kind": "app",
5016+ "url": "https://g1t.sh/flagon-io/lab-api",
5017+ "pushed_at": "2026-10-06T18:40:00.000Z",
5018+ "updated_at": "2026-10-01T09:12:00.000Z"
5019+ },
5020+ {
5021+ "position": 2,
5022+ "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y",
5023+ "workspace": "flagon-io",
5024+ "slug": "hello",
5025+ "name": "hello",
5026+ "description": "Greets people from the command line.",
5027+ "private": false,
5028+ "archived": false,
5029+ "kind": "library",
5030+ "url": "https://g1t.sh/flagon-io/hello",
5031+ "pushed_at": null,
5032+ "updated_at": "2026-10-01T09:12:00.000Z"
5033+ }
5034+ ],
5035+ "notes": "In your order: `position` 0 first. The sidebar shows these, then the projects you opened last. `pushed_at` is null until the project's repository is pushed to."
5036+ },
5037+ "pin_project": {
5038+ "params": {
5039+ "workspace": "flagon-io",
5040+ "project": "hello"
5041+ },
5042+ "request": {
5043+ "position": 0
5044+ },
5045+ "response": [
5046+ {
5047+ "position": 0,
5048+ "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y",
5049+ "workspace": "flagon-io",
5050+ "slug": "hello",
5051+ "name": "hello",
5052+ "description": "Greets people from the command line.",
5053+ "private": false,
5054+ "archived": false,
5055+ "kind": "library",
5056+ "url": "https://g1t.sh/flagon-io/hello",
5057+ "pushed_at": null,
5058+ "updated_at": "2026-10-01T09:12:00.000Z"
5059+ },
5060+ {
5061+ "position": 1,
5062+ "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x",
5063+ "workspace": "flagon-io",
5064+ "slug": "g1t",
5065+ "name": "g1t",
5066+ "description": null,
5067+ "private": false,
5068+ "archived": false,
5069+ "kind": "app",
5070+ "url": "https://g1t.sh/flagon-io/g1t",
5071+ "pushed_at": "2026-10-07T10:00:00.000Z",
5072+ "updated_at": "2026-10-01T09:12:00.000Z"
5073+ },
5074+ {
5075+ "position": 2,
5076+ "id": "prj_01kkq0c4d9h2w7rn5f8t3k6p2z",
5077+ "workspace": "flagon-io",
5078+ "slug": "lab-api",
5079+ "name": "lab-api",
5080+ "description": null,
5081+ "private": true,
5082+ "archived": false,
5083+ "kind": "app",
5084+ "url": "https://g1t.sh/flagon-io/lab-api",
5085+ "pushed_at": "2026-10-06T18:40:00.000Z",
5086+ "updated_at": "2026-10-01T09:12:00.000Z"
5087+ }
5088+ ],
5089+ "notes": "A ninth pin in a workspace is refused with `409 conflict`: unpin one first."
5090+ },
5091+ "unpin_project": {
5092+ "params": {
5093+ "workspace": "flagon-io",
5094+ "project": "lab-api"
5095+ },
5096+ "response": [
5097+ {
5098+ "position": 0,
5099+ "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x",
5100+ "workspace": "flagon-io",
5101+ "slug": "g1t",
5102+ "name": "g1t",
5103+ "description": null,
5104+ "private": false,
5105+ "archived": false,
5106+ "kind": "app",
5107+ "url": "https://g1t.sh/flagon-io/g1t",
5108+ "pushed_at": "2026-10-07T10:00:00.000Z",
5109+ "updated_at": "2026-10-01T09:12:00.000Z"
5110+ },
5111+ {
5112+ "position": 1,
5113+ "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y",
5114+ "workspace": "flagon-io",
5115+ "slug": "hello",
5116+ "name": "hello",
5117+ "description": "Greets people from the command line.",
5118+ "private": false,
5119+ "archived": false,
5120+ "kind": "library",
5121+ "url": "https://g1t.sh/flagon-io/hello",
5122+ "pushed_at": null,
5123+ "updated_at": "2026-10-01T09:12:00.000Z"
5124+ }
5125+ ]
5126+ },
5127+ "reorder_pinned_projects": {
5128+ "params": {
5129+ "workspace": "flagon-io"
5130+ },
5131+ "request": {
5132+ "projects": [
5133+ "lab-api",
5134+ "g1t",
5135+ "hello"
5136+ ]
5137+ },
5138+ "response": [
5139+ {
5140+ "position": 0,
5141+ "id": "prj_01kkq0c4d9h2w7rn5f8t3k6p2z",
5142+ "workspace": "flagon-io",
5143+ "slug": "lab-api",
5144+ "name": "lab-api",
5145+ "description": null,
5146+ "private": true,
5147+ "archived": false,
5148+ "kind": "app",
5149+ "url": "https://g1t.sh/flagon-io/lab-api",
5150+ "pushed_at": "2026-10-06T18:40:00.000Z",
5151+ "updated_at": "2026-10-01T09:12:00.000Z"
5152+ },
5153+ {
5154+ "position": 1,
5155+ "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x",
5156+ "workspace": "flagon-io",
5157+ "slug": "g1t",
5158+ "name": "g1t",
5159+ "description": null,
5160+ "private": false,
5161+ "archived": false,
5162+ "kind": "app",
5163+ "url": "https://g1t.sh/flagon-io/g1t",
5164+ "pushed_at": "2026-10-07T10:00:00.000Z",
5165+ "updated_at": "2026-10-01T09:12:00.000Z"
5166+ },
5167+ {
5168+ "position": 2,
5169+ "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y",
5170+ "workspace": "flagon-io",
5171+ "slug": "hello",
5172+ "name": "hello",
5173+ "description": "Greets people from the command line.",
5174+ "private": false,
5175+ "archived": false,
5176+ "kind": "library",
5177+ "url": "https://g1t.sh/flagon-io/hello",
5178+ "pushed_at": null,
5179+ "updated_at": "2026-10-01T09:12:00.000Z"
5180+ }
5181+ ],
5182+ "notes": "Name every pinned project once; anything else is refused with `422 invalid`."
49805183 }
49815184 }
+5−0
7979 route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]),
8080 route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]),
8181 route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]),
82+ // Your pinned projects in a workspace, in your order.
83+ route("GET", "/user/pinned_projects/:workspace", Op::ListPinnedProjects, &[]),
84+ route("PUT", "/user/pinned_projects/:workspace", Op::ReorderPinnedProjects, &[]),
85+ route("PUT", "/user/pinned_projects/:workspace/:project", Op::PinProject, &[]),
86+ route("DELETE", "/user/pinned_projects/:workspace/:project", Op::UnpinProject, &[]),
8287 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
8388 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
8489 route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]),
+5−1
227227 Tool {
228228 name: "workspace",
229229 title: "Workspaces",
230− description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, and connect integrations and model providers.",
230+ description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, and keep your own pinned projects at the top of its sidebar.",
231231 default_action: None,
232232 actions: &[
233233 a("create", Op::CreateWorkspace, "Create a workspace"),
242242 a("test_integration", Op::TestIntegration, "Check its credentials"),
243243 a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"),
244244 a("set_model_routes", Op::SetModelRoutes, "Replace them"),
245+ a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"),
246+ a("pin_project", Op::PinProject, "Pin a project, at a position or the end"),
247+ a("unpin_project", Op::UnpinProject, "Unpin a project"),
248+ a("reorder_pinned_projects", Op::ReorderPinnedProjects, "Put your pins in a new order"),
245249 ],
246250 },
247251 Tool {
+3−1
2929 { "binding": "CONTEXT", "service": "g1t-context" },
3030 { "binding": "SEARCH", "service": "g1t-search" },
3131 // Secret and dependency alerts: list_security_alerts and dismissing them.
32− { "binding": "SECURITY", "service": "g1t-security" }
32+ { "binding": "SECURITY", "service": "g1t-security" },
33+ // A person's pinned projects: list_pinned_projects and changing them.
34+ { "binding": "PROJECTS", "service": "g1t-projects" }
3335 ],
3436 // GitHub Actions artifacts, in chunks, with KV's own expiry (and cache
3537 // entries saved before the cache moved to R2, until they expire).
+2−2
351351 | `workflows:write` | Run, cancel, rerun and turn workflows on or off |
352352 | `memory:read` | Recall memory and search the workspace's context |
353353 | `memory:write` | Save memory for the next agent |
354−| `account:read` | Read your email addresses, invites and invitations |
355−| `account:write` | Change your email addresses, make invites and answer invitations |
354+| `account:read` | Read your email addresses, invites, invitations and pinned projects |
355+| `account:write` | Change your email addresses, make invites, answer invitations and pin projects |
356356 | `notifications:read` | See your [inbox](/guides/inbox/), its threads, and what you subscribe to and watch |
357357 | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories |
358358 | `workspace:read` | Read workspace invites, integrations and model routes |
+0−0

Binary or large file; its contents are not shown.

+6−0
268268 "get_thread_subscription",
269269 "get_repo_subscription",
270270 "list_watched_repos",
271+ "list_pinned_projects",
271272 ];
272273
273274 /// What no agent's token may ever do, whatever its scope says: workspaces,
349350 "set_repo_subscription",
350351 "delete_repo_subscription",
351352 "list_watched_repos",
353+ // Pins are a person's own, as the inbox is.
354+ "list_pinned_projects",
355+ "pin_project",
356+ "unpin_project",
357+ "reorder_pinned_projects",
352358 ];
353359
354360 /// Reading what an agent needs to know about its repository.
+7−2
288288 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
289289 Scope::MemoryRead => "Recall memory and search the workspace's context",
290290 Scope::MemoryWrite => "Save memory for the next agent",
291− Scope::AccountRead => "Read your email addresses, invites and invitations",
292− Scope::AccountWrite => "Change your email addresses, make invites and answer invitations",
291+ Scope::AccountRead => "Read your email addresses, invites, invitations and pinned projects",
292+ Scope::AccountWrite => "Change your email addresses, make invites, answer invitations and pin projects",
293293 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
294294 Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories",
295295 Scope::WorkspaceRead => "Read workspace invites, integrations and model routes",
475475 ("list_my_repo_invitations", Scope::AccountRead),
476476 ("accept_repo_invitation", Scope::AccountWrite),
477477 ("decline_repo_invitation", Scope::AccountWrite),
478+ // Your pinned projects: a preference of your account.
479+ ("list_pinned_projects", Scope::AccountRead),
480+ ("pin_project", Scope::AccountWrite),
481+ ("unpin_project", Scope::AccountWrite),
482+ ("reorder_pinned_projects", Scope::AccountWrite),
478483 // Your inbox: notifications, subscriptions and watching.
479484 ("list_notifications", Scope::NotificationsRead),
480485 ("get_notification_thread", Scope::NotificationsRead),
+7−2
4646 { scope: "workflows:write", description: "Run, cancel, rerun and turn workflows on or off" },
4747 { scope: "memory:read", description: "Recall memory and search the workspace's context" },
4848 { scope: "memory:write", description: "Save memory for the next agent" },
49− { scope: "account:read", description: "Read your email addresses, invites and invitations" },
50− { scope: "account:write", description: "Change your email addresses, make invites and answer invitations" },
49+ { scope: "account:read", description: "Read your email addresses, invites, invitations and pinned projects" },
50+ { scope: "account:write", description: "Change your email addresses, make invites, answer invitations and pin projects" },
5151 { scope: "notifications:read", description: "See your inbox, its threads, and what you subscribe to and watch" },
5252 { scope: "notifications:write", description: "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories" },
5353 { scope: "workspace:read", description: "Read workspace invites, integrations and model routes" },
175175 ["list_my_repo_invitations", "account:read"],
176176 ["accept_repo_invitation", "account:write"],
177177 ["decline_repo_invitation", "account:write"],
178+ // Your pinned projects: a preference of your account.
179+ ["list_pinned_projects", "account:read"],
180+ ["pin_project", "account:write"],
181+ ["unpin_project", "account:write"],
182+ ["reorder_pinned_projects", "account:write"],
178183 // Your inbox: notifications, subscriptions and watching.
179184 ["list_notifications", "notifications:read"],
180185 ["get_notification_thread", "notifications:read"],