| 1 | + | //! Artifacts over REST and MCP: a workspace's docs, slides, designs and | |
| 2 | + | //! dashboards (Artifacts mode, docs/ARTIFACTS_MODE.md), at | |
| 3 | + | //! `/workspaces/{workspace}/artifacts` and as the `artifact` MCP tool. | |
| 4 | + | //! Code calls them folios; people, URLs, the tool and the scopes say | |
| 5 | + | //! "artifact". Workflow runs' artifacts are something else (artifacts.rs). | |
| 6 | + | //! | |
| 7 | + | //! The docs service (`services/docs`, the `DOCS` binding) decides who may | |
| 8 | + | //! do what with each one, from the person's own role on it: this checks | |
| 9 | + | //! the input, names people for the service (a username becomes | |
| 10 | + | //! `user:<id>`), and gives each answer its public shape, in snake_case. | |
| 11 | + | //! The token's `artifacts:*` scope is checked before anything runs | |
| 12 | + | //! (audit.rs). A workspace's own token is refused: an artifact always has | |
| 13 | + | //! a person as its owner. | |
| 14 | + | ||
| 15 | + | use g1t_contracts::datasets::DatasetQuery; | |
| 16 | + | use g1t_contracts::folios::*; | |
| 17 | + | use g1t_contracts::identity::UsernameArgs; | |
| 18 | + | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; | |
| 19 | + | use serde::Serialize; | |
| 20 | + | use serde::de::DeserializeOwned; | |
| 21 | + | use serde_json::{Map, Value, json}; | |
| 22 | + | use worker::Result; | |
| 23 | + | ||
| 24 | + | use crate::operations::Services; | |
| 25 | + | ||
| 26 | + | /// One operation on artifacts. | |
| 27 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 28 | + | pub enum FoliosOp { | |
| 29 | + | List, | |
| 30 | + | Search, | |
| 31 | + | Get, | |
| 32 | + | GetContent, | |
| 33 | + | ListVersions, | |
| 34 | + | GetAccess, | |
| 35 | + | ListTemplates, | |
| 36 | + | ListSpaces, | |
| 37 | + | QueryDataset, | |
| 38 | + | Create, | |
| 39 | + | Update, | |
| 40 | + | Edit, | |
| 41 | + | Trash, | |
| 42 | + | Restore, | |
| 43 | + | RestoreVersion, | |
| 44 | + | SetAccess, | |
| 45 | + | Purge, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | impl FoliosOp { | |
| 49 | + | /// Every one: `Op::ALL` lists each as `Op::Folios(…)`, which a test | |
| 50 | + | /// checks against this. | |
| 51 | + | #[cfg(test)] | |
| 52 | + | pub const ALL: [FoliosOp; 17] = [ | |
| 53 | + | FoliosOp::List, | |
| 54 | + | FoliosOp::Search, | |
| 55 | + | FoliosOp::Get, | |
| 56 | + | FoliosOp::GetContent, | |
| 57 | + | FoliosOp::ListVersions, | |
| 58 | + | FoliosOp::GetAccess, | |
| 59 | + | FoliosOp::ListTemplates, | |
| 60 | + | FoliosOp::ListSpaces, | |
| 61 | + | FoliosOp::QueryDataset, | |
| 62 | + | FoliosOp::Create, | |
| 63 | + | FoliosOp::Update, | |
| 64 | + | FoliosOp::Edit, | |
| 65 | + | FoliosOp::Trash, | |
| 66 | + | FoliosOp::Restore, | |
| 67 | + | FoliosOp::RestoreVersion, | |
| 68 | + | FoliosOp::SetAccess, | |
| 69 | + | FoliosOp::Purge, | |
| 70 | + | ]; | |
| 71 | + | ||
| 72 | + | /// Its operation id. `workspace_artifact`, because GitHub's names for | |
| 73 | + | /// workflow runs' artifacts (`list_artifacts`, `get_artifact`) are taken. | |
| 74 | + | pub fn name(self) -> &'static str { | |
| 75 | + | match self { | |
| 76 | + | FoliosOp::List => "list_workspace_artifacts", | |
| 77 | + | FoliosOp::Search => "search_workspace_artifacts", | |
| 78 | + | FoliosOp::Get => "get_workspace_artifact", | |
| 79 | + | FoliosOp::GetContent => "get_workspace_artifact_content", | |
| 80 | + | FoliosOp::ListVersions => "list_workspace_artifact_versions", | |
| 81 | + | FoliosOp::GetAccess => "get_workspace_artifact_access", | |
| 82 | + | FoliosOp::ListTemplates => "list_workspace_artifact_templates", | |
| 83 | + | FoliosOp::ListSpaces => "list_workspace_artifact_spaces", | |
| 84 | + | FoliosOp::QueryDataset => "query_workspace_dataset", | |
| 85 | + | FoliosOp::Create => "create_workspace_artifact", | |
| 86 | + | FoliosOp::Update => "update_workspace_artifact", | |
| 87 | + | FoliosOp::Edit => "edit_workspace_artifact", | |
| 88 | + | FoliosOp::Trash => "trash_workspace_artifact", | |
| 89 | + | FoliosOp::Restore => "restore_workspace_artifact", | |
| 90 | + | FoliosOp::RestoreVersion => "restore_workspace_artifact_version", | |
| 91 | + | FoliosOp::SetAccess => "set_workspace_artifact_access", | |
| 92 | + | FoliosOp::Purge => "purge_workspace_artifact", | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// For the API reference. | |
| 97 | + | pub fn title(self) -> &'static str { | |
| 98 | + | match self { | |
| 99 | + | FoliosOp::List => "List a workspace's artifacts", | |
| 100 | + | FoliosOp::Search => "Search a workspace's artifacts", | |
| 101 | + | FoliosOp::Get => "Get an artifact", | |
| 102 | + | FoliosOp::GetContent => "Get an artifact's content", | |
| 103 | + | FoliosOp::ListVersions => "List an artifact's versions", | |
| 104 | + | FoliosOp::GetAccess => "Get who can open an artifact", | |
| 105 | + | FoliosOp::ListTemplates => "List artifact templates", | |
| 106 | + | FoliosOp::ListSpaces => "List artifact spaces", | |
| 107 | + | FoliosOp::QueryDataset => "Query a dataset", | |
| 108 | + | FoliosOp::Create => "Create an artifact", | |
| 109 | + | FoliosOp::Update => "Update an artifact", | |
| 110 | + | FoliosOp::Edit => "Edit an artifact's content", | |
| 111 | + | FoliosOp::Trash => "Move an artifact to the trash", | |
| 112 | + | FoliosOp::Restore => "Restore an artifact from the trash", | |
| 113 | + | FoliosOp::RestoreVersion => "Restore an artifact version", | |
| 114 | + | FoliosOp::SetAccess => "Share an artifact", | |
| 115 | + | FoliosOp::Purge => "Delete an artifact for good", | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | pub fn description(self) -> &'static str { | |
| 120 | + | match self { | |
| 121 | + | FoliosOp::List => "List the artifacts (docs, slides, designs and dashboards) in a workspace that you can open, most recently edited first: each with its id (fol_…), kind, title, icon, space (null in its owner's Private), parent_id, owner, your role (viewer_role: view, comment, edit or manage), general_access, private, excerpt, edited_at and html_url. tab is all (the default), yours (you own them) or shared (shared with you). Narrow with kind, space (its slug or id), project (owner/name), q (words or meaning) and limit (at most 100); next_cursor pages on. With state trashed, the artifacts in the trash you can restore instead. Not workflow runs' artifacts.", | |
| 122 | + | FoliosOp::Search => "Search the artifacts in a workspace that you can open, by words and by meaning: each hit is the artifact with the passage that matched (snippet, and the heading it is under), its space_name and edited_at. Narrow with kind, space, project and limit (at most 50). Only what you can read now is found.", | |
| 123 | + | FoliosOp::Get => "Get one artifact by its id (fol_…) or its address (`/acme/-/artifacts/q4-roadmap-fol_…`): its kind, title, space, parent_id, owner, who made it, your role, general_access, whether it is private, how many it is shared with, its excerpt, whether it may be out of date with the code it cites (stale), and html_url. Not found when you cannot open it, as for one that does not exist. Opening a link-shared artifact this way counts as following its link.", | |
| 124 | + | FoliosOp::GetContent => "Get an artifact's content in the form agents read and write: for a doc, its Markdown in content, and its top-level blocks (id, type, level and Markdown) to target an edit at; with can (read, suggest, edit): what you may do to it. Slides, designs and dashboards answer that they are not here yet.", | |
| 125 | + | FoliosOp::ListVersions => "List an artifact's saved versions, newest first: each with its id (ver_…), created_at, kind (created, edit, agent, suggestion, proposal or restore), note, and the people and agents who made it.", | |
| 126 | + | FoliosOp::GetAccess => "Who can open an artifact and how: its owner, each person, agent and team it is shared with (principal, role, and inherited_from when the access comes from a doc it is under), general_access (none, workspace or link) with general_role, whether it follows its space or parent (inherit), agent_mode, and whether you may change it (can_share).", | |
| 127 | + | FoliosOp::ListTemplates => "The templates an artifact can start from: the built-in ones and those saved in the workspace, each with its id, kind, name, description and body (Markdown, or a JSON spec). Narrow with kind.", | |
| 128 | + | FoliosOp::ListSpaces => "The spaces in your Artifacts sidebar: the General space, open spaces you joined, your teams' spaces and Members-only spaces you are in. Each with its id, slug, name, kind (workspace for an open space, team or private), your role in it (viewer_role) and how many artifacts it holds you can open.", | |
| 129 | + | FoliosOp::QueryDataset => "Run a dataset query (issues, pull requests, workflow runs, deployments, spend or agent sessions) as you, over what you can read: rows of the measure, grouped and filtered as asked, with truncated when more rows matched, and partial true when some of it was left out because you cannot read it. Answers that dashboards are not here yet until they ship.", | |
| 130 | + | FoliosOp::Create => "Make an artifact. kind is doc (the default); slides, designs and dashboards answer that they are not here yet. It lands in space (a slug or id you can edit in), under parent_id (a doc you can edit), or, with neither, in your Private, where only you can open it. Start it from markdown, or from a template (template_id), with an optional title and icon. You own it. Returns the artifact.", | |
| 131 | + | FoliosOp::Update => "Rename an artifact (title), change its icon (null clears it), or move it: to space (a slug or id; `private` for your Private, yours only), under parent_id (a doc), before before_id among its new siblings. Moving changes who can open it when it follows its space or parent. Takes the edit role on it, and on where it goes. Returns the artifact.", | |
| 132 | + | FoliosOp::Edit => "Change an artifact's content. For a doc: markdown with a target: append (add to the end), document (replace it all), section (with heading: that heading and everything under it) or blocks (from_block through to_block, block ids from its content). With the edit role the change is made, as a new version; with the comment role, or suggest_only, it is filed as a suggestion its editors accept or reject. note says why; marks_current says it brings the doc up to date with the code it cites. Returns mode (applied or suggested), version_id or the suggestion, and the artifact.", | |
| 133 | + | FoliosOp::Trash => "Move an artifact, and everything under it, to the trash: nobody can open it until it is restored, and it is deleted for good after 30 days. Takes the edit role. Returns the artifact, with trashed_at.", | |
| 134 | + | FoliosOp::Restore => "Bring an artifact back from the trash, with what went to the trash with it, where it was (at the top of its space or your Private when the doc it was under is gone). Takes the edit role. Returns the artifact.", | |
| 135 | + | FoliosOp::RestoreVersion => "Make an earlier version (version_id) an artifact's content again, as a new version: nothing in between is lost. Takes the edit role. Returns the new version.", | |
| 136 | + | FoliosOp::SetAccess => "Change who can open an artifact. Share it with a person (username), a team (team: its slug) or an agent (agent: its id), or a principal from its access list, at role view, comment, edit or manage (full access), with an optional notify message; role none takes theirs away. Set general_access to none (only people invited), workspace (everyone in the workspace) or link (anyone in the workspace with the link), with general_role view, comment or edit. inherit false stops it following its space or parent; true follows again. agent_mode suggest or edit says how agents change it (null follows its space). Takes full access (manage). Returns who can open it now.", | |
| 137 | + | FoliosOp::Purge => "Delete an artifact in the trash, and everything under it, for good: its content and versions are gone and cannot be restored. Move it to the trash first. Takes full access (manage).", | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// Whether it changes anything. | |
| 142 | + | #[cfg(test)] | |
| 143 | + | pub fn writes(self) -> bool { | |
| 144 | + | !matches!( | |
| 145 | + | self, | |
| 146 | + | FoliosOp::List | |
| 147 | + | | FoliosOp::Search | |
| 148 | + | | FoliosOp::Get | |
| 149 | + | | FoliosOp::GetContent | |
| 150 | + | | FoliosOp::ListVersions | |
| 151 | + | | FoliosOp::GetAccess | |
| 152 | + | | FoliosOp::ListTemplates | |
| 153 | + | | FoliosOp::ListSpaces | |
| 154 | + | | FoliosOp::QueryDataset | |
| 155 | + | ) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | pub fn input(self) -> Value { | |
| 159 | + | let workspace = json!({ "type": "string", "description": "The workspace's slug, e.g. \"acme\"." }); | |
| 160 | + | let artifact_id = json!({ "type": "string", "description": "The artifact's id (fol_…), or its address: /acme/-/artifacts/q4-roadmap-fol_…" }); | |
| 161 | + | let kind = json!({ "type": "string", "enum": ["doc", "slides", "design", "dashboard"], "description": "doc, slides, design or dashboard." }); | |
| 162 | + | let space = json!({ "type": "string", "description": "A space: its slug (general) or id (spc_…)." }); | |
| 163 | + | let project = json!({ "type": "string", "description": "Only artifacts about this repository: owner/name." }); | |
| 164 | + | let with = |mut properties: Value| { | |
| 165 | + | properties["workspace"] = workspace.clone(); | |
| 166 | + | properties["artifact_id"] = artifact_id.clone(); | |
| 167 | + | properties | |
| 168 | + | }; | |
| 169 | + | let one = ["workspace", "artifact_id"]; | |
| 170 | + | let (properties, required): (Value, Vec<&str>) = match self { | |
| 171 | + | FoliosOp::List => ( | |
| 172 | + | json!({ | |
| 173 | + | "workspace": workspace, | |
| 174 | + | "tab": { "type": "string", "enum": ["all", "yours", "shared"], "description": "all (the default), yours (you own them) or shared (shared with you by others)." }, | |
| 175 | + | "kind": kind, | |
| 176 | + | "space": space, | |
| 177 | + | "project": project, | |
| 178 | + | "q": { "type": "string", "description": "Only artifacts matching these words or this meaning, best first." }, | |
| 179 | + | "state": { "type": "string", "enum": ["active", "trashed"], "description": "active (the default), or trashed: what you can restore from the trash." }, | |
| 180 | + | "cursor": { "type": "string", "description": "next_cursor from the page before." }, | |
| 181 | + | "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "How many, at most 100." }, | |
| 182 | + | }), | |
| 183 | + | vec!["workspace"], | |
| 184 | + | ), | |
| 185 | + | FoliosOp::Search => ( | |
| 186 | + | json!({ | |
| 187 | + | "workspace": workspace, | |
| 188 | + | "q": { "type": "string", "description": "Words or a question." }, | |
| 189 | + | "kind": kind, | |
| 190 | + | "space": space, | |
| 191 | + | "project": project, | |
| 192 | + | "limit": { "type": "integer", "minimum": 1, "maximum": 50, "description": "How many hits, at most 50." }, | |
| 193 | + | }), | |
| 194 | + | vec!["workspace", "q"], | |
| 195 | + | ), | |
| 196 | + | FoliosOp::Get | FoliosOp::GetContent | FoliosOp::ListVersions | FoliosOp::GetAccess | FoliosOp::Trash | FoliosOp::Restore | FoliosOp::Purge => { | |
| 197 | + | (with(json!({})), one.to_vec()) | |
| 198 | + | } | |
| 199 | + | FoliosOp::ListTemplates => (json!({ "workspace": workspace, "kind": kind }), vec!["workspace"]), | |
| 200 | + | FoliosOp::ListSpaces => (json!({ "workspace": workspace }), vec!["workspace"]), | |
| 201 | + | FoliosOp::QueryDataset => ( | |
| 202 | + | json!({ | |
| 203 | + | "workspace": workspace, | |
| 204 | + | "query": { | |
| 205 | + | "type": "object", | |
| 206 | + | "description": "dataset (issues, pull_requests, workflow_runs, deployments, spend or agent_sessions), measure ({ op: count, sum, avg, p50, p95 or rate, field where it takes one }), and optional group_by, interval (day, week or month), time, filters ([{ field, op, value }], at most 10), range (7d, 30d, 90d, or { from, to }, at most 366 days) and limit (at most 100).", | |
| 207 | + | }, | |
| 208 | + | }), | |
| 209 | + | vec!["workspace", "query"], | |
| 210 | + | ), | |
| 211 | + | FoliosOp::Create => ( | |
| 212 | + | json!({ | |
| 213 | + | "workspace": workspace, | |
| 214 | + | "kind": kind, | |
| 215 | + | "title": { "type": "string", "description": "At most 200 characters. Left out: the template's, or Untitled." }, | |
| 216 | + | "icon": { "type": "string", "description": "An emoji." }, | |
| 217 | + | "space": { "type": "string", "description": "A space you can edit in: its slug or id. Left out, with no parent_id: your Private." }, | |
| 218 | + | "parent_id": { "type": "string", "description": "A doc to put it under, which you can edit: its id. Its space is the doc's." }, | |
| 219 | + | "markdown": { "type": "string", "description": "What a doc starts with, in Markdown." }, | |
| 220 | + | "template_id": { "type": "string", "description": "A template to start from (list_workspace_artifact_templates), instead of markdown." }, | |
| 221 | + | }), | |
| 222 | + | vec!["workspace"], | |
| 223 | + | ), | |
| 224 | + | FoliosOp::Update => ( | |
| 225 | + | with(json!({ | |
| 226 | + | "title": { "type": "string", "description": "Its new title." }, | |
| 227 | + | "icon": { "type": ["string", "null"], "description": "An emoji; null clears it." }, | |
| 228 | + | "space": { "type": "string", "description": "Move it to the top of this space (slug or id), or `private` for your Private." }, | |
| 229 | + | "parent_id": { "type": "string", "description": "Move it under this doc: its id." }, | |
| 230 | + | "before_id": { "type": "string", "description": "Where it goes among its new siblings: before this one. Left out: last." }, | |
| 231 | + | })), | |
| 232 | + | one.to_vec(), | |
| 233 | + | ), | |
| 234 | + | FoliosOp::Edit => ( | |
| 235 | + | with(json!({ | |
| 236 | + | "markdown": { "type": "string", "description": "For a doc: the Markdown to add, or to replace the target with." }, | |
| 237 | + | "target": { | |
| 238 | + | "type": ["object", "string"], | |
| 239 | + | "description": "For a doc: append or document (as a string or { \"kind\": … }), { \"kind\": \"section\", \"heading\": … }, or { \"kind\": \"blocks\", \"from_block\": …, \"to_block\": … }. Left out: append.", | |
| 240 | + | }, | |
| 241 | + | "ops": { "type": "array", "items": { "type": "object" }, "description": "For slides, designs and dashboards, once they ship: the kind's own ops." }, | |
| 242 | + | "kind": { "type": "string", "enum": ["doc", "slides", "design", "dashboard"], "description": "The artifact's kind; left out, doc." }, | |
| 243 | + | "note": { "type": "string", "description": "Why, in a line: shown with the version or suggestion." }, | |
| 244 | + | "suggest_only": { "type": "boolean", "description": "File a suggestion even when you could edit." }, | |
| 245 | + | "marks_current": { "type": "boolean", "description": "The edit brings it up to date with the code it cites." }, | |
| 246 | + | })), | |
| 247 | + | one.to_vec(), | |
| 248 | + | ), | |
| 249 | + | FoliosOp::RestoreVersion => ( | |
| 250 | + | with(json!({ "version_id": { "type": "string", "description": "The version's id (ver_…), from its versions." } })), | |
| 251 | + | [one.as_slice(), &["version_id"]].concat(), | |
| 252 | + | ), | |
| 253 | + | FoliosOp::SetAccess => ( | |
| 254 | + | with(json!({ | |
| 255 | + | "username": { "type": "string", "description": "A member to share it with." }, | |
| 256 | + | "team": { "type": "string", "description": "A team of the workspace to share it with: its slug." }, | |
| 257 | + | "agent": { "type": "string", "description": "An agent of the workspace to share it with: its id." }, | |
| 258 | + | "principal": { "type": "string", "description": "Who, as its access list names them: user:…, agent:… or team:…" }, | |
| 259 | + | "role": { "type": "string", "enum": ["view", "comment", "edit", "manage", "none"], "description": "What they may do: view, comment, edit, or manage (full access, sharing included); none takes their access away." }, | |
| 260 | + | "notify": { "type": "string", "description": "A message for the person it is shared with." }, | |
| 261 | + | "general_access": { "type": "string", "enum": ["none", "workspace", "link"], "description": "none (only people invited), workspace (everyone in the workspace) or link (anyone in the workspace with the link)." }, | |
| 262 | + | "general_role": { "type": "string", "enum": ["view", "comment", "edit"], "description": "What general access gives; never full access." }, | |
| 263 | + | "inherit": { "type": "boolean", "description": "Whether it follows its space or the doc it is under." }, | |
| 264 | + | "agent_mode": { "type": ["string", "null"], "enum": ["suggest", "edit", null], "description": "How agents change it: suggest or edit; null follows its space." }, | |
| 265 | + | })), | |
| 266 | + | one.to_vec(), | |
| 267 | + | ), | |
| 268 | + | }; | |
| 269 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // --- Reading the input -------------------------------------------------------- | |
| 274 | + | ||
| 275 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 276 | + | input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /// A whole number given as one, or as digits (a REST query). | |
| 280 | + | fn number(input: &Value, key: &str) -> Option<u32> { | |
| 281 | + | input[key] | |
| 282 | + | .as_u64() | |
| 283 | + | .or_else(|| text(input, key).and_then(|given| given.parse().ok())) | |
| 284 | + | .map(|n| u32::try_from(n).unwrap_or(u32::MAX)) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | fn invalid(message: impl Into<String>) -> Outcome<Value> { | |
| 288 | + | Outcome::fail(FailureCode::Invalid, message) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// The folio id an `artifact_id` names: an id, or an address or link that | |
| 292 | + | /// ends in one. | |
| 293 | + | pub(crate) fn artifact_id(given: &str) -> Option<String> { | |
| 294 | + | let given = given.trim(); | |
| 295 | + | let path = given.split(['?', '#']).next().unwrap_or_default(); | |
| 296 | + | let last = path.trim_end_matches('/').rsplit('/').next().unwrap_or_default(); | |
| 297 | + | folio_id_from(last).map(str::to_owned) | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | fn kind_of(input: &Value) -> std::result::Result<Option<FolioKind>, String> { | |
| 301 | + | match text(input, "kind") { | |
| 302 | + | None => Ok(None), | |
| 303 | + | Some(kind) => FolioKind::parse(&kind.to_lowercase()) | |
| 304 | + | .map(Some) | |
| 305 | + | .ok_or_else(|| format!("There is no kind of artifact called {kind}: doc, slides, design or dashboard.")), | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | /// A doc edit's target, as the docs service reads it: a string is the | |
| 310 | + | /// kind, an object passes as given. Left out, append. | |
| 311 | + | fn edit_target(input: &Value) -> Value { | |
| 312 | + | match &input["target"] { | |
| 313 | + | Value::String(kind) => json!({ "kind": kind.trim().to_lowercase() }), | |
| 314 | + | Value::Object(target) => Value::Object(target.clone()), | |
| 315 | + | _ => json!({ "kind": "append" }), | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | /// The `FolioAgentEdit` a call describes. | |
| 320 | + | pub(crate) fn agent_edit(input: &Value) -> std::result::Result<Value, String> { | |
| 321 | + | let kind = kind_of(input)?.unwrap_or(FolioKind::Doc); | |
| 322 | + | let mut edit = Map::new(); | |
| 323 | + | edit.insert("kind".to_owned(), json!(kind.as_str())); | |
| 324 | + | if kind == FolioKind::Doc { | |
| 325 | + | let Some(markdown) = input["markdown"].as_str() else { | |
| 326 | + | return Err("Give the markdown to add, or to replace the target with.".to_owned()); | |
| 327 | + | }; | |
| 328 | + | edit.insert("markdown".to_owned(), json!(markdown)); | |
| 329 | + | edit.insert("target".to_owned(), edit_target(input)); | |
| 330 | + | } else { | |
| 331 | + | edit.insert("ops".to_owned(), input["ops"].clone()); | |
| 332 | + | } | |
| 333 | + | for key in ["note", "suggest_only", "marks_current"] { | |
| 334 | + | if let Some(value) = input.get(key).filter(|value| !value.is_null()) { | |
| 335 | + | edit.insert(key.to_owned(), value.clone()); | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | let edit = Value::Object(edit); | |
| 339 | + | agent_edit_error(&edit)?; | |
| 340 | + | Ok(edit) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /// What a call to the access route changes, in order: someone's role, | |
| 344 | + | /// then general access, then following the space or parent, then how | |
| 345 | + | /// agents change it. The principal is resolved by the caller. | |
| 346 | + | pub(crate) fn access_changes(input: &Value, principal: Option<String>) -> std::result::Result<Vec<FolioAccessChange>, String> { | |
| 347 | + | let mut changes = Vec::new(); | |
| 348 | + | let role = text(input, "role").map(|role| role.to_lowercase()); | |
| 349 | + | match (principal, role.as_deref()) { | |
| 350 | + | (Some(principal), Some("none")) => changes.push(FolioAccessChange::Revoke { principal }), | |
| 351 | + | (Some(principal), Some(role)) => { | |
| 352 | + | let role = parse_role(role).ok_or_else(|| format!("{role} is not a role: view, comment, edit, manage or none."))?; | |
| 353 | + | changes.push(FolioAccessChange::Grant { principal, role, notify: text(input, "notify") }); | |
| 354 | + | } | |
| 355 | + | (Some(_), None) => return Err("Give the role: view, comment, edit, manage, or none to take their access away.".to_owned()), | |
| 356 | + | (None, Some(_)) => return Err("Give who to share it with: username, team, agent or principal.".to_owned()), | |
| 357 | + | (None, None) => {} | |
| 358 | + | } | |
| 359 | + | if let Some(access) = text(input, "general_access") { | |
| 360 | + | let access = match access.to_lowercase().as_str() { | |
| 361 | + | "none" | "restricted" => GeneralAccess::None, | |
| 362 | + | "workspace" => GeneralAccess::Workspace, | |
| 363 | + | "link" => GeneralAccess::Link, | |
| 364 | + | other => return Err(format!("{other} is not general access: none, workspace or link.")), | |
| 365 | + | }; | |
| 366 | + | let role = match (access, text(input, "general_role")) { | |
| 367 | + | (GeneralAccess::None, _) => None, | |
| 368 | + | (_, None) => Some(FolioRole::View), | |
| 369 | + | (_, Some(role)) => Some(parse_role(&role.to_lowercase()).ok_or_else(|| format!("{role} is not a role: view, comment or edit."))?), | |
| 370 | + | }; | |
| 371 | + | changes.push(FolioAccessChange::General { access, role }); | |
| 372 | + | } | |
| 373 | + | if let Some(inherit) = input["inherit"].as_bool() { | |
| 374 | + | changes.push(FolioAccessChange::Inherit { inherit }); | |
| 375 | + | } | |
| 376 | + | if let Some(mode) = input.get("agent_mode") { | |
| 377 | + | let agent_mode = match mode.as_str().map(str::to_lowercase).as_deref() { | |
| 378 | + | None if mode.is_null() => None, | |
| 379 | + | Some("suggest") => Some(AgentMode::Suggest), | |
| 380 | + | Some("edit") => Some(AgentMode::Edit), | |
| 381 | + | _ => return Err("agent_mode is suggest, edit, or null to follow its space.".to_owned()), | |
| 382 | + | }; | |
| 383 | + | changes.push(FolioAccessChange::AgentMode { agent_mode }); | |
| 384 | + | } | |
| 385 | + | if changes.is_empty() { | |
| 386 | + | return Err("Say what to change: who and a role, general_access, inherit or agent_mode.".to_owned()); | |
| 387 | + | } | |
| 388 | + | for change in &changes { | |
| 389 | + | change.validate()?; | |
| 390 | + | } | |
| 391 | + | Ok(changes) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | fn parse_role(text: &str) -> Option<FolioRole> { | |
| 395 | + | match text { | |
| 396 | + | "view" | "read" => Some(FolioRole::View), | |
| 397 | + | "comment" => Some(FolioRole::Comment), | |
| 398 | + | "edit" | "write" => Some(FolioRole::Edit), | |
| 399 | + | "manage" | "full" | "admin" => Some(FolioRole::Manage), | |
| 400 | + | _ => None, | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | // --- Answers, as the API shows them ------------------------------------------- | |
| 405 | + | ||
| 406 | + | /// A person, agent or team, from a docs service profile. | |
| 407 | + | pub(crate) fn person_json(profile: &Value) -> Value { | |
| 408 | + | if !profile.is_object() { | |
| 409 | + | return Value::Null; | |
| 410 | + | } | |
| 411 | + | let kind = profile["kind"].as_str().unwrap_or("user"); | |
| 412 | + | let name = profile["name"].clone(); | |
| 413 | + | match kind { | |
| 414 | + | "agent" => json!({ "type": "agent", "id": profile["id"], "handle": name, "display_name": profile["display_name"] }), | |
| 415 | + | "team" => json!({ "type": "team", "slug": profile["id"], "display_name": profile["display_name"] }), | |
| 416 | + | _ => json!({ "type": "user", "id": profile["id"], "username": name, "display_name": profile["display_name"] }), | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | fn people_json(list: &Value) -> Value { | |
| 421 | + | Value::Array(list.as_array().map(|list| list.iter().map(person_json).collect()).unwrap_or_default()) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | fn html_url(site: &str, path: &Value) -> Value { | |
| 425 | + | match path.as_str() { | |
| 426 | + | Some(path) => json!(format!("{}{}", site.trim_end_matches('/'), path)), | |
| 427 | + | None => Value::Null, | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | /// Enough to link to an artifact. | |
| 432 | + | pub(crate) fn ref_json(folio: &Value, site: &str) -> Value { | |
| 433 | + | json!({ | |
| 434 | + | "id": folio["id"], | |
| 435 | + | "kind": folio["kind"], | |
| 436 | + | "title": folio["title"], | |
| 437 | + | "icon": folio["icon"], | |
| 438 | + | "slug": folio["slug"], | |
| 439 | + | "html_url": html_url(site, &folio["path"]), | |
| 440 | + | }) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | /// An artifact as the API shows one. | |
| 444 | + | pub(crate) fn folio_json(folio: &Value, site: &str) -> Value { | |
| 445 | + | let space = match &folio["space"] { | |
| 446 | + | Value::Object(space) => json!({ "id": space.get("id"), "slug": space.get("slug"), "name": space.get("name"), "kind": space.get("kind") }), | |
| 447 | + | _ => Value::Null, | |
| 448 | + | }; | |
| 449 | + | json!({ | |
| 450 | + | "id": folio["id"], | |
| 451 | + | "kind": folio["kind"], | |
| 452 | + | "title": folio["title"], | |
| 453 | + | "icon": folio["icon"], | |
| 454 | + | "slug": folio["slug"], | |
| 455 | + | "space": space, | |
| 456 | + | "parent_id": folio["parent_id"], | |
| 457 | + | "has_children": folio["has_children"], | |
| 458 | + | "owner": person_json(&folio["owner"]), | |
| 459 | + | "created_by": person_json(&folio["created_by"]), | |
| 460 | + | "created_at": folio["created_at"], | |
| 461 | + | "updated_at": folio["updated_at"], | |
| 462 | + | "edited_by": person_json(&folio["edited_by"]), | |
| 463 | + | "edited_at": folio["edited_at"], | |
| 464 | + | "trashed_at": folio["trashed_at"], | |
| 465 | + | "viewer_role": folio["viewer_role"], | |
| 466 | + | "private": folio["private"], | |
| 467 | + | "shared_count": folio["shared_count"], | |
| 468 | + | "general_access": folio["general_access"], | |
| 469 | + | "general_role": folio["general_role"], | |
| 470 | + | "inherit": folio["inherit"], | |
| 471 | + | "agent_mode": folio["agent_mode"], | |
| 472 | + | "excerpt": folio["excerpt"], | |
| 473 | + | "source": folio["source"], | |
| 474 | + | "stale": folio["stale"], | |
| 475 | + | "html_url": html_url(site, &folio["path"]), | |
| 476 | + | }) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | fn content_json(read: &Value, site: &str) -> Value { | |
| 480 | + | let mut artifact = ref_json(&read["folio"], site); | |
| 481 | + | artifact["edited_at"] = read["folio"]["edited_at"].clone(); | |
| 482 | + | let mut shown = json!({ | |
| 483 | + | "artifact": artifact, | |
| 484 | + | "space": read["space"], | |
| 485 | + | "content": read["content"], | |
| 486 | + | "can": read["can"], | |
| 487 | + | }); | |
| 488 | + | if let Some(blocks) = read.get("blocks").filter(|blocks| blocks.is_array()) { | |
| 489 | + | shown["blocks"] = blocks.clone(); | |
| 490 | + | } | |
| 491 | + | shown | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | fn suggestion_json(suggestion: &Value) -> Value { | |
| 495 | + | json!({ | |
| 496 | + | "id": suggestion["id"], | |
| 497 | + | "status": suggestion["status"], | |
| 498 | + | "target": suggestion["target"], | |
| 499 | + | "before_markdown": suggestion["before_markdown"], | |
| 500 | + | "after_markdown": suggestion["after_markdown"], | |
| 501 | + | "note": suggestion["note"], | |
| 502 | + | "author": person_json(&suggestion["author"]), | |
| 503 | + | "created_at": suggestion["created_at"], | |
| 504 | + | }) | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | fn edit_json(result: &Value, site: &str) -> Value { | |
| 508 | + | let mut shown = json!({ "mode": result["mode"], "artifact": ref_json(&result["folio"], site) }); | |
| 509 | + | match result["mode"].as_str() { | |
| 510 | + | Some("applied") => { | |
| 511 | + | shown["version_id"] = result["version_id"].clone(); | |
| 512 | + | shown["summary"] = result["summary"].clone(); | |
| 513 | + | } | |
| 514 | + | Some("suggested") => shown["suggestion"] = suggestion_json(&result["suggestion"]), | |
| 515 | + | _ => {} | |
| 516 | + | } | |
| 517 | + | shown | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | fn version_json(version: &Value) -> Value { | |
| 521 | + | json!({ | |
| 522 | + | "id": version["id"], | |
| 523 | + | "artifact_id": version["folio_id"], | |
| 524 | + | "created_at": version["created_at"], | |
| 525 | + | "kind": version["kind"], | |
| 526 | + | "note": version["note"], | |
| 527 | + | "authors": people_json(&version["authors"]), | |
| 528 | + | }) | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | fn access_json(list: &Value) -> Value { | |
| 532 | + | let rows: Vec<Value> = list["rows"] | |
| 533 | + | .as_array() | |
| 534 | + | .map(|rows| { | |
| 535 | + | rows.iter() | |
| 536 | + | .map(|row| { | |
| 537 | + | let inherited = match row["source"]["kind"].as_str() { | |
| 538 | + | Some("folio") => json!({ "artifact_id": row["source"]["id"], "title": row["source"]["title"] }), | |
| 539 | + | _ => Value::Null, | |
| 540 | + | }; | |
| 541 | + | json!({ "principal": row["principal"], "member": person_json(&row["profile"]), "role": row["role"], "inherited_from": inherited }) | |
| 542 | + | }) | |
| 543 | + | .collect() | |
| 544 | + | }) | |
| 545 | + | .unwrap_or_default(); | |
| 546 | + | let inherited_from = match &list["inherited_from"] { | |
| 547 | + | Value::Object(from) => json!({ "type": from.get("kind").and_then(Value::as_str).map(|kind| if kind == "folio" { "artifact" } else { kind }), "id": from.get("id"), "name": from.get("name") }), | |
| 548 | + | _ => Value::Null, | |
| 549 | + | }; | |
| 550 | + | json!({ | |
| 551 | + | "artifact_id": list["folio_id"], | |
| 552 | + | "owner": person_json(&list["owner"]), | |
| 553 | + | "shared_with": rows, | |
| 554 | + | "general_access": list["general_access"], | |
| 555 | + | "general_role": list["general_role"], | |
| 556 | + | "inherit": list["inherit"], | |
| 557 | + | "inherited_from": inherited_from, | |
| 558 | + | "agent_mode": list["agent_mode"], | |
| 559 | + | "can_share": list["can_share"], | |
| 560 | + | }) | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | fn template_json(template: &Value) -> Value { | |
| 564 | + | json!({ | |
| 565 | + | "id": template["id"], | |
| 566 | + | "kind": template["kind"], | |
| 567 | + | "name": template["name"], | |
| 568 | + | "description": template["description"], | |
| 569 | + | "icon": template["icon"], | |
| 570 | + | "builtin": template["builtin"], | |
| 571 | + | "body": template["body"], | |
| 572 | + | }) | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | fn space_json(space: &Value) -> Value { | |
| 576 | + | json!({ | |
| 577 | + | "id": space["id"], | |
| 578 | + | "slug": space["slug"], | |
| 579 | + | "name": space["name"], | |
| 580 | + | "description": space["description"], | |
| 581 | + | "icon": space["icon"], | |
| 582 | + | "kind": space["kind"], | |
| 583 | + | "is_default": space["is_default"], | |
| 584 | + | "joined": space["joined"], | |
| 585 | + | "viewer_role": space["viewer_role"], | |
| 586 | + | "artifact_count": space["page_count"], | |
| 587 | + | }) | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | fn hit_json(hit: &Value, site: &str) -> Value { | |
| 591 | + | let mut shown = ref_json(hit, site); | |
| 592 | + | for key in ["space_name", "snippet", "heading", "matched", "edited_at"] { | |
| 593 | + | shown[key] = hit.get(key).cloned().unwrap_or(Value::Null); | |
| 594 | + | } | |
| 595 | + | shown | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | fn mapped(outcome: Outcome<Value>, f: impl FnOnce(&Value) -> Value) -> Outcome<Value> { | |
| 599 | + | match outcome { | |
| 600 | + | Outcome::Ok(value) => Outcome::Ok(f(&value)), | |
| 601 | + | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 602 | + | } | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | fn listed(outcome: Outcome<Value>, f: impl Fn(&Value) -> Value) -> Outcome<Value> { | |
| 606 | + | mapped(outcome, |list| Value::Array(list.as_array().map(|list| list.iter().map(&f).collect()).unwrap_or_default())) | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | // --- Running ------------------------------------------------------------------ | |
| 610 | + | ||
| 611 | + | async fn call<T: DeserializeOwned>(services: &Services, method: &str, args: &impl Serialize) -> Result<Outcome<T>> { | |
| 612 | + | g1t_kit::call(&services.docs, method, args).await | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | /// The person acting, or the refusal: nobody, or a workspace's own token. | |
| 616 | + | pub(crate) fn person(viewer: &Viewer) -> std::result::Result<User, Outcome<Value>> { | |
| 617 | + | match viewer { | |
| 618 | + | None => Err(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")), | |
| 619 | + | Some(user) if user.kind == PrincipalKind::Workspace => Err(Outcome::fail( | |
| 620 | + | FailureCode::Forbidden, | |
| 621 | + | "Artifacts belong to people: use a personal access token, or a g1t agent's.", | |
| 622 | + | )), | |
| 623 | + | Some(user) => Ok(user.clone()), | |
| 624 | + | } | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | /// A space's id from its slug or id, among the spaces in the person's | |
| 628 | + | /// sidebar; an id passes as given. `Ok(None)` for `private`. | |
| 629 | + | async fn space_id(services: &Services, workspace: &str, viewer: &User, given: &str) -> Result<std::result::Result<Option<String>, Outcome<Value>>> { | |
| 630 | + | let given = given.trim(); | |
| 631 | + | if given.eq_ignore_ascii_case("private") { | |
| 632 | + | return Ok(Ok(None)); | |
| 633 | + | } | |
| 634 | + | if given.starts_with("spc_") { | |
| 635 | + | return Ok(Ok(Some(given.to_owned()))); | |
| 636 | + | } | |
| 637 | + | let sidebar: Outcome<Value> = call(services, "folio_sidebar", &json!({ "workspace": workspace, "viewer": viewer })).await?; | |
| 638 | + | let sidebar = match sidebar { | |
| 639 | + | Outcome::Ok(sidebar) => sidebar, | |
| 640 | + | Outcome::Fail(refused) => return Ok(Err(Outcome::Fail(refused))), | |
| 641 | + | }; | |
| 642 | + | let found = sidebar["spaces"] | |
| 643 | + | .as_array() | |
| 644 | + | .and_then(|spaces| spaces.iter().find(|space| space["slug"].as_str().is_some_and(|slug| slug.eq_ignore_ascii_case(given)))) | |
| 645 | + | .and_then(|space| space["id"].as_str().map(str::to_owned)); | |
| 646 | + | Ok(match found { | |
| 647 | + | Some(id) => Ok(Some(id)), | |
| 648 | + | None => Err(invalid(format!("There is no space called {given} in your sidebar: give its id, or join it first."))), | |
| 649 | + | }) | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | /// Who a share is for, as the docs service names them. | |
| 653 | + | async fn principal(services: &Services, input: &Value) -> Result<std::result::Result<Option<String>, Outcome<Value>>> { | |
| 654 | + | if let Some(principal) = text(input, "principal") { | |
| 655 | + | return Ok(Ok(Some(principal))); | |
| 656 | + | } | |
| 657 | + | if let Some(team) = text(input, "team") { | |
| 658 | + | let slug = team.trim_start_matches('@').rsplit('/').next().unwrap_or_default().to_lowercase(); | |
| 659 | + | return Ok(Ok(Some(format!("team:{slug}")))); | |
| 660 | + | } | |
| 661 | + | if let Some(agent) = text(input, "agent") { | |
| 662 | + | return Ok(Ok(Some(format!("agent:{agent}")))); | |
| 663 | + | } | |
| 664 | + | let Some(username) = text(input, "username").or_else(|| text(input, "user")) else { | |
| 665 | + | return Ok(Ok(None)); | |
| 666 | + | }; | |
| 667 | + | let username = username.trim_start_matches('@').to_lowercase(); | |
| 668 | + | let found: Viewer = g1t_kit::call(&services.identity, "user_by_username", &UsernameArgs { username: username.clone() }).await?; | |
| 669 | + | Ok(match found { | |
| 670 | + | Some(user) => Ok(Some(format!("user:{}", user.id))), | |
| 671 | + | None => Err(invalid(format!("There is no account named {username}."))), | |
| 672 | + | }) | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | pub async fn run(op: FoliosOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 676 | + | let site = services.addresses.site.clone(); | |
| 677 | + | let viewer = match person(viewer) { | |
| 678 | + | Ok(user) => user, | |
| 679 | + | Err(refused) => return Ok(refused), | |
| 680 | + | }; | |
| 681 | + | let Some(workspace) = text(input, "workspace").map(|w| w.to_lowercase()) else { | |
| 682 | + | return Ok(invalid("Give the workspace's slug.")); | |
| 683 | + | }; | |
| 684 | + | let kind = match kind_of(input) { | |
| 685 | + | Ok(kind) => kind, | |
| 686 | + | Err(message) => return Ok(invalid(message)), | |
| 687 | + | }; | |
| 688 | + | macro_rules! space { | |
| 689 | + | ($given:expr) => { | |
| 690 | + | match space_id(services, &workspace, &viewer, $given).await? { | |
| 691 | + | Ok(id) => id, | |
| 692 | + | Err(refused) => return Ok(refused), | |
| 693 | + | } | |
| 694 | + | }; | |
| 695 | + | } | |
| 696 | + | let folio_id = match op { | |
| 697 | + | FoliosOp::List | FoliosOp::Search | FoliosOp::ListTemplates | FoliosOp::ListSpaces | FoliosOp::QueryDataset | FoliosOp::Create => String::new(), | |
| 698 | + | _ => match text(input, "artifact_id").as_deref().and_then(artifact_id) { | |
| 699 | + | Some(id) => id, | |
| 700 | + | None => return Ok(invalid("Give the artifact_id: its id (fol_…) or its address.")), | |
| 701 | + | }, | |
| 702 | + | }; | |
| 703 | + | let one = || FolioArgs { workspace: workspace.clone(), viewer: viewer.clone(), folio_id: folio_id.clone() }; | |
| 704 | + | let shown = |folio: &Value| folio_json(folio, &site); | |
| 705 | + | Ok(match op { | |
| 706 | + | FoliosOp::List => { | |
| 707 | + | if text(input, "state").as_deref() == Some("trashed") { | |
| 708 | + | let found: Outcome<Value> = call(services, "folio_trash", &json!({ "workspace": workspace, "viewer": viewer })).await?; | |
| 709 | + | return Ok(mapped(found, |list| json!({ "items": list.as_array().map(|list| list.iter().map(shown).collect::<Vec<_>>()).unwrap_or_default(), "next_cursor": null }))); | |
| 710 | + | } | |
| 711 | + | let tab = match text(input, "tab").map(|tab| tab.to_lowercase()).as_deref() { | |
| 712 | + | None | Some("all") => FolioListTab::All, | |
| 713 | + | Some("yours") | Some("mine") => FolioListTab::Yours, | |
| 714 | + | Some("shared") => FolioListTab::Shared, | |
| 715 | + | Some(other) => return Ok(invalid(format!("{other} is not a tab: all, yours or shared."))), | |
| 716 | + | }; | |
| 717 | + | let space_id = match text(input, "space") { | |
| 718 | + | Some(given) => space!(&given), | |
| 719 | + | None => None, | |
| 720 | + | }; | |
| 721 | + | let limit = number(input, "limit"); | |
| 722 | + | let query = FolioListQuery { | |
| 723 | + | tab, | |
| 724 | + | kinds: kind.map(|kind| vec![kind]), | |
| 725 | + | space_id, | |
| 726 | + | owner: None, | |
| 727 | + | project: text(input, "project"), | |
| 728 | + | q: text(input, "q"), | |
| 729 | + | cursor: text(input, "cursor"), | |
| 730 | + | limit, | |
| 731 | + | }; | |
| 732 | + | if let Err(message) = query.validate() { | |
| 733 | + | return Ok(invalid(message)); | |
| 734 | + | } | |
| 735 | + | let found: Outcome<Value> = call(services, "folio_list", &FolioListArgs { workspace, viewer, query }).await?; | |
| 736 | + | mapped(found, |list| { | |
| 737 | + | json!({ | |
| 738 | + | "items": list["items"].as_array().map(|items| items.iter().map(shown).collect::<Vec<_>>()).unwrap_or_default(), | |
| 739 | + | "next_cursor": list["next_cursor"], | |
| 740 | + | }) | |
| 741 | + | }) | |
| 742 | + | } | |
| 743 | + | FoliosOp::Search => { | |
| 744 | + | let Some(q) = text(input, "q") else { return Ok(invalid("Give q: what to search for.")) }; | |
| 745 | + | let space_id = match text(input, "space") { | |
| 746 | + | Some(given) => space!(&given), | |
| 747 | + | None => None, | |
| 748 | + | }; | |
| 749 | + | let limit = number(input, "limit").map(|l| l.clamp(1, 50)); | |
| 750 | + | let query = FolioSearchQuery { q, kinds: kind.map(|kind| vec![kind]), space_id, project: text(input, "project"), owner: None, mode: Some("hybrid".to_owned()), limit }; | |
| 751 | + | let found: Outcome<Value> = call(services, "search_folios", &SearchFoliosArgs { workspace, viewer, query }).await?; | |
| 752 | + | listed(found, |hit| hit_json(hit, &site)) | |
| 753 | + | } | |
| 754 | + | FoliosOp::Get => mapped(call(services, "folio", &one()).await?, shown), | |
| 755 | + | FoliosOp::GetContent => mapped(call(services, "folio_content", &one()).await?, |read| content_json(read, &site)), | |
| 756 | + | FoliosOp::ListVersions => listed(call(services, "folio_versions", &one()).await?, version_json), | |
| 757 | + | FoliosOp::GetAccess => mapped(call(services, "folio_access", &one()).await?, access_json), | |
| 758 | + | FoliosOp::ListTemplates => { | |
| 759 | + | let found: Outcome<Value> = call(services, "folio_templates", &FolioTemplatesArgs { workspace, viewer, kind }).await?; | |
| 760 | + | listed(found, template_json) | |
| 761 | + | } | |
| 762 | + | FoliosOp::ListSpaces => { | |
| 763 | + | let found: Outcome<Value> = call(services, "folio_sidebar", &json!({ "workspace": workspace, "viewer": viewer })).await?; | |
| 764 | + | mapped(found, |sidebar| Value::Array(sidebar["spaces"].as_array().map(|spaces| spaces.iter().map(space_json).collect()).unwrap_or_default())) | |
| 765 | + | } | |
| 766 | + | FoliosOp::QueryDataset => { | |
| 767 | + | let query: DatasetQuery = match serde_json::from_value(input["query"].clone()) { | |
| 768 | + | Ok(query) => query, | |
| 769 | + | Err(error) => return Ok(invalid(format!("That query does not read: {error}."))), | |
| 770 | + | }; | |
| 771 | + | if let Err(message) = query.validate() { | |
| 772 | + | return Ok(invalid(message)); | |
| 773 | + | } | |
| 774 | + | call(services, "query_dataset", &QueryDatasetArgs { workspace, viewer, query }).await? | |
| 775 | + | } | |
| 776 | + | FoliosOp::Create => { | |
| 777 | + | let kind = kind.unwrap_or(FolioKind::Doc); | |
| 778 | + | let space_id = match text(input, "space") { | |
| 779 | + | Some(given) => space!(&given), | |
| 780 | + | None => None, | |
| 781 | + | }; | |
| 782 | + | let content = input["markdown"].as_str().map(|markdown| FolioContentInput { markdown: Some(markdown.to_owned()), spec: None }); | |
| 783 | + | let new = NewFolio { | |
| 784 | + | kind, | |
| 785 | + | title: text(input, "title"), | |
| 786 | + | icon: text(input, "icon"), | |
| 787 | + | space_id, | |
| 788 | + | parent_id: text(input, "parent_id"), | |
| 789 | + | template_id: text(input, "template_id"), | |
| 790 | + | content, | |
| 791 | + | source: None, | |
| 792 | + | share_with: None, | |
| 793 | + | }; | |
| 794 | + | if let Err(message) = new.validate() { | |
| 795 | + | return Ok(invalid(message)); | |
| 796 | + | } | |
| 797 | + | mapped(call(services, "create_folio", &CreateFolioArgs { workspace, viewer, input: new }).await?, shown) | |
| 798 | + | } | |
| 799 | + | FoliosOp::Update => { | |
| 800 | + | let mut change = FolioChange::default(); | |
| 801 | + | if let Some(title) = input["title"].as_str() { | |
| 802 | + | if title.chars().count() > MAX_TITLE { | |
| 803 | + | return Ok(invalid(format!("A title is at most {MAX_TITLE} characters."))); | |
| 804 | + | } | |
| 805 | + | change.title = Some(title.to_owned()); | |
| 806 | + | } | |
| 807 | + | if let Some(icon) = input.get("icon") { | |
| 808 | + | change.icon = Some(icon.as_str().map(str::to_owned)); | |
| 809 | + | } | |
| 810 | + | let moving = input.get("space").is_some_and(|v| !v.is_null()) || input.get("parent_id").is_some_and(|v| !v.is_null()); | |
| 811 | + | if change == FolioChange::default() && !moving { | |
| 812 | + | return Ok(invalid("Say what to change: title, icon, space or parent_id.")); | |
| 813 | + | } | |
| 814 | + | let mut answer: Option<Outcome<Value>> = None; | |
| 815 | + | if change != FolioChange::default() { | |
| 816 | + | let changed: Outcome<Value> = call(services, "update_folio", &UpdateFolioArgs { workspace: workspace.clone(), viewer: viewer.clone(), folio_id: folio_id.clone(), change }).await?; | |
| 817 | + | if let Outcome::Fail(_) = changed { | |
| 818 | + | return Ok(changed); | |
| 819 | + | } | |
| 820 | + | answer = Some(changed); | |
| 821 | + | } | |
| 822 | + | if moving { | |
| 823 | + | let parent_id = text(input, "parent_id"); | |
| 824 | + | let space_id = match (&parent_id, text(input, "space")) { | |
| 825 | + | (None, Some(given)) => space!(&given), | |
| 826 | + | _ => None, | |
| 827 | + | }; | |
| 828 | + | let to = FolioMove { space_id, parent_id, before_id: text(input, "before_id") }; | |
| 829 | + | answer = Some(call(services, "move_folio", &MoveFolioArgs { workspace, viewer, folio_id, to }).await?); | |
| 830 | + | } | |
| 831 | + | mapped(answer.unwrap_or_else(|| invalid("Nothing to change.")), shown) | |
| 832 | + | } | |
| 833 | + | FoliosOp::Edit => { | |
| 834 | + | let edit = match agent_edit(input) { | |
| 835 | + | Ok(edit) => edit, | |
| 836 | + | Err(message) => return Ok(invalid(message)), | |
| 837 | + | }; | |
| 838 | + | mapped(call(services, "edit_folio", &EditFolioArgs { workspace, viewer, folio_id, edit }).await?, |result| edit_json(result, &site)) | |
| 839 | + | } | |
| 840 | + | FoliosOp::Trash => mapped(call(services, "trash_folio", &one()).await?, shown), | |
| 841 | + | FoliosOp::Restore => mapped(call(services, "restore_folio", &one()).await?, shown), | |
| 842 | + | FoliosOp::Purge => mapped(call(services, "delete_folio", &one()).await?, |_| json!({ "deleted": true })), | |
| 843 | + | FoliosOp::RestoreVersion => { | |
| 844 | + | let Some(version_id) = text(input, "version_id") else { return Ok(invalid("Give the version_id.")) }; | |
| 845 | + | let args = FolioVersionArgs { workspace, viewer, folio_id, version_id }; | |
| 846 | + | mapped(call(services, "restore_folio_version", &args).await?, version_json) | |
| 847 | + | } | |
| 848 | + | FoliosOp::SetAccess => { | |
| 849 | + | let principal = match principal(services, input).await? { | |
| 850 | + | Ok(principal) => principal, | |
| 851 | + | Err(refused) => return Ok(refused), | |
| 852 | + | }; | |
| 853 | + | let changes = match access_changes(input, principal) { | |
| 854 | + | Ok(changes) => changes, | |
| 855 | + | Err(message) => return Ok(invalid(message)), | |
| 856 | + | }; | |
| 857 | + | let mut last: Outcome<Value> = invalid("Nothing to change."); | |
| 858 | + | for change in changes { | |
| 859 | + | let method = change.method(); | |
| 860 | + | let args = FolioAccessArgs { workspace: workspace.clone(), viewer: viewer.clone(), folio_id: folio_id.clone(), change }; | |
| 861 | + | last = call(services, method, &args).await?; | |
| 862 | + | if let Outcome::Fail(_) = last { | |
| 863 | + | return Ok(last); | |
| 864 | + | } | |
| 865 | + | } | |
| 866 | + | mapped(last, access_json) | |
| 867 | + | } | |
| 868 | + | }) | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | #[cfg(test)] | |
| 872 | + | mod tests { | |
| 873 | + | use super::*; | |
| 874 | + | ||
| 875 | + | fn folio() -> Value { | |
| 876 | + | json!({ | |
| 877 | + | "id": "fol_0123456789abcdefghjkmnpqrs", | |
| 878 | + | "kind": "doc", | |
| 879 | + | "title": "Q4 roadmap", | |
| 880 | + | "icon": null, | |
| 881 | + | "slug": "q4-roadmap-fol_0123456789abcdefghjkmnpqrs", | |
| 882 | + | "path": "/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs", | |
| 883 | + | "workspace_id": "ws_1", | |
| 884 | + | "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" }, | |
| 885 | + | "parent_id": null, | |
| 886 | + | "position": 1024, | |
| 887 | + | "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana", "avatar": null, "role": null }, | |
| 888 | + | "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t", "avatar": null, "role": null }, | |
| 889 | + | "created_at": "2026-10-01T00:00:00.000Z", | |
| 890 | + | "updated_at": "2026-10-02T00:00:00.000Z", | |
| 891 | + | "edited_by": null, | |
| 892 | + | "edited_at": "2026-10-02T00:00:00.000Z", | |
| 893 | + | "trashed_at": null, | |
| 894 | + | "viewer_role": "manage", | |
| 895 | + | "favorite": false, | |
| 896 | + | "private": false, | |
| 897 | + | "shared_count": 2, | |
| 898 | + | "general_access": "workspace", | |
| 899 | + | "general_role": "view", | |
| 900 | + | "inherit": true, | |
| 901 | + | "inherited_from": null, | |
| 902 | + | "agent_mode": "suggest", | |
| 903 | + | "excerpt": "What we ship.", | |
| 904 | + | "preview": null, | |
| 905 | + | "source": null, | |
| 906 | + | "stale": false, | |
| 907 | + | "has_children": false, | |
| 908 | + | }) | |
| 909 | + | } | |
| 910 | + | ||
| 911 | + | #[test] | |
| 912 | + | fn an_artifact_is_snake_case_with_its_page() { | |
| 913 | + | let shown = folio_json(&folio(), "https://g1t.sh/"); | |
| 914 | + | assert_eq!(shown["html_url"], "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs"); | |
| 915 | + | assert_eq!(shown["owner"], json!({ "type": "user", "id": "usr_1", "username": "ana", "display_name": "Ana" })); | |
| 916 | + | assert_eq!(shown["created_by"]["type"], "agent"); | |
| 917 | + | assert_eq!(shown["edited_by"], Value::Null); | |
| 918 | + | assert_eq!(shown["space"]["slug"], "general"); | |
| 919 | + | assert!(shown.get("preview").is_none() && shown.get("workspace_id").is_none()); | |
| 920 | + | assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty()); | |
| 921 | + | } | |
| 922 | + | ||
| 923 | + | #[test] | |
| 924 | + | fn an_artifact_is_named_by_its_id_or_any_address_ending_in_it() { | |
| 925 | + | let id = "fol_0123456789abcdefghjkmnpqrs"; | |
| 926 | + | assert_eq!(artifact_id(id).as_deref(), Some(id)); | |
| 927 | + | assert_eq!(artifact_id("q4-roadmap-fol_0123456789abcdefghjkmnpqrs").as_deref(), Some(id)); | |
| 928 | + | assert_eq!(artifact_id("https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs?x=1#h").as_deref(), Some(id)); | |
| 929 | + | assert_eq!(artifact_id("/acme/-/artifacts/q4-roadmap-fol_0123456789abcdefghjkmnpqrs/").as_deref(), Some(id)); | |
| 930 | + | assert_eq!(artifact_id("q4-roadmap"), None); | |
| 931 | + | assert_eq!(artifact_id("../fol_x"), None); | |
| 932 | + | } | |
| 933 | + | ||
| 934 | + | #[test] | |
| 935 | + | fn an_edit_is_a_doc_edit_unless_it_says_otherwise() { | |
| 936 | + | let edit = agent_edit(&json!({ "markdown": "## Next\n\nMore." })).unwrap(); | |
| 937 | + | assert_eq!(edit, json!({ "kind": "doc", "markdown": "## Next\n\nMore.", "target": { "kind": "append" } })); | |
| 938 | + | let edit = agent_edit(&json!({ "markdown": "x", "target": "Document", "note": "Rewrite", "suggest_only": true })).unwrap(); | |
| 939 | + | assert_eq!(edit["target"], json!({ "kind": "document" })); | |
| 940 | + | assert_eq!(edit["note"], "Rewrite"); | |
| 941 | + | assert_eq!(edit["suggest_only"], true); | |
| 942 | + | let edit = agent_edit(&json!({ "markdown": "x", "target": { "kind": "section", "heading": "Risks" } })).unwrap(); | |
| 943 | + | assert_eq!(edit["target"]["heading"], "Risks"); | |
| 944 | + | assert_eq!(agent_edit(&json!({ "markdown": "x", "target": { "kind": "section" } })), Err("A section target names its heading.".to_owned())); | |
| 945 | + | assert!(agent_edit(&json!({})).unwrap_err().contains("markdown")); | |
| 946 | + | // Other kinds carry ops, which the docs service checks (and refuses | |
| 947 | + | // until each kind ships). | |
| 948 | + | let edit = agent_edit(&json!({ "kind": "slides", "ops": [{ "op": "add_slide" }] })).unwrap(); | |
| 949 | + | assert_eq!(edit["kind"], "slides"); | |
| 950 | + | assert_eq!(agent_edit(&json!({ "kind": "slides" })), Err("A deck edit has a list of ops.".to_owned())); | |
| 951 | + | assert!(agent_edit(&json!({ "kind": "poster", "markdown": "x" })).unwrap_err().contains("poster")); | |
| 952 | + | } | |
| 953 | + | ||
| 954 | + | #[test] | |
| 955 | + | fn sharing_reads_one_change_of_each_kind_in_order() { | |
| 956 | + | let changes = access_changes( | |
| 957 | + | &json!({ "role": "edit", "notify": "Have a look", "general_access": "workspace", "inherit": false, "agent_mode": null }), | |
| 958 | + | Some("user:usr_2".to_owned()), | |
| 959 | + | ) | |
| 960 | + | .unwrap(); | |
| 961 | + | assert_eq!( | |
| 962 | + | changes, | |
| 963 | + | vec![ | |
| 964 | + | FolioAccessChange::Grant { principal: "user:usr_2".into(), role: FolioRole::Edit, notify: Some("Have a look".into()) }, | |
| 965 | + | FolioAccessChange::General { access: GeneralAccess::Workspace, role: Some(FolioRole::View) }, | |
| 966 | + | FolioAccessChange::Inherit { inherit: false }, | |
| 967 | + | FolioAccessChange::AgentMode { agent_mode: None }, | |
| 968 | + | ] | |
| 969 | + | ); | |
| 970 | + | assert_eq!(changes[0].method(), "set_folio_grant"); | |
| 971 | + | assert_eq!(changes[1].method(), "set_folio_general_access"); | |
| 972 | + | assert_eq!( | |
| 973 | + | access_changes(&json!({ "role": "none" }), Some("team:design".into())).unwrap(), | |
| 974 | + | vec![FolioAccessChange::Revoke { principal: "team:design".into() }] | |
| 975 | + | ); | |
| 976 | + | assert_eq!( | |
| 977 | + | access_changes(&json!({ "general_access": "none", "general_role": "edit" }), None).unwrap(), | |
| 978 | + | vec![FolioAccessChange::General { access: GeneralAccess::None, role: None }] | |
| 979 | + | ); | |
| 980 | + | // General access never gives full access; that is shared by name. | |
| 981 | + | assert_eq!( | |
| 982 | + | access_changes(&json!({ "general_access": "link", "general_role": "manage" }), None), | |
| 983 | + | Err("General access gives view, comment or edit, never full access.".to_owned()) | |
| 984 | + | ); | |
| 985 | + | assert!(access_changes(&json!({ "role": "edit" }), None).unwrap_err().contains("who")); | |
| 986 | + | assert!(access_changes(&json!({}), Some("user:usr_2".into())).unwrap_err().contains("role")); | |
| 987 | + | assert!(access_changes(&json!({}), None).unwrap_err().starts_with("Say what to change")); | |
| 988 | + | assert!(access_changes(&json!({ "role": "owner" }), Some("user:usr_2".into())).unwrap_err().contains("owner")); | |
| 989 | + | assert!(access_changes(&json!({ "role": "view" }), Some("someone".into())).unwrap_err().contains("user:, agent: or team:")); | |
| 990 | + | } | |
| 991 | + | ||
| 992 | + | #[test] | |
| 993 | + | fn a_workspace_token_is_refused_and_nobody_is_asked_to_sign_in() { | |
| 994 | + | let workspace = User { id: "ws_1".into(), username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() }; | |
| 995 | + | let Err(Outcome::Fail(refused)) = person(&Some(workspace)) else { panic!("a workspace token") }; | |
| 996 | + | assert_eq!(refused.code, FailureCode::Forbidden); | |
| 997 | + | let Err(Outcome::Fail(refused)) = person(&None) else { panic!("nobody") }; | |
| 998 | + | assert_eq!(refused.code, FailureCode::Unauthenticated); | |
| 999 | + | assert!(person(&Some(User { id: "usr_1".into(), username: "ana".into(), ..User::default() })).is_ok()); | |
| 1000 | + | } | |
| 1001 | + | ||
| 1002 | + | #[test] | |
| 1003 | + | fn answers_are_snake_case_and_name_people_the_api_way() { | |
| 1004 | + | let site = "https://g1t.sh"; | |
| 1005 | + | let access = access_json(&json!({ | |
| 1006 | + | "folio_id": "fol_1", | |
| 1007 | + | "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" }, | |
| 1008 | + | "rows": [ | |
| 1009 | + | { "principal": "team:design", "profile": { "kind": "team", "id": "design", "name": "design", "display_name": "@acme/design" }, "role": "edit", "source": { "kind": "folio", "id": "fol_0", "title": "Plans", "path": "/acme/-/artifacts/plans-fol_0" } }, | |
| 1010 | + | { "principal": "user:usr_2", "profile": { "kind": "user", "id": "usr_2", "name": "bo", "display_name": "Bo" }, "role": "view", "source": { "kind": "grant" } }, | |
| 1011 | + | ], | |
| 1012 | + | "general_access": "none", | |
| 1013 | + | "general_role": null, | |
| 1014 | + | "inherit": true, | |
| 1015 | + | "inherited_from": { "kind": "folio", "id": "fol_0", "name": "Plans" }, | |
| 1016 | + | "agent_mode": null, | |
| 1017 | + | "can_share": true, | |
| 1018 | + | "public_link": "off", | |
| 1019 | + | })); | |
| 1020 | + | assert_eq!(access["shared_with"][0]["member"], json!({ "type": "team", "slug": "design", "display_name": "@acme/design" })); | |
| 1021 | + | assert_eq!(access["shared_with"][0]["inherited_from"]["artifact_id"], "fol_0"); | |
| 1022 | + | assert_eq!(access["shared_with"][1]["inherited_from"], Value::Null); | |
| 1023 | + | assert_eq!(access["inherited_from"]["type"], "artifact"); | |
| 1024 | + | assert!(access.get("public_link").is_none()); | |
| 1025 | + | let content = content_json( | |
| 1026 | + | &json!({ "folio": { "id": "fol_1", "kind": "doc", "title": "T", "icon": null, "slug": "t-fol_1", "path": "/acme/-/artifacts/t-fol_1", "edited_at": "2026-10-02T00:00:00.000Z" }, "space": null, "content": "# T", "blocks": [{ "id": "b1", "type": "heading", "level": 1, "markdown": "# T" }], "can": { "read": true, "suggest": true, "edit": true }, "audience_can_read": true }), | |
| 1027 | + | site, | |
| 1028 | + | ); | |
| 1029 | + | assert_eq!(content["artifact"]["html_url"], "https://g1t.sh/acme/-/artifacts/t-fol_1"); | |
| 1030 | + | assert!(content.get("audience_can_read").is_none()); | |
| 1031 | + | let edit = edit_json(&json!({ "mode": "applied", "version_id": "ver_1", "folio": { "id": "fol_1", "path": "/acme/-/artifacts/t-fol_1" }, "summary": "Added a section." }), site); | |
| 1032 | + | assert_eq!(edit["version_id"], "ver_1"); | |
| 1033 | + | for shown in [access, content, edit, version_json(&json!({ "id": "ver_1", "folio_id": "fol_1", "authors": [{ "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" }] }))] { | |
| 1034 | + | assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty(), "{shown}"); | |
| 1035 | + | } | |
| 1036 | + | } | |
| 1037 | + | ||
| 1038 | + | #[test] | |
| 1039 | + | fn each_operation_is_described_with_a_schema_and_a_scope() { | |
| 1040 | + | use g1t_contracts::scopes::{Level, Scope, scope_for}; | |
| 1041 | + | for op in FoliosOp::ALL { | |
| 1042 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Folios(op)), "{}", op.name()); | |
| 1043 | + | assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name()); | |
| 1044 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); | |
| 1045 | + | let scope = scope_for(op.name()).unwrap(); | |
| 1046 | + | assert_eq!(scope.resource(), g1t_contracts::scopes::Resource::Artifacts, "{}", op.name()); | |
| 1047 | + | assert_eq!(op.writes(), scope.level() != Level::Read, "{}", op.name()); | |
| 1048 | + | } | |
| 1049 | + | // Sharing and deleting for good are the admin scope's alone. | |
| 1050 | + | let admin: Vec<FoliosOp> = FoliosOp::ALL.into_iter().filter(|op| scope_for(op.name()) == Some(Scope::ArtifactsAdmin)).collect(); | |
| 1051 | + | assert_eq!(admin, vec![FoliosOp::SetAccess, FoliosOp::Purge]); | |
| 1052 | + | } | |
| 1053 | + | } |
| 22 | 22 | mod oauth; | |
| 23 | 23 | mod oidc; | |
| 24 | 24 | mod packages; | |
| 25 | + | mod folios; | |
| 25 | 26 | mod people; | |
| 26 | 27 | mod openapi; | |
| 27 | 28 | mod pins; |
| 13 | 13 | use crate::mirrors::MirrorsOp; | |
| 14 | 14 | use crate::deployments::DeploymentsOp; | |
| 15 | 15 | use crate::packages::PackagesOp; | |
| 16 | + | use crate::folios::FoliosOp; | |
| 16 | 17 | use crate::protection::ProtectionOp; | |
| 17 | 18 | use crate::token_policy::TokenOp; | |
| 18 | 19 | use crate::operations::Op; | |
| ⋯ | |||
| 536 | 537 | Op::ImportIssue, | |
| 537 | 538 | ], | |
| 538 | 539 | ), | |
| 540 | + | ( | |
| 541 | + | "Artifacts", | |
| 542 | + | "A workspace's docs, slides, designs and dashboards (Artifacts mode): listing and searching the ones you can open, reading and editing their content in Markdown, making, moving, trashing and restoring them, their versions, and who can open them. Each answers for your own role on it. Not workflow runs' artifacts, which are under Actions. Slides, designs and dashboards answer that they are not here yet.", | |
| 543 | + | &[ | |
| 544 | + | Op::Folios(FoliosOp::List), | |
| 545 | + | Op::Folios(FoliosOp::Search), | |
| 546 | + | Op::Folios(FoliosOp::Get), | |
| 547 | + | Op::Folios(FoliosOp::GetContent), | |
| 548 | + | Op::Folios(FoliosOp::ListVersions), | |
| 549 | + | Op::Folios(FoliosOp::GetAccess), | |
| 550 | + | Op::Folios(FoliosOp::ListTemplates), | |
| 551 | + | Op::Folios(FoliosOp::ListSpaces), | |
| 552 | + | Op::Folios(FoliosOp::QueryDataset), | |
| 553 | + | Op::Folios(FoliosOp::Create), | |
| 554 | + | Op::Folios(FoliosOp::Update), | |
| 555 | + | Op::Folios(FoliosOp::Edit), | |
| 556 | + | Op::Folios(FoliosOp::Trash), | |
| 557 | + | Op::Folios(FoliosOp::Restore), | |
| 558 | + | Op::Folios(FoliosOp::RestoreVersion), | |
| 559 | + | Op::Folios(FoliosOp::SetAccess), | |
| 560 | + | Op::Folios(FoliosOp::Purge), | |
| 561 | + | ], | |
| 562 | + | ), | |
| 539 | 563 | ]; | |
| 540 | 564 | ||
| 541 | 565 | /// The section of the API reference an operation is listed under. | |
| ⋯ | |||
| 752 | 776 | Op::DeployKeys(op) => op.title(), | |
| 753 | 777 | Op::Mirrors(op) => op.title(), | |
| 754 | 778 | Op::Packages(op) => op.title(), | |
| 779 | + | Op::Folios(op) => op.title(), | |
| 755 | 780 | } | |
| 756 | 781 | } | |
| 757 | 782 | ||
| 33 | 33 | use crate::mirrors::MirrorsOp; | |
| 34 | 34 | use crate::deployments::DeploymentsOp; | |
| 35 | 35 | use crate::packages::PackagesOp; | |
| 36 | + | use crate::folios::FoliosOp; | |
| 36 | 37 | use crate::protection::ProtectionOp; | |
| 37 | 38 | use crate::token_policy::TokenOp; | |
| 38 | 39 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 68 | 69 | pub deployments: Fetcher, | |
| 69 | 70 | /// Packages: their settings, versions, deleting and restoring them. | |
| 70 | 71 | pub packages: Fetcher, | |
| 72 | + | /// The docs service: Artifacts mode's docs, slides, designs and | |
| 73 | + | /// dashboards (folios), for the artifact routes and tool. | |
| 74 | + | pub docs: Fetcher, | |
| 71 | 75 | /// Where the request came in, for its audit entries. | |
| 72 | 76 | pub audit: crate::audit::AuditContext, | |
| 73 | 77 | /// Set for a request made with an agent's token: all it may do. | |
| ⋯ | |||
| 94 | 98 | projects: env.service("PROJECTS")?, | |
| 95 | 99 | deployments: env.service("DEPLOYMENTS")?, | |
| 96 | 100 | packages: env.service("PACKAGES")?, | |
| 101 | + | docs: env.service("DOCS")?, | |
| 97 | 102 | scope: None, | |
| 98 | 103 | audit: crate::audit::AuditContext::default(), | |
| 99 | 104 | addresses: crate::addresses::Addresses::from_env(env), | |
| ⋯ | |||
| 321 | 326 | /// A workspace's packages, their versions, deleting and restoring | |
| 322 | 327 | /// them, and who may use them: packages.rs. | |
| 323 | 328 | Packages(PackagesOp), | |
| 329 | + | /// Artifacts mode's docs, slides, designs and dashboards, kept by the | |
| 330 | + | /// docs service: folios.rs. | |
| 331 | + | Folios(FoliosOp), | |
| 324 | 332 | } | |
| 325 | 333 | ||
| 326 | 334 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| ⋯ | |||
| 690 | 698 | } | |
| 691 | 699 | ||
| 692 | 700 | impl Op { | |
| 693 | − | pub const ALL: [Op; 328] = [ | |
| 701 | + | pub const ALL: [Op; 345] = [ | |
| 694 | 702 | Op::Whoami, | |
| 695 | 703 | Op::GetWorkspace, | |
| 696 | 704 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1019 | 1027 | Op::Packages(PackagesOp::RestorePackage), | |
| 1020 | 1028 | Op::Packages(PackagesOp::DeleteVersion), | |
| 1021 | 1029 | Op::Packages(PackagesOp::RestoreVersion), | |
| 1030 | + | Op::Folios(FoliosOp::List), | |
| 1031 | + | Op::Folios(FoliosOp::Search), | |
| 1032 | + | Op::Folios(FoliosOp::Get), | |
| 1033 | + | Op::Folios(FoliosOp::GetContent), | |
| 1034 | + | Op::Folios(FoliosOp::ListVersions), | |
| 1035 | + | Op::Folios(FoliosOp::GetAccess), | |
| 1036 | + | Op::Folios(FoliosOp::ListTemplates), | |
| 1037 | + | Op::Folios(FoliosOp::ListSpaces), | |
| 1038 | + | Op::Folios(FoliosOp::QueryDataset), | |
| 1039 | + | Op::Folios(FoliosOp::Create), | |
| 1040 | + | Op::Folios(FoliosOp::Update), | |
| 1041 | + | Op::Folios(FoliosOp::Edit), | |
| 1042 | + | Op::Folios(FoliosOp::Trash), | |
| 1043 | + | Op::Folios(FoliosOp::Restore), | |
| 1044 | + | Op::Folios(FoliosOp::RestoreVersion), | |
| 1045 | + | Op::Folios(FoliosOp::SetAccess), | |
| 1046 | + | Op::Folios(FoliosOp::Purge), | |
| 1022 | 1047 | ]; | |
| 1023 | 1048 | ||
| 1024 | 1049 | pub fn by_name(name: &str) -> Option<Op> { | |
| ⋯ | |||
| 1231 | 1256 | Op::DeployKeys(op) => op.name(), | |
| 1232 | 1257 | Op::Mirrors(op) => op.name(), | |
| 1233 | 1258 | Op::Packages(op) => op.name(), | |
| 1259 | + | Op::Folios(op) => op.name(), | |
| 1234 | 1260 | } | |
| 1235 | 1261 | } | |
| 1236 | 1262 | ||
| ⋯ | |||
| 1787 | 1813 | Op::DeployKeys(op) => op.description(), | |
| 1788 | 1814 | Op::Mirrors(op) => op.description(), | |
| 1789 | 1815 | Op::Packages(op) => op.description(), | |
| 1816 | + | Op::Folios(op) => op.description(), | |
| 1790 | 1817 | } | |
| 1791 | 1818 | } | |
| 1792 | 1819 | ||
| ⋯ | |||
| 3272 | 3299 | Op::DeployKeys(op) => op.input(), | |
| 3273 | 3300 | Op::Mirrors(op) => op.input(), | |
| 3274 | 3301 | Op::Packages(op) => op.input(), | |
| 3302 | + | Op::Folios(op) => op.input(), | |
| 3275 | 3303 | } | |
| 3276 | 3304 | } | |
| 3277 | 3305 | ||
| ⋯ | |||
| 3346 | 3374 | if let Op::Packages(_) = self { | |
| 3347 | 3375 | return false; | |
| 3348 | 3376 | } | |
| 3377 | + | // An artifact belongs to its workspace. | |
| 3378 | + | if let Op::Folios(_) = self { | |
| 3379 | + | return false; | |
| 3380 | + | } | |
| 3349 | 3381 | if let Op::About(op) = self { | |
| 3350 | 3382 | return op.needs_repo(); | |
| 3351 | 3383 | } | |
| ⋯ | |||
| 5592 | 5624 | Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await, | |
| 5593 | 5625 | Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await, | |
| 5594 | 5626 | Op::Packages(op) => crate::packages::run(op, services, viewer, input).await, | |
| 5627 | + | Op::Folios(op) => crate::folios::run(op, services, viewer, input).await, | |
| 5595 | 5628 | Op::ReopenSecurityAlert => { | |
| 5596 | 5629 | let changed: Outcome<AlertChange> = call( | |
| 5597 | 5630 | &services.security, | |
| 12593 | 12593 | "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n" | |
| 12594 | 12594 | }, | |
| 12595 | 12595 | "response": true | |
| 12596 | + | }, | |
| 12597 | + | "list_workspace_artifacts": { | |
| 12598 | + | "params": { | |
| 12599 | + | "workspace": "acme" | |
| 12600 | + | }, | |
| 12601 | + | "query": { | |
| 12602 | + | "kind": "doc", | |
| 12603 | + | "space": "general" | |
| 12604 | + | }, | |
| 12605 | + | "response": { | |
| 12606 | + | "items": [ | |
| 12607 | + | { | |
| 12608 | + | "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12609 | + | "kind": "doc", | |
| 12610 | + | "title": "Q4 roadmap", | |
| 12611 | + | "icon": "🗺️", | |
| 12612 | + | "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12613 | + | "space": { | |
| 12614 | + | "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w", | |
| 12615 | + | "slug": "general", | |
| 12616 | + | "name": "General", | |
| 12617 | + | "kind": "workspace" | |
| 12618 | + | }, | |
| 12619 | + | "parent_id": null, | |
| 12620 | + | "has_children": false, | |
| 12621 | + | "owner": { | |
| 12622 | + | "type": "user", | |
| 12623 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 12624 | + | "username": "ana", | |
| 12625 | + | "display_name": "Ana Lima" | |
| 12626 | + | }, | |
| 12627 | + | "created_by": { | |
| 12628 | + | "type": "user", | |
| 12629 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 12630 | + | "username": "ana", | |
| 12631 | + | "display_name": "Ana Lima" | |
| 12632 | + | }, | |
| 12633 | + | "created_at": "2026-10-06T09:12:00.000Z", | |
| 12634 | + | "updated_at": "2026-10-08T16:40:12.000Z", | |
| 12635 | + | "edited_by": { | |
| 12636 | + | "type": "user", | |
| 12637 | + | "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2", | |
| 12638 | + | "username": "bo", | |
| 12639 | + | "display_name": "Bo Chen" | |
| 12640 | + | }, | |
| 12641 | + | "edited_at": "2026-10-08T16:40:12.000Z", | |
| 12642 | + | "trashed_at": null, | |
| 12643 | + | "viewer_role": "manage", | |
| 12644 | + | "private": false, | |
| 12645 | + | "shared_count": 1, | |
| 12646 | + | "general_access": "workspace", | |
| 12647 | + | "general_role": "view", | |
| 12648 | + | "inherit": true, | |
| 12649 | + | "agent_mode": "suggest", | |
| 12650 | + | "excerpt": "What we ship this quarter, and what we leave for next.", | |
| 12651 | + | "source": null, | |
| 12652 | + | "stale": false, | |
| 12653 | + | "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z" | |
| 12654 | + | } | |
| 12655 | + | ], | |
| 12656 | + | "next_cursor": null | |
| 12657 | + | }, | |
| 12658 | + | "notes": "Only artifacts you can open are listed, with `viewer_role` your own role on each. Page on with `cursor` set to `next_cursor` until it is null. With `state=trashed`, what you can restore from the trash. With `q`, the best matches first. These are Artifacts mode's docs, slides, designs and dashboards, not workflow runs' artifacts (`list_artifacts`)." | |
| 12659 | + | }, | |
| 12660 | + | "search_workspace_artifacts": { | |
| 12661 | + | "params": { | |
| 12662 | + | "workspace": "acme" | |
| 12663 | + | }, | |
| 12664 | + | "query": { | |
| 12665 | + | "q": "what ships in Q4" | |
| 12666 | + | }, | |
| 12667 | + | "response": [ | |
| 12668 | + | { | |
| 12669 | + | "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12670 | + | "kind": "doc", | |
| 12671 | + | "title": "Q4 roadmap", | |
| 12672 | + | "icon": "🗺️", | |
| 12673 | + | "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12674 | + | "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12675 | + | "space_name": "General", | |
| 12676 | + | "snippet": "We ship the merge queue and Artifacts in Q4; dashboards follow.", | |
| 12677 | + | "heading": "This quarter", | |
| 12678 | + | "matched": "both", | |
| 12679 | + | "edited_at": "2026-10-08T16:40:12.000Z" | |
| 12680 | + | } | |
| 12681 | + | ], | |
| 12682 | + | "notes": "Matches words and meaning, and is checked again against who can read each artifact now: one shared with you a minute ago is found, one taken away is not." | |
| 12683 | + | }, | |
| 12684 | + | "get_workspace_artifact": { | |
| 12685 | + | "params": { | |
| 12686 | + | "workspace": "acme", | |
| 12687 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z" | |
| 12688 | + | }, | |
| 12689 | + | "response": { | |
| 12690 | + | "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12691 | + | "kind": "doc", | |
| 12692 | + | "title": "Q4 roadmap", | |
| 12693 | + | "icon": "🗺️", | |
| 12694 | + | "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12695 | + | "space": { | |
| 12696 | + | "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w", | |
| 12697 | + | "slug": "general", | |
| 12698 | + | "name": "General", | |
| 12699 | + | "kind": "workspace" | |
| 12700 | + | }, | |
| 12701 | + | "parent_id": null, | |
| 12702 | + | "has_children": false, | |
| 12703 | + | "owner": { | |
| 12704 | + | "type": "user", | |
| 12705 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 12706 | + | "username": "ana", | |
| 12707 | + | "display_name": "Ana Lima" | |
| 12708 | + | }, | |
| 12709 | + | "created_by": { | |
| 12710 | + | "type": "user", | |
| 12711 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 12712 | + | "username": "ana", | |
| 12713 | + | "display_name": "Ana Lima" | |
| 12714 | + | }, | |
| 12715 | + | "created_at": "2026-10-06T09:12:00.000Z", | |
| 12716 | + | "updated_at": "2026-10-08T16:40:12.000Z", | |
| 12717 | + | "edited_by": { | |
| 12718 | + | "type": "user", | |
| 12719 | + | "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2", | |
| 12720 | + | "username": "bo", | |
| 12721 | + | "display_name": "Bo Chen" | |
| 12722 | + | }, | |
| 12723 | + | "edited_at": "2026-10-08T16:40:12.000Z", | |
| 12724 | + | "trashed_at": null, | |
| 12725 | + | "viewer_role": "manage", | |
| 12726 | + | "private": false, | |
| 12727 | + | "shared_count": 1, | |
| 12728 | + | "general_access": "workspace", | |
| 12729 | + | "general_role": "view", | |
| 12730 | + | "inherit": true, | |
| 12731 | + | "agent_mode": "suggest", | |
| 12732 | + | "excerpt": "What we ship this quarter, and what we leave for next.", | |
| 12733 | + | "source": null, | |
| 12734 | + | "stale": false, | |
| 12735 | + | "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z" | |
| 12736 | + | }, | |
| 12737 | + | "notes": "`artifact_id` is the id, or the artifact's address (`q4-roadmap-fol_…`, or its whole link). Not found when you cannot open it, the same as when it does not exist." | |
| 12738 | + | }, | |
| 12739 | + | "get_workspace_artifact_content": { | |
| 12740 | + | "params": { | |
| 12741 | + | "workspace": "acme", | |
| 12742 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z" | |
| 12743 | + | }, | |
| 12744 | + | "response": { | |
| 12745 | + | "artifact": { | |
| 12746 | + | "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12747 | + | "kind": "doc", | |
| 12748 | + | "title": "Q4 roadmap", | |
| 12749 | + | "icon": "🗺️", | |
| 12750 | + | "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12751 | + | "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12752 | + | "edited_at": "2026-10-08T16:40:12.000Z" | |
| 12753 | + | }, | |
| 12754 | + | "space": { | |
| 12755 | + | "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w", | |
| 12756 | + | "slug": "general", | |
| 12757 | + | "name": "General", | |
| 12758 | + | "agent_mode": "suggest" | |
| 12759 | + | }, | |
| 12760 | + | "content": "# Q4 roadmap\n\n## This quarter\n\nWe ship the merge queue and Artifacts.\n", | |
| 12761 | + | "blocks": [ | |
| 12762 | + | { | |
| 12763 | + | "id": "b_4f1c", | |
| 12764 | + | "type": "heading", | |
| 12765 | + | "level": 1, | |
| 12766 | + | "markdown": "# Q4 roadmap" | |
| 12767 | + | }, | |
| 12768 | + | { | |
| 12769 | + | "id": "b_9a02", | |
| 12770 | + | "type": "heading", | |
| 12771 | + | "level": 2, | |
| 12772 | + | "markdown": "## This quarter" | |
| 12773 | + | }, | |
| 12774 | + | { | |
| 12775 | + | "id": "b_77d3", | |
| 12776 | + | "type": "paragraph", | |
| 12777 | + | "level": null, | |
| 12778 | + | "markdown": "We ship the merge queue and Artifacts." | |
| 12779 | + | } | |
| 12780 | + | ], | |
| 12781 | + | "can": { | |
| 12782 | + | "read": true, | |
| 12783 | + | "suggest": true, | |
| 12784 | + | "edit": true | |
| 12785 | + | } | |
| 12786 | + | }, | |
| 12787 | + | "notes": "A doc's content is Markdown. `blocks` are its top-level blocks, whose ids an edit with a `blocks` target names. Slides, designs and dashboards answer `422` saying they are not here yet." | |
| 12788 | + | }, | |
| 12789 | + | "list_workspace_artifact_versions": { | |
| 12790 | + | "params": { | |
| 12791 | + | "workspace": "acme", | |
| 12792 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z" | |
| 12793 | + | }, | |
| 12794 | + | "response": [ | |
| 12795 | + | { | |
| 12796 | + | "id": "ver_01kq9e6g8j0m2p4r6t8v0x2z4b", | |
| 12797 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12798 | + | "created_at": "2026-10-08T16:40:12.000Z", | |
| 12799 | + | "kind": "edit", | |
| 12800 | + | "note": null, | |
| 12801 | + | "authors": [ | |
| 12802 | + | { | |
| 12803 | + | "type": "user", | |
| 12804 | + | "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2", | |
| 12805 | + | "username": "bo", | |
| 12806 | + | "display_name": "Bo Chen" | |
| 12807 | + | } | |
| 12808 | + | ] | |
| 12809 | + | }, | |
| 12810 | + | { | |
| 12811 | + | "id": "ver_01kq7c4e6g8j0m2p4r6t8v0x2y", | |
| 12812 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12813 | + | "created_at": "2026-10-06T09:12:00.000Z", | |
| 12814 | + | "kind": "created", | |
| 12815 | + | "note": null, | |
| 12816 | + | "authors": [ | |
| 12817 | + | { | |
| 12818 | + | "type": "user", | |
| 12819 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 12820 | + | "username": "ana", | |
| 12821 | + | "display_name": "Ana Lima" | |
| 12822 | + | } | |
| 12823 | + | ] | |
| 12824 | + | } | |
| 12825 | + | ] | |
| 12826 | + | }, | |
| 12827 | + | "get_workspace_artifact_access": { | |
| 12828 | + | "params": { | |
| 12829 | + | "workspace": "acme", | |
| 12830 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z" | |
| 12831 | + | }, | |
| 12832 | + | "response": { | |
| 12833 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 12834 | + | "owner": { | |
| 12835 | + | "type": "user", | |
| 12836 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 12837 | + | "username": "ana", | |
| 12838 | + | "display_name": "Ana Lima" | |
| 12839 | + | }, | |
| 12840 | + | "shared_with": [ | |
| 12841 | + | { | |
| 12842 | + | "principal": "user:usr_01kmb2d4f6h8k0m2p4r6t8v0x2", | |
| 12843 | + | "member": { | |
| 12844 | + | "type": "user", | |
| 12845 | + | "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2", | |
| 12846 | + | "username": "bo", | |
| 12847 | + | "display_name": "Bo Chen" | |
| 12848 | + | }, | |
| 12849 | + | "role": "edit", | |
| 12850 | + | "inherited_from": null | |
| 12851 | + | }, | |
| 12852 | + | { | |
| 12853 | + | "principal": "team:design", | |
| 12854 | + | "member": { | |
| 12855 | + | "type": "team", | |
| 12856 | + | "slug": "design", | |
| 12857 | + | "display_name": "@acme/design" | |
| 12858 | + | }, | |
| 12859 | + | "role": "comment", | |
| 12860 | + | "inherited_from": null | |
| 12861 | + | } | |
| 12862 | + | ], | |
| 12863 | + | "general_access": "workspace", | |
| 12864 | + | "general_role": "view", | |
| 12865 | + | "inherit": true, | |
| 12866 | + | "inherited_from": { | |
| 12867 | + | "type": "space", | |
| 12868 | + | "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w", | |
| 12869 | + | "name": "General" | |
| 12870 | + | }, | |
| 12871 | + | "agent_mode": null, | |
| 12872 | + | "can_share": true | |
| 12873 | + | }, | |
| 12874 | + | "notes": "Its owner always has full access and is not in `shared_with`. `inherited_from` on a row is the doc above it that gives that access; on the whole list, the space or doc it follows while `inherit` is true." | |
| 12875 | + | }, | |
| 12876 | + | "list_workspace_artifact_templates": { | |
| 12877 | + | "params": { | |
| 12878 | + | "workspace": "acme" | |
| 12879 | + | }, | |
| 12880 | + | "query": { | |
| 12881 | + | "kind": "doc" | |
| 12882 | + | }, | |
| 12883 | + | "response": [ | |
| 12884 | + | { | |
| 12885 | + | "id": "builtin:doc:meeting-notes", | |
| 12886 | + | "kind": "doc", | |
| 12887 | + | "name": "Meeting notes", | |
| 12888 | + | "description": "Who came, what was decided, and what happens next.", | |
| 12889 | + | "icon": "📝", | |
| 12890 | + | "builtin": true, | |
| 12891 | + | "body": "# Meeting notes\n\n## Attendees\n\n## Decisions\n\n## Next steps\n" | |
| 12892 | + | } | |
| 12893 | + | ] | |
| 12894 | + | }, | |
| 12895 | + | "list_workspace_artifact_spaces": { | |
| 12896 | + | "params": { | |
| 12897 | + | "workspace": "acme" | |
| 12898 | + | }, | |
| 12899 | + | "response": [ | |
| 12900 | + | { | |
| 12901 | + | "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w", | |
| 12902 | + | "slug": "general", | |
| 12903 | + | "name": "General", | |
| 12904 | + | "description": null, | |
| 12905 | + | "icon": null, | |
| 12906 | + | "kind": "workspace", | |
| 12907 | + | "is_default": true, | |
| 12908 | + | "joined": true, | |
| 12909 | + | "viewer_role": "edit", | |
| 12910 | + | "artifact_count": 14 | |
| 12911 | + | }, | |
| 12912 | + | { | |
| 12913 | + | "id": "spc_01kq2b4d6f8h0k2m4p6r8t0v2x", | |
| 12914 | + | "slug": "design", | |
| 12915 | + | "name": "Design", | |
| 12916 | + | "description": "The design team's specs.", | |
| 12917 | + | "icon": "🎨", | |
| 12918 | + | "kind": "team", | |
| 12919 | + | "is_default": false, | |
| 12920 | + | "joined": true, | |
| 12921 | + | "viewer_role": "view", | |
| 12922 | + | "artifact_count": 6 | |
| 12923 | + | } | |
| 12924 | + | ], | |
| 12925 | + | "notes": "Open spaces show once you join them in Artifacts; General always shows. Give a space's `slug` or `id` as `space` to the other artifact calls." | |
| 12926 | + | }, | |
| 12927 | + | "query_workspace_dataset": { | |
| 12928 | + | "params": { | |
| 12929 | + | "workspace": "acme" | |
| 12930 | + | }, | |
| 12931 | + | "request": { | |
| 12932 | + | "query": { | |
| 12933 | + | "dataset": "pull_requests", | |
| 12934 | + | "measure": { | |
| 12935 | + | "op": "count" | |
| 12936 | + | }, | |
| 12937 | + | "group_by": "repo", | |
| 12938 | + | "range": { | |
| 12939 | + | "from": "2026-09-01", | |
| 12940 | + | "to": "2026-09-30" | |
| 12941 | + | } | |
| 12942 | + | } | |
| 12943 | + | }, | |
| 12944 | + | "response": { | |
| 12945 | + | "columns": [ | |
| 12946 | + | { | |
| 12947 | + | "name": "repo", | |
| 12948 | + | "type": "string" | |
| 12949 | + | }, | |
| 12950 | + | { | |
| 12951 | + | "name": "count", | |
| 12952 | + | "type": "number" | |
| 12953 | + | } | |
| 12954 | + | ], | |
| 12955 | + | "rows": [ | |
| 12956 | + | [ | |
| 12957 | + | "acme/web", | |
| 12958 | + | 42 | |
| 12959 | + | ], | |
| 12960 | + | [ | |
| 12961 | + | "acme/api", | |
| 12962 | + | 7 | |
| 12963 | + | ] | |
| 12964 | + | ], | |
| 12965 | + | "truncated": false, | |
| 12966 | + | "partial": false, | |
| 12967 | + | "as_of": "2026-10-09T12:00:00.000Z" | |
| 12968 | + | }, | |
| 12969 | + | "notes": "Runs as you, over what you can read: `partial` is true when some of it was left out because you cannot read it. Until dashboards ship this answers `422` saying they are not here yet." | |
| 12970 | + | }, | |
| 12971 | + | "create_workspace_artifact": { | |
| 12972 | + | "params": { | |
| 12973 | + | "workspace": "acme" | |
| 12974 | + | }, | |
| 12975 | + | "request": { | |
| 12976 | + | "kind": "doc", | |
| 12977 | + | "title": "Release notes", | |
| 12978 | + | "markdown": "# Release notes\n\nWhat changed in 2.4.\n" | |
| 12979 | + | }, | |
| 12980 | + | "response": { | |
| 12981 | + | "id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a", | |
| 12982 | + | "kind": "doc", | |
| 12983 | + | "title": "Release notes", | |
| 12984 | + | "icon": null, | |
| 12985 | + | "slug": "release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a", | |
| 12986 | + | "space": null, | |
| 12987 | + | "parent_id": null, | |
| 12988 | + | "has_children": false, | |
| 12989 | + | "owner": { | |
| 12990 | + | "type": "user", | |
| 12991 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 12992 | + | "username": "ana", | |
| 12993 | + | "display_name": "Ana Lima" | |
| 12994 | + | }, | |
| 12995 | + | "created_by": { | |
| 12996 | + | "type": "user", | |
| 12997 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 12998 | + | "username": "ana", | |
| 12999 | + | "display_name": "Ana Lima" | |
| 13000 | + | }, | |
| 13001 | + | "created_at": "2026-10-09T10:00:00.000Z", | |
| 13002 | + | "updated_at": "2026-10-09T10:00:00.000Z", | |
| 13003 | + | "edited_by": null, | |
| 13004 | + | "edited_at": "2026-10-09T10:00:00.000Z", | |
| 13005 | + | "trashed_at": null, | |
| 13006 | + | "viewer_role": "manage", | |
| 13007 | + | "private": true, | |
| 13008 | + | "shared_count": 0, | |
| 13009 | + | "general_access": "none", | |
| 13010 | + | "general_role": null, | |
| 13011 | + | "inherit": true, | |
| 13012 | + | "agent_mode": null, | |
| 13013 | + | "excerpt": "What changed in 2.4.", | |
| 13014 | + | "source": null, | |
| 13015 | + | "stale": false, | |
| 13016 | + | "html_url": "https://g1t.sh/acme/-/artifacts/release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a" | |
| 13017 | + | }, | |
| 13018 | + | "notes": "Left out `space` and `parent_id`, it lands in your Private, where only you can open it until you share it. `kind` is `doc` until slides, designs and dashboards ship; they answer `422` saying they are not here yet." | |
| 13019 | + | }, | |
| 13020 | + | "update_workspace_artifact": { | |
| 13021 | + | "params": { | |
| 13022 | + | "workspace": "acme", | |
| 13023 | + | "artifact_id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a" | |
| 13024 | + | }, | |
| 13025 | + | "request": { | |
| 13026 | + | "title": "Release notes 2.4", | |
| 13027 | + | "space": "general" | |
| 13028 | + | }, | |
| 13029 | + | "response": { | |
| 13030 | + | "id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a", | |
| 13031 | + | "kind": "doc", | |
| 13032 | + | "title": "Release notes 2.4", | |
| 13033 | + | "icon": null, | |
| 13034 | + | "slug": "release-notes-2-4-fol_01kq8d5f7h9k1n3q5s7u9w1y3a", | |
| 13035 | + | "space": { | |
| 13036 | + | "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w", | |
| 13037 | + | "slug": "general", | |
| 13038 | + | "name": "General", | |
| 13039 | + | "kind": "workspace" | |
| 13040 | + | }, | |
| 13041 | + | "parent_id": null, | |
| 13042 | + | "has_children": false, | |
| 13043 | + | "owner": { | |
| 13044 | + | "type": "user", | |
| 13045 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 13046 | + | "username": "ana", | |
| 13047 | + | "display_name": "Ana Lima" | |
| 13048 | + | }, | |
| 13049 | + | "created_by": { | |
| 13050 | + | "type": "user", | |
| 13051 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 13052 | + | "username": "ana", | |
| 13053 | + | "display_name": "Ana Lima" | |
| 13054 | + | }, | |
| 13055 | + | "created_at": "2026-10-09T10:00:00.000Z", | |
| 13056 | + | "updated_at": "2026-10-09T10:00:00.000Z", | |
| 13057 | + | "edited_by": null, | |
| 13058 | + | "edited_at": "2026-10-09T10:00:00.000Z", | |
| 13059 | + | "trashed_at": null, | |
| 13060 | + | "viewer_role": "manage", | |
| 13061 | + | "private": false, | |
| 13062 | + | "shared_count": 0, | |
| 13063 | + | "general_access": "none", | |
| 13064 | + | "general_role": null, | |
| 13065 | + | "inherit": true, | |
| 13066 | + | "agent_mode": null, | |
| 13067 | + | "excerpt": "What changed in 2.4.", | |
| 13068 | + | "source": null, | |
| 13069 | + | "stale": false, | |
| 13070 | + | "html_url": "https://g1t.sh/acme/-/artifacts/release-notes-2-4-fol_01kq8d5f7h9k1n3q5s7u9w1y3a" | |
| 13071 | + | }, | |
| 13072 | + | "notes": "Moving it into a space it follows (`inherit`) gives the space's members its access. `space` set to `private` moves it to the top of your Private, which only its owner can do." | |
| 13073 | + | }, | |
| 13074 | + | "edit_workspace_artifact": { | |
| 13075 | + | "params": { | |
| 13076 | + | "workspace": "acme", | |
| 13077 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z" | |
| 13078 | + | }, | |
| 13079 | + | "request": { | |
| 13080 | + | "target": { | |
| 13081 | + | "kind": "section", | |
| 13082 | + | "heading": "This quarter" | |
| 13083 | + | }, | |
| 13084 | + | "markdown": "## This quarter\n\nWe ship the merge queue, Artifacts and the API for both.\n", | |
| 13085 | + | "note": "Adds the API" | |
| 13086 | + | }, | |
| 13087 | + | "response": { | |
| 13088 | + | "mode": "applied", | |
| 13089 | + | "artifact": { | |
| 13090 | + | "id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 13091 | + | "kind": "doc", | |
| 13092 | + | "title": "Q4 roadmap", | |
| 13093 | + | "icon": "🗺️", | |
| 13094 | + | "slug": "q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 13095 | + | "html_url": "https://g1t.sh/acme/-/artifacts/q4-roadmap-fol_01kq7c4e6g8j0m2p4r6t8v0x2z" | |
| 13096 | + | }, | |
| 13097 | + | "version_id": "ver_01kqa7h9k1n3q5s7u9w1y3a5c", | |
| 13098 | + | "summary": "Replaced the section This quarter." | |
| 13099 | + | }, | |
| 13100 | + | "notes": "With only the comment role, or with `suggest_only`, the change is filed as a suggestion instead: `mode` is `suggested`, with the `suggestion` its editors accept or reject. A target that is not there any more answers `404`: read the content again." | |
| 13101 | + | }, | |
| 13102 | + | "trash_workspace_artifact": { | |
| 13103 | + | "params": { | |
| 13104 | + | "workspace": "acme", | |
| 13105 | + | "artifact_id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a" | |
| 13106 | + | }, | |
| 13107 | + | "response": { | |
| 13108 | + | "id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a", | |
| 13109 | + | "kind": "doc", | |
| 13110 | + | "title": "Release notes", | |
| 13111 | + | "icon": null, | |
| 13112 | + | "slug": "release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a", | |
| 13113 | + | "space": null, | |
| 13114 | + | "parent_id": null, | |
| 13115 | + | "has_children": false, | |
| 13116 | + | "owner": { | |
| 13117 | + | "type": "user", | |
| 13118 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 13119 | + | "username": "ana", | |
| 13120 | + | "display_name": "Ana Lima" | |
| 13121 | + | }, | |
| 13122 | + | "created_by": { | |
| 13123 | + | "type": "user", | |
| 13124 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 13125 | + | "username": "ana", | |
| 13126 | + | "display_name": "Ana Lima" | |
| 13127 | + | }, | |
| 13128 | + | "created_at": "2026-10-09T10:00:00.000Z", | |
| 13129 | + | "updated_at": "2026-10-09T10:00:00.000Z", | |
| 13130 | + | "edited_by": null, | |
| 13131 | + | "edited_at": "2026-10-09T10:00:00.000Z", | |
| 13132 | + | "trashed_at": "2026-10-09T11:30:00.000Z", | |
| 13133 | + | "viewer_role": "manage", | |
| 13134 | + | "private": true, | |
| 13135 | + | "shared_count": 0, | |
| 13136 | + | "general_access": "none", | |
| 13137 | + | "general_role": null, | |
| 13138 | + | "inherit": true, | |
| 13139 | + | "agent_mode": null, | |
| 13140 | + | "excerpt": "What changed in 2.4.", | |
| 13141 | + | "source": null, | |
| 13142 | + | "stale": false, | |
| 13143 | + | "html_url": "https://g1t.sh/acme/-/artifacts/release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a" | |
| 13144 | + | }, | |
| 13145 | + | "notes": "Everything under it goes to the trash with it. It is deleted for good 30 days later, or with `purge_workspace_artifact`." | |
| 13146 | + | }, | |
| 13147 | + | "restore_workspace_artifact": { | |
| 13148 | + | "params": { | |
| 13149 | + | "workspace": "acme", | |
| 13150 | + | "artifact_id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a" | |
| 13151 | + | }, | |
| 13152 | + | "response": { | |
| 13153 | + | "id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a", | |
| 13154 | + | "kind": "doc", | |
| 13155 | + | "title": "Release notes", | |
| 13156 | + | "icon": null, | |
| 13157 | + | "slug": "release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a", | |
| 13158 | + | "space": null, | |
| 13159 | + | "parent_id": null, | |
| 13160 | + | "has_children": false, | |
| 13161 | + | "owner": { | |
| 13162 | + | "type": "user", | |
| 13163 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 13164 | + | "username": "ana", | |
| 13165 | + | "display_name": "Ana Lima" | |
| 13166 | + | }, | |
| 13167 | + | "created_by": { | |
| 13168 | + | "type": "user", | |
| 13169 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 13170 | + | "username": "ana", | |
| 13171 | + | "display_name": "Ana Lima" | |
| 13172 | + | }, | |
| 13173 | + | "created_at": "2026-10-09T10:00:00.000Z", | |
| 13174 | + | "updated_at": "2026-10-09T10:00:00.000Z", | |
| 13175 | + | "edited_by": null, | |
| 13176 | + | "edited_at": "2026-10-09T10:00:00.000Z", | |
| 13177 | + | "trashed_at": null, | |
| 13178 | + | "viewer_role": "manage", | |
| 13179 | + | "private": true, | |
| 13180 | + | "shared_count": 0, | |
| 13181 | + | "general_access": "none", | |
| 13182 | + | "general_role": null, | |
| 13183 | + | "inherit": true, | |
| 13184 | + | "agent_mode": null, | |
| 13185 | + | "excerpt": "What changed in 2.4.", | |
| 13186 | + | "source": null, | |
| 13187 | + | "stale": false, | |
| 13188 | + | "html_url": "https://g1t.sh/acme/-/artifacts/release-notes-fol_01kq8d5f7h9k1n3q5s7u9w1y3a" | |
| 13189 | + | } | |
| 13190 | + | }, | |
| 13191 | + | "restore_workspace_artifact_version": { | |
| 13192 | + | "params": { | |
| 13193 | + | "workspace": "acme", | |
| 13194 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 13195 | + | "version_id": "ver_01kq7c4e6g8j0m2p4r6t8v0x2y" | |
| 13196 | + | }, | |
| 13197 | + | "response": { | |
| 13198 | + | "id": "ver_01kqb8j0m2p4r6t8v0x2z4b6d", | |
| 13199 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 13200 | + | "created_at": "2026-10-09T12:00:00.000Z", | |
| 13201 | + | "kind": "restore", | |
| 13202 | + | "note": null, | |
| 13203 | + | "authors": [ | |
| 13204 | + | { | |
| 13205 | + | "type": "user", | |
| 13206 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 13207 | + | "username": "ana", | |
| 13208 | + | "display_name": "Ana Lima" | |
| 13209 | + | } | |
| 13210 | + | ] | |
| 13211 | + | } | |
| 13212 | + | }, | |
| 13213 | + | "set_workspace_artifact_access": { | |
| 13214 | + | "params": { | |
| 13215 | + | "workspace": "acme", | |
| 13216 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z" | |
| 13217 | + | }, | |
| 13218 | + | "request": { | |
| 13219 | + | "username": "bo", | |
| 13220 | + | "role": "edit", | |
| 13221 | + | "notify": "Can you fill in the dates?" | |
| 13222 | + | }, | |
| 13223 | + | "response": { | |
| 13224 | + | "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 13225 | + | "owner": { | |
| 13226 | + | "type": "user", | |
| 13227 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09q", | |
| 13228 | + | "username": "ana", | |
| 13229 | + | "display_name": "Ana Lima" | |
| 13230 | + | }, | |
| 13231 | + | "shared_with": [ | |
| 13232 | + | { | |
| 13233 | + | "principal": "user:usr_01kmb2d4f6h8k0m2p4r6t8v0x2", | |
| 13234 | + | "member": { | |
| 13235 | + | "type": "user", | |
| 13236 | + | "id": "usr_01kmb2d4f6h8k0m2p4r6t8v0x2", | |
| 13237 | + | "username": "bo", | |
| 13238 | + | "display_name": "Bo Chen" | |
| 13239 | + | }, | |
| 13240 | + | "role": "edit", | |
| 13241 | + | "inherited_from": null | |
| 13242 | + | }, | |
| 13243 | + | { | |
| 13244 | + | "principal": "team:design", | |
| 13245 | + | "member": { | |
| 13246 | + | "type": "team", | |
| 13247 | + | "slug": "design", | |
| 13248 | + | "display_name": "@acme/design" | |
| 13249 | + | }, | |
| 13250 | + | "role": "comment", | |
| 13251 | + | "inherited_from": null | |
| 13252 | + | } | |
| 13253 | + | ], | |
| 13254 | + | "general_access": "workspace", | |
| 13255 | + | "general_role": "view", | |
| 13256 | + | "inherit": true, | |
| 13257 | + | "inherited_from": { | |
| 13258 | + | "type": "space", | |
| 13259 | + | "id": "spc_01kq1a3c5e7g9j1l3n5q7s9u1w", | |
| 13260 | + | "name": "General" | |
| 13261 | + | }, | |
| 13262 | + | "agent_mode": null, | |
| 13263 | + | "can_share": true | |
| 13264 | + | }, | |
| 13265 | + | "notes": "One call can share with one person, team or agent, and change `general_access`, `inherit` and `agent_mode`, in that order. `role` set to `none` takes someone's access away. General access never gives full access. On an artifact that follows the doc it is under, change general access on that doc, or set `inherit` to false first." | |
| 13266 | + | }, | |
| 13267 | + | "purge_workspace_artifact": { | |
| 13268 | + | "params": { | |
| 13269 | + | "workspace": "acme", | |
| 13270 | + | "artifact_id": "fol_01kq8d5f7h9k1n3q5s7u9w1y3a" | |
| 13271 | + | }, | |
| 13272 | + | "response": { | |
| 13273 | + | "deleted": true | |
| 13274 | + | }, | |
| 13275 | + | "notes": "Only an artifact in the trash can be deleted for good: `trash_workspace_artifact` first." | |
| 12596 | 13276 | } | |
| 12597 | 13277 | } |
| 127 | 127 | Op::DeployKeys(DeployKeysOp::DeleteDeployKey) => return through::<bool>(op, as_is), | |
| 128 | 128 | // Packages are shaped by the API itself, in `snake_case`. | |
| 129 | 129 | Op::Packages(_) => return as_is, | |
| 130 | + | // Artifacts are shaped by the API itself, in `snake_case`. | |
| 131 | + | Op::Folios(_) => return as_is, | |
| 130 | 132 | // Built by the API itself, in `snake_case`. | |
| 131 | 133 | Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is), | |
| 132 | 134 | Op::DismissSecurityAlert | Op::ReopenSecurityAlert => { |
| 8 | 8 | use crate::mirrors::MirrorsOp; | |
| 9 | 9 | use crate::deployments::DeploymentsOp; | |
| 10 | 10 | use crate::packages::PackagesOp; | |
| 11 | + | use crate::folios::FoliosOp; | |
| 11 | 12 | use crate::protection::ProtectionOp; | |
| 12 | 13 | use crate::token_policy::TokenOp; | |
| 13 | 14 | use crate::operations::Op; | |
| ⋯ | |||
| 961 | 962 | Op::MergePullRequest, | |
| 962 | 963 | &[], | |
| 963 | 964 | ), | |
| 965 | + | // Artifacts mode's docs, slides, designs and dashboards. Search comes | |
| 966 | + | // before an artifact by id, which it would otherwise match. | |
| 967 | + | route("GET", "/workspaces/:workspace/artifacts", Op::Folios(FoliosOp::List), &[("tab", "tab"), ("kind", "kind"), ("space", "space"), ("project", "project"), ("q", "q"), ("state", "state"), ("cursor", "cursor"), ("limit", "limit")]), | |
| 968 | + | route("POST", "/workspaces/:workspace/artifacts", Op::Folios(FoliosOp::Create), &[]), | |
| 969 | + | route("GET", "/workspaces/:workspace/artifacts/search", Op::Folios(FoliosOp::Search), &[("q", "q"), ("kind", "kind"), ("space", "space"), ("project", "project"), ("limit", "limit")]), | |
| 970 | + | route("GET", "/workspaces/:workspace/artifacts/:artifact_id", Op::Folios(FoliosOp::Get), &[]), | |
| 971 | + | route("PATCH", "/workspaces/:workspace/artifacts/:artifact_id", Op::Folios(FoliosOp::Update), &[]), | |
| 972 | + | route("DELETE", "/workspaces/:workspace/artifacts/:artifact_id", Op::Folios(FoliosOp::Trash), &[]), | |
| 973 | + | route("POST", "/workspaces/:workspace/artifacts/:artifact_id/restore", Op::Folios(FoliosOp::Restore), &[]), | |
| 974 | + | route("POST", "/workspaces/:workspace/artifacts/:artifact_id/purge", Op::Folios(FoliosOp::Purge), &[]), | |
| 975 | + | route("GET", "/workspaces/:workspace/artifacts/:artifact_id/content", Op::Folios(FoliosOp::GetContent), &[]), | |
| 976 | + | route("PUT", "/workspaces/:workspace/artifacts/:artifact_id/content", Op::Folios(FoliosOp::Edit), &[]), | |
| 977 | + | route("GET", "/workspaces/:workspace/artifacts/:artifact_id/access", Op::Folios(FoliosOp::GetAccess), &[]), | |
| 978 | + | route("PUT", "/workspaces/:workspace/artifacts/:artifact_id/access", Op::Folios(FoliosOp::SetAccess), &[]), | |
| 979 | + | route("GET", "/workspaces/:workspace/artifacts/:artifact_id/versions", Op::Folios(FoliosOp::ListVersions), &[]), | |
| 980 | + | route("POST", "/workspaces/:workspace/artifacts/:artifact_id/versions/:version_id/restore", Op::Folios(FoliosOp::RestoreVersion), &[]), | |
| 981 | + | route("GET", "/workspaces/:workspace/artifact-templates", Op::Folios(FoliosOp::ListTemplates), &[("kind", "kind")]), | |
| 982 | + | route("GET", "/workspaces/:workspace/artifact-spaces", Op::Folios(FoliosOp::ListSpaces), &[]), | |
| 983 | + | route("POST", "/workspaces/:workspace/datasets/query", Op::Folios(FoliosOp::QueryDataset), &[]), | |
| 964 | 984 | ]; | |
| 965 | 985 | ||
| 966 | 986 | impl Route { | |
| 24 | 24 | use crate::mirrors::MirrorsOp; | |
| 25 | 25 | use crate::deployments::DeploymentsOp; | |
| 26 | 26 | use crate::packages::PackagesOp; | |
| 27 | + | use crate::folios::FoliosOp; | |
| 27 | 28 | use crate::protection::ProtectionOp; | |
| 28 | 29 | use crate::token_policy::TokenOp; | |
| 29 | 30 | use crate::operations::Op; | |
| ⋯ | |||
| 525 | 526 | a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"), | |
| 526 | 527 | ], | |
| 527 | 528 | }, | |
| 529 | + | Tool { | |
| 530 | + | name: "artifact", | |
| 531 | + | title: "Artifacts", | |
| 532 | + | description: "A workspace's docs, slides, designs and dashboards (Artifacts mode), as you can open them: list, search and read them (a doc's content is Markdown, with block ids to target), make them, edit them (a change with the edit role, a suggestion with comment), move, trash and restore them, their versions, and who can open them. Name one by its id (fol_…) or its address. Not the `workflow` tool's run artifacts. Slides, designs and dashboards answer that they are not here yet.", | |
| 533 | + | default_action: None, | |
| 534 | + | actions: &[ | |
| 535 | + | a("list", Op::Folios(FoliosOp::List), "Artifacts you can open; tab, kind, space, q; state trashed for the trash"), | |
| 536 | + | a("search", Op::Folios(FoliosOp::Search), "Search them by words and meaning, with the passage that matched"), | |
| 537 | + | a("get", Op::Folios(FoliosOp::Get), "One artifact: kind, title, space, owner, your role, who it is shared with"), | |
| 538 | + | a("read", Op::Folios(FoliosOp::GetContent), "Its content: a doc's Markdown and block ids, and what you may do"), | |
| 539 | + | a("versions", Op::Folios(FoliosOp::ListVersions), "Its saved versions, newest first"), | |
| 540 | + | a("access", Op::Folios(FoliosOp::GetAccess), "Who can open it, and how"), | |
| 541 | + | a("templates", Op::Folios(FoliosOp::ListTemplates), "Templates to start one from"), | |
| 542 | + | a("spaces", Op::Folios(FoliosOp::ListSpaces), "The spaces in your sidebar"), | |
| 543 | + | a("query_data", Op::Folios(FoliosOp::QueryDataset), "Run a dataset query as you, over what you can read"), | |
| 544 | + | a("create", Op::Folios(FoliosOp::Create), "Make one: in a space, under a doc, or in your Private"), | |
| 545 | + | a("update", Op::Folios(FoliosOp::Update), "Rename it, change its icon, or move it"), | |
| 546 | + | a("edit", Op::Folios(FoliosOp::Edit), "Change its content: append, replace it all, a section or blocks"), | |
| 547 | + | a("trash", Op::Folios(FoliosOp::Trash), "Move it to the trash; restorable for 30 days"), | |
| 548 | + | a("restore", Op::Folios(FoliosOp::Restore), "Bring it back from the trash"), | |
| 549 | + | a("restore_version", Op::Folios(FoliosOp::RestoreVersion), "Make an earlier version its content again"), | |
| 550 | + | a("share", Op::Folios(FoliosOp::SetAccess), "Share it, change general access, or take access away"), | |
| 551 | + | a("purge", Op::Folios(FoliosOp::Purge), "Delete one in the trash for good"), | |
| 552 | + | ], | |
| 553 | + | }, | |
| 528 | 554 | ]; | |
| 529 | 555 | ||
| 530 | 556 | /// Operations that cannot be undone, or reach beyond g1t's own records: | |
| ⋯ | |||
| 561 | 587 | | Op::RemoveRunner | |
| 562 | 588 | | Op::DeleteRunnerGroup | |
| 563 | 589 | | Op::UpdateRunnerSettings | |
| 590 | + | | Op::Folios(FoliosOp::SetAccess | FoliosOp::Purge) | |
| 564 | 591 | ) | |
| 565 | 592 | } | |
| 566 | 593 | ||
| ⋯ | |||
| 811 | 838 | assert!(tool.action(default).is_some(), "{}", tool.name); | |
| 812 | 839 | } | |
| 813 | 840 | } | |
| 814 | − | assert!(TOOLS.len() <= 18, "{} tools", TOOLS.len()); | |
| 841 | + | assert!(TOOLS.len() <= 19, "{} tools", TOOLS.len()); | |
| 815 | 842 | } | |
| 816 | 843 | ||
| 817 | 844 | #[test] | |
| ⋯ | |||
| 992 | 1019 | assert!(!reads_only(Op::RequestReviewers)); | |
| 993 | 1020 | } | |
| 994 | 1021 | ||
| 1022 | + | /// The `artifact` tool offers each token only what its artifacts scope | |
| 1023 | + | /// allows: reading, then changing, then sharing and deleting for good. | |
| 1024 | + | /// Workflow runs' artifacts are the `workflow` tool's, under their own | |
| 1025 | + | /// scope, and neither scope reaches the other's. | |
| 1026 | + | #[test] | |
| 1027 | + | fn the_artifact_tool_offers_what_the_artifacts_scope_allows() { | |
| 1028 | + | let actions = |scopes: Vec<Scope>| -> Option<Value> { | |
| 1029 | + | let access = token(Some(scopes)); | |
| 1030 | + | Tool::by_name("artifact").unwrap().listed(&Gate::Token(&access)).map(|tool| tool["inputSchema"]["properties"]["action"]["enum"].clone()) | |
| 1031 | + | }; | |
| 1032 | + | let reads = json!(["list", "search", "get", "read", "versions", "access", "templates", "spaces", "query_data"]); | |
| 1033 | + | assert_eq!(actions(vec![Scope::ArtifactsRead]), Some(reads.clone())); | |
| 1034 | + | let writes = actions(vec![Scope::ArtifactsWrite]).unwrap(); | |
| 1035 | + | for action in ["create", "update", "edit", "trash", "restore", "restore_version"] { | |
| 1036 | + | assert!(writes.as_array().unwrap().contains(&json!(action)), "{action}"); | |
| 1037 | + | } | |
| 1038 | + | assert!(!writes.as_array().unwrap().contains(&json!("share")) && !writes.as_array().unwrap().contains(&json!("purge"))); | |
| 1039 | + | let admin = actions(vec![Scope::ArtifactsAdmin]).unwrap(); | |
| 1040 | + | assert_eq!(admin.as_array().unwrap().len(), Tool::by_name("artifact").unwrap().actions.len()); | |
| 1041 | + | // Without an artifacts scope there is no artifact tool at all. | |
| 1042 | + | assert_eq!(actions(vec![Scope::WorkflowsWrite, Scope::IssuesWrite]), None); | |
| 1043 | + | // And an artifacts scope shows nothing of workflow runs' artifacts. | |
| 1044 | + | let access = token(Some(vec![Scope::ArtifactsAdmin])); | |
| 1045 | + | assert!(Tool::by_name("workflow").unwrap().listed(&Gate::Token(&access)).is_none()); | |
| 1046 | + | // The read-only and agent presets read artifacts and change none. | |
| 1047 | + | for preset in [Preset::ReadOnly, Preset::Agent] { | |
| 1048 | + | let access = token(preset.scopes()); | |
| 1049 | + | let tool = Tool::by_name("artifact").unwrap().listed(&Gate::Token(&access)).unwrap(); | |
| 1050 | + | assert_eq!(tool["inputSchema"]["properties"]["action"]["enum"], reads, "{}", preset.as_str()); | |
| 1051 | + | assert_eq!(tool["annotations"]["readOnlyHint"], true); | |
| 1052 | + | } | |
| 1053 | + | // Sharing and deleting for good can't be undone the same way. | |
| 1054 | + | let tools = listed(&Gate::Everything); | |
| 1055 | + | let artifact = tools.iter().find(|tool| tool["name"] == "artifact").unwrap(); | |
| 1056 | + | assert_eq!(artifact["annotations"]["destructiveHint"], true); | |
| 1057 | + | assert!(artifact["description"].as_str().unwrap().contains("Not the `workflow` tool's run artifacts")); | |
| 1058 | + | let tool = Tool::by_name("artifact").unwrap(); | |
| 1059 | + | assert_eq!(resolve(tool, &json!({ "action": "read", "workspace": "acme" })), Err("artifact.read needs artifact_id.".to_owned())); | |
| 1060 | + | assert_eq!( | |
| 1061 | + | resolve(tool, &json!({ "action": "edit", "workspace": "acme", "artifact_id": "fol_1", "markdown": "x" })), | |
| 1062 | + | Ok(Op::Folios(FoliosOp::Edit)) | |
| 1063 | + | ); | |
| 1064 | + | } | |
| 1065 | + | ||
| 995 | 1066 | /// How much smaller `tools/list` is than one tool per operation. Run | |
| 996 | 1067 | /// with `--nocapture` to see the numbers. | |
| 997 | 1068 | #[test] | |
| 33 | 33 | // A person's pinned projects: list_pinned_projects and changing them. | |
| 34 | 34 | { "binding": "PROJECTS", "service": "g1t-projects" }, | |
| 35 | 35 | // Packages: list_packages, their settings, deleting and restoring them. | |
| 36 | − | { "binding": "PACKAGES", "service": "g1t-packages" } | |
| 36 | + | { "binding": "PACKAGES", "service": "g1t-packages" }, | |
| 37 | + | // Artifacts (folios): the artifact tool and /workspaces/{ws}/artifacts. | |
| 38 | + | { "binding": "DOCS", "service": "g1t-docs-service" } | |
| 37 | 39 | ], | |
| 38 | 40 | // GitHub Actions artifacts older runners kept, in chunks, with KV's own | |
| 39 | 41 | // expiry, read until it passes (and cache |
| 76 | 76 | | Actor | Who did it: a person, an agent, or a workspace token. | | |
| 77 | 77 | | On behalf of | For an agent, the person it worked for: `g1t on behalf of syntaqx`. | | |
| 78 | 78 | | Run | The agent run, with its kind: `implement`, `review`, `update` and so on; or the workflow run whose job's token did it, as `workflow_job`. | | |
| 79 | − | | Credential | The id of the token used. | | |
| 79 | + | | Credential | The id of the token used: on the API, the MCP server or git, or on g1t.sh by a token [used on the website](/guides/authentication/#use-a-token-on-the-website). Empty for a person signed in on g1t.sh. | | |
| 80 | 80 | | Action | The API or MCP operation, such as `create_issue`, or `git.push` and `git.fetch`. | | |
| 81 | 81 | | Target | The repository, the issue or pull request number, and for git the refs it moved. | | |
| 82 | 82 | | Outcome | `allowed` or `denied`. | |
| 577 | 577 | ||
| 578 | 578 | ## Access tokens | |
| 579 | 579 | ||
| 580 | − | A token stands in for your password everywhere outside the website: | |
| 580 | + | A token stands in for your password everywhere outside the website, and on | |
| 581 | + | the website too when you turn that on for it: | |
| 581 | 582 | ||
| 582 | 583 | | Where | How to send it | | |
| 583 | 584 | | --- | --- | | |
| 584 | 585 | | git | As the password, with your username. | | |
| 585 | 586 | | API | `Authorization: Bearer g1t_…` | | |
| 586 | 587 | | MCP | The same header, set when you add the server. | | |
| 588 | + | | The website | The same header on every request, from automation that drives a browser. Only a token with **Use the website as you** turned on. See [use a token on the website](#use-a-token-on-the-website). | | |
| 587 | 589 | ||
| 588 | 590 | A token is shown once, when it is created; g1t stores only a hash of it. | |
| 589 | 591 | If you lose one, delete it and create another. Delete a token the moment | |
| ⋯ | |||
| 627 | 629 | 5. Under **Permissions**, set each resource the token needs to a level. | |
| 628 | 630 | **Read only**, **Agent** and **CI** fill in a [preset](#presets); | |
| 629 | 631 | **Clear** sets everything back to no access. | |
| 630 | − | 6. Select **Generate token**, and copy it. It is not shown again. | |
| 632 | + | 6. Leave **Use the website as you**, under **Website**, off unless the | |
| 633 | + | token is for automation that drives a browser. See | |
| 634 | + | [use a token on the website](#use-a-token-on-the-website). | |
| 635 | + | 7. Select **Generate token**, and copy it. It is not shown again. | |
| 631 | 636 | ||
| 632 | 637 | When you make a token for one workspace that | |
| 633 | 638 | [requires approval](#a-workspaces-rules-for-tokens), and you are not one of | |
| ⋯ | |||
| 641 | 646 | The list under Settings → Access tokens shows each token's name, status | |
| 642 | 647 | (pending, denied or revoked, with the owner's note), where it reaches, its | |
| 643 | 648 | permissions, and when it was made, last used and expires. Select a token to | |
| 644 | − | open its page, where you can change its name, description, repositories | |
| 645 | − | and permissions, and select **Save changes**. The token itself stays the | |
| 649 | + | open its page, where you can change its name, description, repositories, | |
| 650 | + | permissions and **Use the website as you**, and select **Save changes**. | |
| 651 | + | A token that can use the website is marked **Uses the website**. The token | |
| 652 | + | itself stays the | |
| 646 | 653 | same; the change applies from its next request. Widening a token made for | |
| 647 | 654 | a workspace that requires approval asks its owners again. Where it reaches | |
| 648 | 655 | and when it expires cannot change; make a new token instead. | |
| 649 | 656 | ||
| 650 | 657 | **Delete token**, at the bottom of its page, stops it working at once. | |
| 651 | 658 | ||
| 659 | + | ### Use a token on the website | |
| 660 | + | ||
| 661 | + | Automation that drives a browser, such as end-to-end tests or an agent | |
| 662 | + | checking how a page looks, can use g1t.sh as you with an access token, so it | |
| 663 | + | never types your password or a two-factor code. Each request it makes | |
| 664 | + | carries the token in the `Authorization` header; no cookie is set and no | |
| 665 | + | session is started. | |
| 666 | + | ||
| 667 | + | 1. Open [Settings → Access tokens](https://g1t.sh/settings/tokens) and | |
| 668 | + | select **New token**, or open a token you have. | |
| 669 | + | 2. Give it an expiration, and the permissions it needs for git, the API and | |
| 670 | + | MCP, if any. | |
| 671 | + | 3. Under **Website**, tick **Use the website as you**. It is off unless you | |
| 672 | + | tick it, and a workspace's own token cannot have it. | |
| 673 | + | 4. Select **Generate token** (or **Save changes**), and keep the token in a | |
| 674 | + | file only the automation can read. | |
| 675 | + | 5. Send `Authorization: Bearer g1t_…` on every request to g1t.sh. | |
| 676 | + | ||
| 677 | + | With [Playwright](https://playwright.dev), set the header on the browser | |
| 678 | + | context, reading the token from a file so it is never printed: | |
| 679 | + | ||
| 680 | + | ```js | |
| 681 | + | import { readFileSync } from "node:fs"; | |
| 682 | + | import { chromium } from "playwright"; | |
| 683 | + | ||
| 684 | + | const token = readFileSync(process.env.G1T_TOKEN_FILE, "utf8").trim(); | |
| 685 | + | const browser = await chromium.launch(); | |
| 686 | + | const context = await browser.newContext({ | |
| 687 | + | extraHTTPHeaders: { authorization: `Bearer ${token}` }, | |
| 688 | + | }); | |
| 689 | + | const page = await context.newPage(); | |
| 690 | + | await page.goto("https://g1t.sh/acme/rocket/pulls"); | |
| 691 | + | await page.screenshot({ path: "pulls.png", fullPage: true }); | |
| 692 | + | await browser.close(); | |
| 693 | + | ``` | |
| 694 | + | ||
| 695 | + | `extraHTTPHeaders` sends the header with every request the page makes, | |
| 696 | + | including to other addresses it loads files from. To send it to g1t.sh | |
| 697 | + | only, add it per request instead: | |
| 698 | + | ||
| 699 | + | ```js | |
| 700 | + | const context = await browser.newContext(); | |
| 701 | + | await context.route("https://g1t.sh/**", (route) => | |
| 702 | + | route.continue({ headers: { ...route.request().headers(), authorization: `Bearer ${token}` } }), | |
| 703 | + | ); | |
| 704 | + | ``` | |
| 705 | + | ||
| 706 | + | Any HTTP client works the same way: | |
| 707 | + | ||
| 708 | + | ```sh | |
| 709 | + | curl -H "Authorization: Bearer $(cat ~/.config/g1t/website-token)" https://g1t.sh/acme/rocket/pulls | |
| 710 | + | ``` | |
| 711 | + | ||
| 712 | + | On the website, the token acts as you in the workspaces it | |
| 713 | + | [reaches](#where-a-token-reaches). Its permissions are made for git, the API | |
| 714 | + | and MCP, and the website does not hold it to them: treat it as able to do | |
| 715 | + | anything there that you can. Keep it as safe as your password, and give it | |
| 716 | + | an expiration. | |
| 717 | + | ||
| 718 | + | - **Only the header counts.** A token in a query string or a cookie is | |
| 719 | + | ignored. A request with the header is the token's, even if it also has a | |
| 720 | + | session cookie. | |
| 721 | + | - **Checked on every request.** Deleting the token, its expiry, or a | |
| 722 | + | workspace [revoking it](#a-workspaces-rules-for-tokens) stops it at once. | |
| 723 | + | - **A token that is not accepted is no one.** One that is not valid, has | |
| 724 | + | expired, or does not have **Use the website as you** loads pages as | |
| 725 | + | someone signed out, with a `WWW-Authenticate` header saying the token was | |
| 726 | + | refused; the data requests and form posts pages make answer `401`. | |
| 727 | + | - **Form posts need nothing more.** Browsers never send the header by | |
| 728 | + | themselves, so a post with it needs no other proof it came from g1t.sh. | |
| 729 | + | A post from another site is still refused. | |
| 730 | + | - **Limits follow the token**: 1,000 requests a minute, as on the API. See | |
| 731 | + | [rate limits](/reference/rate-limits/). | |
| 732 | + | - **The audit log names it.** A change made this way is recorded as yours, | |
| 733 | + | with the token's id under **Credential**. See [the audit log](/guides/audit-log/). | |
| 734 | + | ||
| 735 | + | Some things always need you to sign in on g1t.sh yourself. With a token, | |
| 736 | + | these pages answer **This needs you to sign in** (`403`): | |
| 737 | + | ||
| 738 | + | | What | Where | | |
| 739 | + | | --- | --- | | |
| 740 | + | | Access tokens, yours and a workspace's, and a workspace's rules for and approvals of members' tokens | Settings → Access tokens; a workspace's Settings → Access tokens and Personal access tokens | | |
| 741 | + | | Two-factor authentication | Settings → Two-factor authentication | | |
| 742 | + | | Your username, and deleting your account | Settings → Account | | |
| 743 | + | | Email addresses, which reset your password | Settings → Emails | | |
| 744 | + | | SSH keys | Settings → SSH keys | | |
| 745 | + | | Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub | | |
| 746 | + | | Letting a device or an application sign in | `g1t.sh/device`, `g1t.sh/oauth/authorize` | | |
| 747 | + | | Deleting a workspace, and giving it to another owner | A workspace's Settings and People | | |
| 748 | + | | Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing | | |
| 749 | + | ||
| 652 | 750 | ### Permissions | |
| 653 | 751 | ||
| 654 | 752 | A permission is a resource and a level. A higher level includes the lower | |
| ⋯ | |||
| 684 | 782 | | Billing | read, read and write | `billing:read`, `billing:write` | | |
| 685 | 783 | | Self-hosted runners | read, admin | `runners:read`, `runners:admin` | | |
| 686 | 784 | | AI Gateway | read, read and write | `models:read`, `models:write` | | |
| 785 | + | | Artifacts | read, read and write, admin | `artifacts:read`, `artifacts:write`, `artifacts:admin` | | |
| 687 | 786 | ||
| 688 | 787 | Account permissions are about you, wherever you are, and only a personal | |
| 689 | 788 | token can hold them: | |
| ⋯ | |||
| 788 | 887 | | `runners:admin` | Register and remove self-hosted runners, change their groups and settings | | |
| 789 | 888 | | `models:read` | See the workspace's [AI Gateway](/guides/ai-gateway/) requests: their models, tokens, cost and status | | |
| 790 | 889 | | `models:write` | Send model requests through the [AI Gateway](/guides/ai-gateway/), which uses the workspace's AI credit. Only a workspace's own token can send them. Not in any preset. | | |
| 890 | + | | `artifacts:read` | List, read and search the [artifacts](/guides/bring-your-own-agent/#artifacts) you can open (docs, and later slides, designs and dashboards), their versions and who can open them. Not workflow runs' artifacts, which are `workflows:read`. | | |
| 891 | + | | `artifacts:write` | Create, rename, move, edit, trash and restore artifacts, and suggest changes to them | | |
| 892 | + | | `artifacts:admin` | Share artifacts, change who can open them, and delete them for good. Not in any preset. | | |
| 791 | 893 | ||
| 792 | 894 | Every operation of the API and the MCP server needs exactly one of these, | |
| 793 | 895 | except `whoami` (`GET /user`), which any token may use. Each endpoint's page | |
| ⋯ | |||
| 904 | 1006 | personal token, starting on the CI preset. A workspace token reaches all | |
| 905 | 1007 | of that workspace's repositories, or the ones chosen, never another | |
| 906 | 1008 | workspace, and cannot manage people, tokens or workspaces. It holds no | |
| 907 | − | account permissions. | |
| 1009 | + | account permissions, and cannot use artifacts, which always belong to a | |
| 1010 | + | person. | |
| 908 | 1011 | ||
| 909 | 1012 | It has the Write role on the workspace's repositories, as a member does: | |
| 910 | 1013 | it pushes, merges and works on issues and pull requests, within its | |
| 125 | 125 | `number`. [MCP tools](/reference/mcp/) lists every tool and action with its | |
| 126 | 126 | required inputs, its scope and its REST route. | |
| 127 | 127 | ||
| 128 | + | ## Artifacts | |
| 129 | + | ||
| 130 | + | Your agent can read and write a workspace's artifacts: its docs, and later | |
| 131 | + | its slides, designs and dashboards. The `artifact` tool does it as you, | |
| 132 | + | so it opens only what you can open, and changes only what you can change. | |
| 133 | + | Its token needs `artifacts:read` to read them (the Read only and Agent | |
| 134 | + | [presets](/guides/authentication/#presets) have it), `artifacts:write` to | |
| 135 | + | make and change them, and `artifacts:admin` to share them or delete them | |
| 136 | + | for good. A token without one of these sees no `artifact` tool, and a | |
| 137 | + | token sees only the actions its scope allows. | |
| 138 | + | ||
| 139 | + | 1. Find what is there with `list` (narrow with `kind`, `space` or `q`) or | |
| 140 | + | `search`, which matches words and meaning and returns the passage that | |
| 141 | + | matched. | |
| 142 | + | 2. Read one with `read`. A doc comes back as Markdown in `content`, with | |
| 143 | + | its top-level `blocks` and their ids, and `can` says whether you may | |
| 144 | + | edit it or only suggest. | |
| 145 | + | 3. Change it with `edit`: `markdown` and a `target`, which is `append`, | |
| 146 | + | `document` (replace it all), a `section` by its `heading`, or `blocks` | |
| 147 | + | from one block id to another. With the edit role the change is made, as | |
| 148 | + | a new version; with the comment role, or with `suggest_only`, it is | |
| 149 | + | filed as a suggestion that the doc's editors accept or reject. | |
| 150 | + | 4. Make one with `create`. Left out `space` and `parent_id`, it lands in | |
| 151 | + | your Private, where only you can open it until you share it. | |
| 152 | + | ||
| 153 | + | ```json | |
| 154 | + | { "action": "edit", "workspace": "acme", "artifact_id": "fol_01kq7c4e6g8j0m2p4r6t8v0x2z", | |
| 155 | + | "target": { "kind": "section", "heading": "Risks" }, | |
| 156 | + | "markdown": "## Risks\n\nThe migration needs a maintenance window.\n", | |
| 157 | + | "note": "Adds the migration risk" } | |
| 158 | + | ``` | |
| 159 | + | ||
| 160 | + | Name an artifact by its id (`fol_…`) or by its link. Each action is also | |
| 161 | + | a REST route under `/workspaces/{workspace}/artifacts`; see the | |
| 162 | + | [API reference](/reference/api/). These are not workflow runs' artifacts, | |
| 163 | + | which are the `workflow` tool's. Slides, designs and dashboards answer | |
| 164 | + | that they are not here yet. A workspace's own token cannot use artifacts: | |
| 165 | + | they always belong to a person. | |
| 166 | + | ||
| 128 | 167 | ## Staying out of each other's way | |
| 129 | 168 | ||
| 130 | 169 | `pull_request` with `get` returns `overlaps`: other pull requests in progress that |
| 235 | 235 | ||
| 236 | 236 | An address such as `me@example.com` is never read as a mention. | |
| 237 | 237 | ||
| 238 | + | ## Format a message | |
| 239 | + | ||
| 240 | + | Messages can have bold, italic and struck-through words, links, inline | |
| 241 | + | code, code blocks, lists and quotes. Format as you write, three ways: | |
| 242 | + | ||
| 243 | + | - **The formatting bar** above the text. **T** at the bottom left of the | |
| 244 | + | composer shows or hides it, and your choice is kept on that device. On a | |
| 245 | + | phone it starts hidden; when it is shown, swipe it sideways for the rest. | |
| 246 | + | - **Shortcuts.** Select words, or start typing after one. The keyboard | |
| 247 | + | button at the end of the bar lists them too. | |
| 248 | + | - **Markdown, typed inline.** It turns into its formatting as you type: | |
| 249 | + | `*bold*` becomes **bold** when you type the closing `*`. | |
| 250 | + | ||
| 251 | + | | Formatting | Shortcut (⌘ on a Mac) | Or type | | |
| 252 | + | | --- | --- | --- | | |
| 253 | + | | Bold | Ctrl+B | `*bold*` or `**bold**` | | |
| 254 | + | | Italic | Ctrl+I | `_italic_` | | |
| 255 | + | | Strikethrough | Ctrl+Shift+X | `~struck~` or `~~struck~~` | | |
| 256 | + | | Link | Ctrl+K | Select words and paste an address over them | | |
| 257 | + | | Inline code | Ctrl+Shift+C or Ctrl+E | `` `code` `` | | |
| 258 | + | | Code block | Ctrl+Alt+Shift+C | ` ``` ` then Enter, or ` ```ts ` to name the language | | |
| 259 | + | | Bulleted list | Ctrl+Shift+8 | `- ` at the start of a line | | |
| 260 | + | | Numbered list | Ctrl+Shift+7 | `1. ` at the start of a line | | |
| 261 | + | | Quote | Ctrl+Shift+9 | `> ` at the start of a line | | |
| 262 | + | ||
| 263 | + | **Ctrl+K** asks where the link goes, and for its text when nothing is | |
| 264 | + | selected. On a link already, it changes the address or removes the link. | |
| 265 | + | A link goes to a web address, an email address or a page on g1t; anything | |
| 266 | + | else is refused. Undo (**Ctrl+Z**) takes formatting back off. | |
| 267 | + | ||
| 268 | + | ### Enter and new lines | |
| 269 | + | ||
| 270 | + | | Key | In a line of text | In a list | In a code block | | |
| 271 | + | | --- | --- | --- | --- | | |
| 272 | + | | **Enter** | Sends | Adds an item; on an empty item, leaves the list | Adds a line; three in a row leave the block | | |
| 273 | + | | **Shift+Enter** | Starts a new line | Adds an item | Adds a line | | |
| 274 | + | | **Ctrl+Enter** | Sends | Sends | Sends | | |
| 275 | + | ||
| 276 | + | In a quote, Enter sends and Shift+Enter starts the next line of the quote. | |
| 277 | + | ||
| 278 | + | ### What a message shows | |
| 279 | + | ||
| 280 | + | Every message, a person's or an agent's, is shown with its formatting. | |
| 281 | + | Code blocks with a language g1t knows (`ts`, `rust`, `python`, `sh`, | |
| 282 | + | `sql`, `json` and others) are coloured, and have a copy button. A web | |
| 283 | + | address on its own becomes a link, and links open in a new tab. Headings | |
| 284 | + | an agent writes show as a bold line, and a rule as a line across. | |
| 285 | + | ||
| 286 | + | Chat never shows HTML: `<b>` reads as typed. An image written in Markdown | |
| 287 | + | shows as a link to it, not the image. | |
| 288 | + | ||
| 289 | + | ### Messages are Markdown | |
| 290 | + | ||
| 291 | + | What you send is kept as Markdown, the formatting written as `**bold**`, | |
| 292 | + | `_italic_`, `~~struck~~` and so on. That is what agents read and write, | |
| 293 | + | and what you edit when you change a message, so formatting survives the | |
| 294 | + | round trip. Text you paste without formatting, such as from a terminal, is | |
| 295 | + | read as Markdown too; paste with **Ctrl+Shift+V** to keep it exactly as it | |
| 296 | + | is. Notifications, pop-ups and browser notifications show a message's | |
| 297 | + | words without the marks: `**Release** is _done_` reads *Release is done*. | |
| 298 | + | ||
| 299 | + | To show a character that would format, put a backslash before it: `\*` | |
| 300 | + | shows `*`. | |
| 301 | + | ||
| 238 | 302 | ## Agents in channels | |
| 239 | 303 | ||
| 240 | 304 | Invite an agent to a channel the way you invite a person. Only agents of | |
| ⋯ | |||
| 343 | 407 | ||
| 344 | 408 | ## Edit and delete | |
| 345 | 409 | ||
| 346 | − | You can edit or delete your own messages. An edited message says so. A deleted message is removed for everyone; replies under it stay. | |
| 347 | − | A message can be up to 40,000 characters, and is written in Markdown. | |
| 410 | + | You can edit or delete your own messages. On a computer, hover your message, | |
| 411 | + | open its **⋯** and choose **Edit message**: it opens in the composer, in | |
| 412 | + | place, with its formatting. Enter or **Save** keeps the change, Esc or | |
| 413 | + | **Cancel** leaves it as it was. On a phone, press and hold the message and | |
| 414 | + | choose **Edit**. An edited message says so. A deleted message is removed | |
| 415 | + | for everyone; replies under it stay. | |
| 416 | + | ||
| 417 | + | A message can be up to 40,000 characters, and is written in Markdown (see | |
| 418 | + | [format a message](#format-a-message)). | |
| 348 | 419 | ||
| 349 | 420 | ## Reactions | |
| 350 | 421 | ||
| 293 | 293 | | `upload` | Only for a push: handing it to the git store and its answer | | |
| 294 | 294 | | `refs` | Only for a push: recording that the repository's refs changed | | |
| 295 | 295 | | `total` | Everything g1t did | | |
| 296 | + | | `repos` | The same, measured where your request arrived | | |
| 296 | 297 | ||
| 297 | 298 | A push's checks run side by side, so each also has its own entry, after | |
| 298 | − | the steps and not counted in the total: `read` (reading the push's objects | |
| 299 | − | and fetching what they build on from the repository), `rules`, `scan` | |
| 300 | − | (secrets and email addresses) and, for an access token, `gate` (workflow | |
| 301 | − | files). | |
| 302 | − | | `repos` | The same, measured where your request arrived | | |
| 299 | + | the steps and not counted in the total: `read` (reading the push's | |
| 300 | + | objects), `rules`, `scan` (secrets and email addresses) and, for an access | |
| 301 | + | token, `gate` (workflow files). | |
| 302 | + | ||
| 303 | + | g1t asks git to send each push whole: every object a delta in it builds on | |
| 304 | + | is in the push too (the `no-thin` capability), so checking a push reads | |
| 305 | + | nothing from the repository. A push that changes a large file a little | |
| 306 | + | uploads more than it would otherwise, and stays within the | |
| 307 | + | [size limits](#size-limits). If your git sends a push that builds on | |
| 308 | + | objects outside it anyway, g1t reads up to 200 of those from the | |
| 309 | + | repository to check it, and says `thin;desc=yes`. | |
| 303 | 310 | ||
| 304 | − | Two entries say how a step went rather than how long it took: | |
| 311 | + | Some entries say how a step went rather than how long it took: | |
| 305 | 312 | ||
| 306 | 313 | | Entry | Values | | |
| 307 | 314 | | --- | --- | | |
| 308 | 315 | | `refs;desc=` | `hit-colo` or `hit-shared` when the ref listing came from g1t's cache, `miss` when the git store was asked | | |
| 309 | 316 | | `pack;desc=` | Only for a fresh clone: `hit` when its pack came from g1t's cache, `miss` when the git store built it | | |
| 310 | 317 | | `cred;desc=` | `isolate` or `shared` for a store credential made a moment ago, `mint` for a new one | | |
| 318 | + | | `thin;desc=` | Only for a push: `no` when it came whole, `yes` when it built on objects outside it | | |
| 311 | 319 | ||
| 312 | 320 | The ref listing git asks for first on every clone and fetch is kept for up | |
| 313 | 321 | to a minute, and only the same question about the same refs gets the same |
| 106 | 106 | </Card> | |
| 107 | 107 | <Card title="Everything has an API" icon="book-open"> | |
| 108 | 108 | Repositories, issues, pull requests, agents and workflows are all a [REST endpoint](/reference/api/) and an [MCP tool action](/reference/mcp/) away, with | |
| 109 | − | [webhooks](/guides/webhooks/) for every event. | |
| 109 | + | [webhooks](/guides/webhooks/) for every event. Tests that drive a browser | |
| 110 | + | [use the website with a token](/guides/authentication/#use-a-token-on-the-website). | |
| 110 | 111 | </Card> | |
| 111 | 112 | </CardGrid> | |
| 112 | 113 |
| 3 | 3 | description: The g1t MCP server's resource tools, each action they take with its required inputs and scope, and how to call them. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | The MCP server at `https://mcp.g1t.sh` exposes 18 tools, one per kind of | |
| 6 | + | The MCP server at `https://mcp.g1t.sh` exposes 19 tools, one per kind of | |
| 7 | 7 | thing on g1t: `search`, `repository`, `issue`, `pull_request`, `agent`, | |
| 8 | 8 | `plan`, `memory`, `workflow`, `package`, `secret`, `security`, `webhook`, `access`, | |
| 9 | − | `team`, `workspace`, `billing`, `notifications` and `account`. Each tool takes an `action` that says what to do. Every | |
| 9 | + | `team`, `workspace`, `billing`, `notifications`, `account` and `artifact`. Each tool takes an `action` that says what to do. Every | |
| 10 | 10 | action is the same operation as a route of the [REST API](/reference/api/), | |
| 11 | 11 | with the same inputs, permissions and results, so the two always agree. | |
| 12 | 12 | ||
| ⋯ | |||
| 169 | 169 | | --- | --- | | |
| 170 | 170 | | `title` | The tool's name for people, such as `Pull requests`. | | |
| 171 | 171 | | `readOnlyHint` | `true` when every action shown only reads. | | |
| 172 | − | | `destructiveHint` | `true` when the tool is not read-only and an action shown cannot be undone or reaches beyond g1t's own records: deleting a workspace, deleting, purging or transferring a repository, changing its visibility, removing an email address or a collaborator, deleting a team or taking its role on a repository away, disconnecting an integration, deleting a webhook, setting or deleting secrets and variables, replacing model routes, setting a workspace's base permission, removing a member, transferring a workspace's ownership, leaving a workspace, merging a pull request, removing a self-hosted runner, deleting a runner group, and changing runner settings. | | |
| 172 | + | | `destructiveHint` | `true` when the tool is not read-only and an action shown cannot be undone or reaches beyond g1t's own records: deleting a workspace, deleting, purging or transferring a repository, changing its visibility, removing an email address or a collaborator, deleting a team or taking its role on a repository away, disconnecting an integration, deleting a webhook, setting or deleting secrets and variables, replacing model routes, setting a workspace's base permission, removing a member, transferring a workspace's ownership, leaving a workspace, merging a pull request, removing a self-hosted runner, deleting a runner group, changing runner settings, sharing an artifact, and deleting an artifact for good. | | |
| 173 | 173 | | `idempotentHint` | The same as `readOnlyHint`. | | |
| 174 | 174 | | `openWorldHint` | Always `false`. | | |
| 175 | 175 | ||
| ⋯ | |||
| 768 | 768 | | [`decline_repository_invitation`](/reference/api/access/decline-repo-invitation/) | Decline one. | `id` | `account:write` | | |
| 769 | 769 | ||
| 770 | 770 | ||
| 771 | + | ## `artifact` | |
| 772 | + | ||
| 773 | + | A workspace's artifacts: its docs, and later its slides, designs and | |
| 774 | + | dashboards. Each action runs as you: it finds and opens only what you can | |
| 775 | + | open, and changes only what your role on it allows, whatever the token's | |
| 776 | + | scope. Name one by its id (`fol_…`) or its link as `artifact_id`, and its | |
| 777 | + | space by slug or id. Not workflow runs' artifacts, which are the | |
| 778 | + | `workflow` tool's. Slides, designs and dashboards answer `422` saying they | |
| 779 | + | are not here yet. A workspace's own token cannot use this tool. See | |
| 780 | + | [artifacts for agents](/guides/bring-your-own-agent/#artifacts). | |
| 781 | + | ||
| 782 | + | | Action | What it does | Required | Scope | | |
| 783 | + | | --- | --- | --- | --- | | |
| 784 | + | | [`list`](/reference/api/artifacts/list-workspace-artifacts/) | The artifacts you can open, most recently edited first, with your role on each. Narrow with `tab` (`all`, `yours`, `shared`), `kind`, `space`, `project` and `q`; page with `cursor`. `state` `trashed` lists what you can restore. | `workspace` | `artifacts:read` | | |
| 785 | + | | [`search`](/reference/api/artifacts/search-workspace-artifacts/) | Search them by words and meaning; each hit has the passage that matched (`snippet`, `heading`). | `workspace`, `q` | `artifacts:read` | | |
| 786 | + | | [`get`](/reference/api/artifacts/get-workspace-artifact/) | One artifact: kind, title, space, owner, your `viewer_role`, general access, whether it is private or stale, and `html_url`. | `workspace`, `artifact_id` | `artifacts:read` | | |
| 787 | + | | [`read`](/reference/api/artifacts/get-workspace-artifact-content/) | Its content: a doc's Markdown, its top-level `blocks` with ids, and `can` (read, suggest, edit). | `workspace`, `artifact_id` | `artifacts:read` | | |
| 788 | + | | [`versions`](/reference/api/artifacts/list-workspace-artifact-versions/) | Its saved versions, newest first, with who made each. | `workspace`, `artifact_id` | `artifacts:read` | | |
| 789 | + | | [`access`](/reference/api/artifacts/get-workspace-artifact-access/) | Who can open it: its owner, who it is shared with and how, general access, and whether you may change it. | `workspace`, `artifact_id` | `artifacts:read` | | |
| 790 | + | | [`templates`](/reference/api/artifacts/list-workspace-artifact-templates/) | Templates to start one from, built-in and the workspace's; narrow with `kind`. | `workspace` | `artifacts:read` | | |
| 791 | + | | [`spaces`](/reference/api/artifacts/list-workspace-artifact-spaces/) | The spaces in your Artifacts sidebar, with your role in each. | `workspace` | `artifacts:read` | | |
| 792 | + | | [`query_data`](/reference/api/artifacts/query-workspace-dataset/) | Run a dataset `query` as you, over what you can read. Answers that dashboards are not here yet until they ship. | `workspace`, `query` | `artifacts:read` | | |
| 793 | + | | [`create`](/reference/api/artifacts/create-workspace-artifact/) | Make one from `markdown` or a `template_id`, in a `space`, under a `parent_id`, or in your Private. `kind` is `doc`; the others are not here yet. | `workspace` | `artifacts:write` | | |
| 794 | + | | [`update`](/reference/api/artifacts/update-workspace-artifact/) | Change its `title` or `icon`, or move it to a `space` (`private` for your Private) or under a `parent_id`. | `workspace`, `artifact_id` | `artifacts:write` | | |
| 795 | + | | [`edit`](/reference/api/artifacts/edit-workspace-artifact/) | Change its content: `markdown` with a `target` (`append`, `document`, a `section` by `heading`, or `blocks`). Made with the edit role; a suggestion with the comment role or `suggest_only`. | `workspace`, `artifact_id` | `artifacts:write` | | |
| 796 | + | | [`trash`](/reference/api/artifacts/trash-workspace-artifact/) | Move it, and what is under it, to the trash; deleted for good after 30 days. | `workspace`, `artifact_id` | `artifacts:write` | | |
| 797 | + | | [`restore`](/reference/api/artifacts/restore-workspace-artifact/) | Bring it back from the trash. | `workspace`, `artifact_id` | `artifacts:write` | | |
| 798 | + | | [`restore_version`](/reference/api/artifacts/restore-workspace-artifact-version/) | Make an earlier version its content again, as a new version. | `workspace`, `artifact_id`, `version_id` | `artifacts:write` | | |
| 799 | + | | [`share`](/reference/api/artifacts/set-workspace-artifact-access/) | Share it with a `username`, `team` or `agent` at a `role` (`view`, `comment`, `edit`, `manage`, or `none` to take access away); set `general_access` and `general_role`, `inherit` or `agent_mode`. Takes full access to it. | `workspace`, `artifact_id` | `artifacts:admin` | | |
| 800 | + | | [`purge`](/reference/api/artifacts/purge-workspace-artifact/) | Delete one in the trash for good. Takes full access to it. | `workspace`, `artifact_id` | `artifacts:admin` | | |
| 801 | + | ||
| 771 | 802 | ## What g1t can use | |
| 772 | 803 | ||
| 773 | 804 | g1t works with a [run credential](/guides/working-with-g1t/#credentials): | |
| ⋯ | |||
| 792 | 823 | reopens a security alert, or the `security` actions that decide about | |
| 793 | 824 | security: `update_secret_alert`, `bypass`, `review_bypass`, the pattern | |
| 794 | 825 | changes, `update_code_alert`, `update_vulnerability_alert`, `fix`, | |
| 795 | − | `update_settings` and `update_workspace_settings`. Every repository it | |
| 826 | + | `update_settings` and `update_workspace_settings`, or `artifact` `share` | |
| 827 | + | and `purge`. Every repository it | |
| 796 | 828 | names must be its own. `tools/list` shows such a token only the tools and | |
| 797 | 829 | actions it may use; a call to any other is refused with the rule that | |
| 798 | 830 | refused it, and recorded in the workspace's [audit log](/guides/audit-log/), | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.