Skip to content
306 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971//! Deployments over REST and MCP: a repository's deployments wherever they
2//! run, their statuses, and its environments.
3//!
4//! Any CI reports a deployment and its statuses here; a g1t Actions job
5//! with an `environment:` makes them itself, and g1t.page builds are read
6//! in alongside. The deployments service decides who may see and report
7//! them (Read to see, Write to report) and keeps them; deployments travel
8//! in `snake_case` between services too, so a request's fields reach it
9//! as they are, and a deployment's `payload` comes back as it was given.
10
11use g1t_contracts::{FailureCode, Outcome, Viewer};
12use serde_json::{Map, Value, json};
13use worker::Result;
14
15use crate::operations::{Services, repo_path};
16
17/// One operation on deployments.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum DeploymentsOp {
20 ListDeployments,
21 GetDeployment,
22 CreateDeployment,
23 ListDeploymentStatuses,
24 CreateDeploymentStatus,
25 ListEnvironments,
26 GetEnvironment,
27}
28
29/// The states a deployment's status can have.
30const STATES: [&str; 6] = ["queued", "in_progress", "success", "failure", "error", "inactive"];
31
32impl DeploymentsOp {
33 /// Every one: `Op::ALL` lists each as `Op::Deployments(…)`, which a
34 /// test checks against this.
35 #[cfg(test)]
36 pub const ALL: [DeploymentsOp; 7] = [
37 DeploymentsOp::ListDeployments,
38 DeploymentsOp::GetDeployment,
39 DeploymentsOp::CreateDeployment,
40 DeploymentsOp::ListDeploymentStatuses,
41 DeploymentsOp::CreateDeploymentStatus,
42 DeploymentsOp::ListEnvironments,
43 DeploymentsOp::GetEnvironment,
44 ];
45
46 pub fn name(self) -> &'static str {
47 match self {
48 DeploymentsOp::ListDeployments => "list_deployments",
49 DeploymentsOp::GetDeployment => "get_deployment",
50 DeploymentsOp::CreateDeployment => "create_deployment",
51 DeploymentsOp::ListDeploymentStatuses => "list_deployment_statuses",
52 DeploymentsOp::CreateDeploymentStatus => "create_deployment_status",
53 DeploymentsOp::ListEnvironments => "list_environments",
54 DeploymentsOp::GetEnvironment => "get_environment",
55 }
56 }
57
58 /// For the API reference: "List deployments".
59 pub fn title(self) -> &'static str {
60 match self {
61 DeploymentsOp::ListDeployments => "List deployments",
62 DeploymentsOp::GetDeployment => "Get a deployment",
63 DeploymentsOp::CreateDeployment => "Create a deployment",
64 DeploymentsOp::ListDeploymentStatuses => "List deployment statuses",
65 DeploymentsOp::CreateDeploymentStatus => "Create a deployment status",
66 DeploymentsOp::ListEnvironments => "List environments",
67 DeploymentsOp::GetEnvironment => "Get an environment",
68 }
69 }
70
71 pub fn description(self) -> &'static str {
72 match self {
73 DeploymentsOp::ListDeployments => "List a repository's deployments wherever they run, newest first: those reported through this API, those g1t Actions made for jobs with an `environment:`, and g1t.page builds (production and previews). Each has its environment, ref and sha, task, description, payload, transient_environment and production_environment, its latest state (queued, in_progress, success, failure, error or inactive), environment_url and log_url, creator, and source (api, actions or g1t_page), with run_id and run_url for g1t Actions and project and number for g1t.page. Filter by environment, ref, sha (or a prefix), task, state, source and creator; page with page and per_page (30 by default, at most 100). total_count counts every match. Needs the Read role; a public repository's are open to anyone.",
74 DeploymentsOp::GetDeployment => "Get one deployment by id (dep_… for a reported one, dpl_… for a g1t.page build), with every status it has had, oldest first. Needs the Read role.",
75 DeploymentsOp::CreateDeployment => "Report a deployment of a commit to an environment, from any CI or script. ref is the branch, tag or commit deployed; sha is resolved from it unless you give the whole commit id. environment is production unless you say (any name up to 255 characters, such as staging or review/feature-x; names are matched without regard to case, and the first spelling is kept). task is deploy unless you say; payload is any JSON object, returned as given. production_environment is true for an environment named production unless you say; transient_environment marks one that goes away, such as a review app. Its first status is state (queued unless you say), with environment_url and log_url. Each status also shows on the commit as the check `deploy / <environment>`, which a ruleset's required_deployments rule can require. Needs the Write role. Returns the deployment with its statuses.",
76 DeploymentsOp::ListDeploymentStatuses => "List a deployment's statuses, newest first: each with its state, description, environment_url, log_url, creator and created_at. A g1t.page build's are read from the build itself. Needs the Read role.",
77 DeploymentsOp::CreateDeploymentStatus => "Add a status to a reported deployment: state (queued, in_progress, success, failure, error or inactive), description, environment_url (where it is served) and log_url (where its output can be read). The deployment takes its state, and any address it gives. A success with auto_inactive (true unless you say) makes the environment's older successful deployments inactive. The commit's `deploy / <environment>` check follows: pending while queued or in progress, then success, failure or error. A g1t.page build's statuses come from the build and cannot be added to. Needs the Write role.",
Merge branch 'worktree-agent-a3abfcce648e87dca'78 DeploymentsOp::ListEnvironments => "List the environments a repository's deployments went to, those people use directly first (production by name before others), then the most recently deployed, and after them those with protection rules but no deployment yet. Each has its name, url (where its current deployment is served), production_environment, transient_environment, deployments_count, latest (its newest deployment, whatever its state), current (its newest successful deployment that is still active) and updated_at, and, when it has rules, protection_rules (required_reviewers, wait_timer, branch_policy), deployment_branch_policy, branch_policies and can_admins_bypass. total_count counts deployments across every environment. Needs the Read role.",
79 DeploymentsOp::GetEnvironment => "Get one environment by name, matched without regard to case, with its current and latest deployments and its protection rules (see update_environment). An environment with rules but no deployment yet is found too. A name with slashes is URL-encoded in the path. Needs the Read role.",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9780 }
81 }
82
83 /// Whether it changes anything.
84 pub fn writes(self) -> bool {
85 matches!(self, DeploymentsOp::CreateDeployment | DeploymentsOp::CreateDeploymentStatus)
86 }
87
88 pub fn input(self) -> Value {
89 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
90 let id = json!({ "type": "string", "description": "The deployment's id: dep_… for a reported one, dpl_… for a g1t.page build." });
91 let state = |what: &str| json!({ "type": "string", "enum": STATES, "description": what });
92 let address = |what: &str| json!({ "type": "string", "description": what });
93 let (properties, required): (Value, &[&str]) = match self {
94 DeploymentsOp::ListDeployments => (
95 json!({
96 "repo": repo,
97 "environment": { "type": "string", "description": "Only this environment's, matched without regard to case." },
98 "ref": { "type": "string", "description": "Only deployments of this branch, tag or commit as it was given." },
99 "sha": { "type": "string", "description": "Only deployments of this commit, or of commits starting with it." },
100 "task": { "type": "string", "description": "Only this task's, such as deploy." },
101 "state": state("Only deployments whose latest status has this state."),
102 "source": { "type": "string", "enum": ["api", "actions", "g1t_page"], "description": "Only those reported through the API, made by g1t Actions, or built on g1t.page." },
103 "creator": { "type": "string", "description": "Only those this username (or g1t) made." },
104 "page": { "type": "integer", "description": "Which page, from 1." },
105 "per_page": { "type": "integer", "description": "How many a page holds, 1 to 100; 30 by default." },
106 }),
107 &["repo"],
108 ),
109 DeploymentsOp::GetDeployment | DeploymentsOp::ListDeploymentStatuses => (json!({ "repo": repo, "id": id }), &["repo", "id"]),
110 DeploymentsOp::CreateDeployment => (
111 json!({
112 "repo": repo,
113 "ref": { "type": "string", "description": "The branch, tag or commit deployed, such as main or v1.4.0." },
114 "sha": { "type": "string", "description": "The commit deployed; resolved from ref when left out." },
115 "environment": { "type": "string", "description": "Where it went, such as production, staging or review/feature-x; production unless you say." },
116 "task": { "type": "string", "description": "What kind of deployment, such as deploy or deploy:migrations; deploy unless you say." },
117 "description": { "type": "string", "description": "A short note, at most 1,000 characters." },
118 "payload": { "type": "object", "description": "Anything else to keep with it, as a JSON object (a JSON string of one is read too), at most 64 KB. Returned as given." },
119 "production_environment": { "type": "boolean", "description": "Whether people use this environment directly. True for production unless you say." },
120 "transient_environment": { "type": "boolean", "description": "Whether the environment goes away, such as a review app. False unless you say." },
121 "state": state("Its first status: queued unless you say. Report in_progress, then success or failure, as it goes."),
122 "environment_url": address("Where it is served, an http(s) address."),
123 "log_url": address("Where its output can be read, an http(s) address."),
124 }),
125 &["repo", "ref"],
126 ),
127 DeploymentsOp::CreateDeploymentStatus => (
128 json!({
129 "repo": repo,
130 "id": id,
131 "state": state("Where it is now."),
132 "description": { "type": "string", "description": "A short note, at most 1,000 characters." },
133 "environment_url": address("Where it is served, an http(s) address."),
134 "log_url": address("Where its output can be read, an http(s) address."),
135 "auto_inactive": { "type": "boolean", "description": "On a success, make the environment's older successful deployments inactive. True unless you say." },
136 }),
137 &["repo", "id", "state"],
138 ),
139 DeploymentsOp::ListEnvironments => (json!({ "repo": repo }), &["repo"]),
140 DeploymentsOp::GetEnvironment => (
141 json!({
142 "repo": repo,
143 "environment": { "type": "string", "description": "The environment's name, such as production." },
144 }),
145 &["repo", "environment"],
146 ),
147 };
148 json!({ "type": "object", "properties": properties, "required": required })
149 }
150}
151
152/// The fields of `input` named in `keys` that were given, as they were.
153fn given(input: &Value, keys: &[&str]) -> Map<String, Value> {
154 keys.iter()
155 .filter_map(|key| input.get(*key).filter(|value| !value.is_null()).map(|value| ((*key).to_owned(), value.clone())))
156 .collect()
157}
158
159/// A query parameter's number, given as a number or as text.
160fn number(input: &Value, key: &str) -> Option<u64> {
161 match &input[key] {
162 Value::Number(number) => number.as_u64(),
163 Value::String(digits) => digits.trim().parse().ok(),
164 _ => None,
165 }
166}
167
168/// A flag given as a boolean or as text.
169fn flag(input: &Value, key: &str) -> Option<bool> {
170 match &input[key] {
171 Value::Bool(value) => Some(*value),
172 Value::String(text) => match text.trim() {
173 "true" | "1" => Some(true),
174 "false" | "0" => Some(false),
175 _ => None,
176 },
177 _ => None,
178 }
179}
180
181/// The arguments the deployments service takes for `op`, from the
182/// operation's input; `Err` says what is missing or wrong.
183pub(crate) fn args(op: DeploymentsOp, input: &Value) -> std::result::Result<(&'static str, Map<String, Value>), String> {
184 let mut out = Map::new();
185 let repo = repo_path(input).ok_or("Give the repository as \"owner/name\".")?;
186 out.insert("repo".into(), json!({ "namespace": repo.namespace, "name": repo.name }));
187 let id = || input["id"].as_str().map(str::trim).filter(|id| !id.is_empty()).map(str::to_owned).ok_or("Give the deployment's id.");
188 if let Some(state) = input["state"].as_str()
189 && !STATES.contains(&state)
190 {
191 return Err(format!("{state} is not a state: use queued, in_progress, success, failure, error or inactive."));
192 }
193 let method = match op {
194 DeploymentsOp::ListDeployments => {
195 out.extend(given(input, &["environment", "ref", "sha", "task", "state", "source", "creator"]));
196 if let Some(page) = number(input, "page") {
197 out.insert("page".into(), page.into());
198 }
199 if let Some(per_page) = number(input, "per_page") {
200 out.insert("per_page".into(), per_page.into());
201 }
202 "list_deployments"
203 }
204 DeploymentsOp::GetDeployment => {
205 out.insert("id".into(), id()?.into());
206 "get_deployment"
207 }
208 DeploymentsOp::ListDeploymentStatuses => {
209 out.insert("id".into(), id()?.into());
210 "list_deployment_statuses"
211 }
212 DeploymentsOp::CreateDeployment => {
213 if input["ref"].as_str().is_none_or(|text| text.trim().is_empty()) && input["sha"].as_str().is_none() {
214 return Err("Give the ref deployed: a branch, a tag or a commit.".to_owned());
215 }
216 out.extend(given(
217 input,
218 &["ref", "sha", "environment", "task", "description", "payload", "state", "environment_url", "log_url"],
219 ));
220 for key in ["production_environment", "transient_environment"] {
221 if let Some(value) = flag(input, key) {
222 out.insert(key.into(), value.into());
223 }
224 }
225 "create_deployment"
226 }
227 DeploymentsOp::CreateDeploymentStatus => {
228 out.insert("id".into(), id()?.into());
229 if input["state"].as_str().is_none() {
230 return Err("Give the status's state: queued, in_progress, success, failure, error or inactive.".to_owned());
231 }
232 out.extend(given(input, &["state", "description", "environment_url", "log_url"]));
233 if let Some(value) = flag(input, "auto_inactive") {
234 out.insert("auto_inactive".into(), value.into());
235 }
236 "create_deployment_status"
237 }
238 DeploymentsOp::ListEnvironments => "list_environments",
239 DeploymentsOp::GetEnvironment => {
240 let name = input["environment"].as_str().map(str::trim).filter(|name| !name.is_empty()).ok_or("Name the environment.")?;
241 out.insert("name".into(), name.into());
242 "get_environment"
243 }
244 };
245 Ok((method, out))
246}
247
248pub async fn run(op: DeploymentsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
249 let (method, mut args) = match args(op, input) {
250 Ok(found) => found,
251 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
252 };
253 if op.writes() {
254 let Some(actor) = viewer else {
255 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Reporting a deployment needs a g1t access token."));
256 };
257 args.insert("actor".into(), serde_json::to_value(actor)?);
258 } else {
259 args.insert("viewer".into(), serde_json::to_value(viewer)?);
260 }
Merge branch 'worktree-agent-a3abfcce648e87dca'261 let answered: Outcome<Value> = g1t_kit::call(&services.deployments, method, &Value::Object(args)).await?;
262 // Environments carry their protection rules, kept by the actions service.
263 match op {
264 DeploymentsOp::ListEnvironments => crate::protection::with_protection(services, viewer, input, answered, None).await,
265 DeploymentsOp::GetEnvironment => {
266 let name = input["environment"].as_str().unwrap_or_default().trim().to_owned();
267 crate::protection::with_protection(services, viewer, input, answered, Some(&name)).await
268 }
269 _ => Ok(answered),
270 }
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn a_request_becomes_the_services_arguments() {
279 let (method, args) = super::args(
280 DeploymentsOp::CreateDeployment,
281 &json!({ "repo": "acme/web", "ref": "main", "environment": "staging", "payload": { "buildId": 7 }, "production_environment": "false", "ignored": 1 }),
282 )
283 .unwrap();
284 assert_eq!(method, "create_deployment");
285 assert_eq!(args["repo"], json!({ "namespace": "acme", "name": "web" }));
286 assert_eq!(args["payload"], json!({ "buildId": 7 }), "a payload passes through as given");
287 assert_eq!(args["production_environment"], json!(false));
288 assert!(!args.contains_key("ignored"));
289 let (method, args) = super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "page": "2", "state": "failure" })).unwrap();
290 assert_eq!(method, "list_deployments");
291 assert_eq!(args["page"], json!(2));
292 assert!(super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "state": "done" })).is_err());
293 assert!(super::args(DeploymentsOp::CreateDeployment, &json!({ "repo": "acme/web" })).is_err());
294 assert!(super::args(DeploymentsOp::CreateDeploymentStatus, &json!({ "repo": "acme/web", "id": "dep_1" })).is_err());
295 let (method, args) = super::args(DeploymentsOp::GetEnvironment, &json!({ "repo": "acme/web", "environment": "review/x" })).unwrap();
296 assert_eq!((method, args["name"].clone()), ("get_environment", json!("review/x")));
297 }
298
299 #[test]
300 fn each_operation_is_described_with_a_schema() {
301 for op in DeploymentsOp::ALL {
302 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
303 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
304 }
305 }
306}

This file's history is long; its oldest lines are credited to the oldest commit read.