Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
153 linesCodeBlameRaw
1//! A person's own Getting started in a workspace, over REST: the setup
2//! guide its owners see on its Home, at
3//! `GET`/`PATCH /workspaces/{workspace}/onboarding`. Identity keeps it
4//! (services/identity/src/onboarding.rs): whether the person dismissed it
5//! and the steps they skipped. Whether each step is done is read from the
6//! workspace itself, so it is not here. People only, each their own; not
7//! an action of any MCP tool, since it is about a person at a page.
8
9use g1t_contracts::identity::{ONBOARDING_STEPS, OnboardingChange};
10use g1t_contracts::{FailureCode, Outcome, Viewer};
11use serde_json::{Value, json};
12use worker::Result;
13
14use crate::operations::Services;
15
16/// One operation.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum OnboardingOp {
19 GetOnboarding,
20 UpdateOnboarding,
21}
22
23impl OnboardingOp {
24 /// Every one: `Op::ALL` lists each as `Op::Onboarding(…)`, which a test
25 /// checks against this.
26 #[cfg(test)]
27 pub const ALL: [OnboardingOp; 2] = [OnboardingOp::GetOnboarding, OnboardingOp::UpdateOnboarding];
28
29 pub fn name(self) -> &'static str {
30 match self {
31 OnboardingOp::GetOnboarding => "get_onboarding",
32 OnboardingOp::UpdateOnboarding => "update_onboarding",
33 }
34 }
35
36 pub fn title(self) -> &'static str {
37 match self {
38 OnboardingOp::GetOnboarding => "Get your Getting started in a workspace",
39 OnboardingOp::UpdateOnboarding => "Dismiss or skip steps of Getting started",
40 }
41 }
42
43 pub fn description(self) -> &'static str {
44 match self {
45 OnboardingOp::GetOnboarding => "Your own state of a workspace's Getting started, the setup guide that opens on its Home for its owners: dismissed_at (when you dismissed it, so it no longer opens by itself; null while it does), skipped (the steps you chose to skip, in the guide's order: ai, ask, code, harness, people, runner) and updated_at (null when you never changed it). Whether a step is done is not here: it is the workspace's own state, such as its AI providers, repositories, members and runners. Members only, as people; each person's own.",
46 OnboardingOp::UpdateOnboarding => "Change your own Getting started in a workspace; fields left out stay as they are. dismissed true dismisses it, so it no longer opens by itself on the workspace's Home (dismissing it again keeps the first time); false makes it open by itself again. skipped replaces the steps you skipped: any of ai, ask, code, harness, people and runner. Opening the guide from Settings or the AI page changes nothing here. Members only, as people; each person's own.",
47 }
48 }
49
50 /// Whether it changes anything: checked against the scope table in tests.
51 #[cfg(test)]
52 pub fn writes(self) -> bool {
53 self == OnboardingOp::UpdateOnboarding
54 }
55
56 pub fn input(self) -> Value {
57 let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." });
58 let (properties, required): (Value, &[&str]) = match self {
59 OnboardingOp::GetOnboarding => (json!({ "workspace": workspace }), &["workspace"]),
60 OnboardingOp::UpdateOnboarding => (
61 json!({
62 "workspace": workspace,
63 "dismissed": { "type": "boolean", "description": "True dismisses it, so it no longer opens by itself; false makes it open by itself again." },
64 "skipped": {
65 "type": "array",
66 "items": { "type": "string", "enum": ONBOARDING_STEPS },
67 "description": "The steps you skip, replacing those you skipped before. An empty list skips none.",
68 },
69 }),
70 &["workspace"],
71 ),
72 };
73 json!({ "type": "object", "properties": properties, "required": required })
74 }
75}
76
77/// `dismissed` as sent: a boolean, or a word from a form; null when left out.
78fn dismissed(input: &Value) -> std::result::Result<Option<bool>, ()> {
79 match &input["dismissed"] {
80 Value::Null => Ok(None),
81 Value::Bool(on) => Ok(Some(*on)),
82 Value::String(word) => match word.trim() {
83 "true" | "1" => Ok(Some(true)),
84 "false" | "0" => Ok(Some(false)),
85 _ => Err(()),
86 },
87 _ => Err(()),
88 }
89}
90
91/// `skipped` as sent: a list of step names; null when left out.
92fn skipped(input: &Value) -> std::result::Result<Option<Vec<String>>, ()> {
93 match &input["skipped"] {
94 Value::Null => Ok(None),
95 Value::Array(items) => items.iter().map(|item| item.as_str().map(str::to_owned).ok_or(())).collect::<std::result::Result<Vec<_>, _>>().map(Some),
96 _ => Err(()),
97 }
98}
99
100pub async fn run(op: OnboardingOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
101 let Some(user) = viewer.clone() else {
102 return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."));
103 };
104 let workspace = input["workspace"].as_str().map(str::trim).unwrap_or_default().to_lowercase();
105 if workspace.is_empty() {
106 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace."));
107 }
108 let identity = &services.identity;
109 match op {
110 OnboardingOp::GetOnboarding => g1t_kit::call(identity, "onboarding", &json!({ "user": user, "workspace": workspace })).await,
111 OnboardingOp::UpdateOnboarding => {
112 let Ok(dismissed) = dismissed(input) else {
113 return Ok(Outcome::fail(FailureCode::Invalid, "dismissed is true or false."));
114 };
115 let Ok(skipped) = skipped(input) else {
116 return Ok(Outcome::fail(FailureCode::Invalid, "skipped is a list of step names."));
117 };
118 let change = OnboardingChange { dismissed, skipped };
119 g1t_kit::call(identity, "set_onboarding", &json!({ "user": user, "workspace": workspace, "change": change })).await
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use g1t_contracts::scopes::{Level, scope_for};
128
129 #[test]
130 fn a_change_is_read_from_what_was_sent() {
131 assert_eq!(dismissed(&json!({})), Ok(None));
132 assert_eq!(dismissed(&json!({ "dismissed": true })), Ok(Some(true)));
133 assert_eq!(dismissed(&json!({ "dismissed": "false" })), Ok(Some(false)));
134 assert!(dismissed(&json!({ "dismissed": "maybe" })).is_err());
135 assert_eq!(skipped(&json!({})), Ok(None));
136 assert_eq!(skipped(&json!({ "skipped": ["people"] })), Ok(Some(vec!["people".to_owned()])));
137 assert_eq!(skipped(&json!({ "skipped": [] })), Ok(Some(Vec::new())));
138 assert!(skipped(&json!({ "skipped": "people" })).is_err());
139 assert!(skipped(&json!({ "skipped": [1] })).is_err());
140 }
141
142 #[test]
143 fn each_operation_is_described_and_scoped() {
144 for op in OnboardingOp::ALL {
145 assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Onboarding(op)), "{}", op.name());
146 assert!(crate::tools::REST_ONLY.contains(&crate::operations::Op::Onboarding(op)), "{}", op.name());
147 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
148 assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name());
149 let level = scope_for(op.name()).unwrap().level();
150 assert_eq!(level, if op.writes() { Level::Write } else { Level::Read }, "{}", op.name());
151 }
152 }
153}