Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

4 commits

300 files+6507−580/300 viewed
+77−0
8181 ]
8282
8383 [[package]]
84+name = "aho-corasick"
85+version = "1.1.5"
86+source = "registry+https://github.com/rust-lang/crates.io-index"
87+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
88+dependencies = [
89+ "memchr",
90+]
91+
92+[[package]]
8493 name = "android_system_properties"
8594 version = "0.1.6"
8695 source = "registry+https://github.com/rust-lang/crates.io-index"
108117 ]
109118
110119 [[package]]
120+name = "arraydeque"
121+version = "0.5.1"
122+source = "registry+https://github.com/rust-lang/crates.io-index"
123+checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
124+
125+[[package]]
111126 name = "async-trait"
112127 version = "0.1.92"
113128 source = "registry+https://github.com/rust-lang/crates.io-index"
774789 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
775790
776791 [[package]]
792+name = "foldhash"
793+version = "0.2.0"
794+source = "registry+https://github.com/rust-lang/crates.io-index"
795+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
796+
797+[[package]]
777798 name = "form_urlencoded"
778799 version = "1.2.2"
779800 source = "registry+https://github.com/rust-lang/crates.io-index"
10771098 version = "0.1.0"
10781099 dependencies = [
10791100 "miniz_oxide",
1101+ "regex",
10801102 "serde",
10811103 "serde_json",
10821104 "sha1 0.11.0",
11121134 version = "0.1.0"
11131135 dependencies = [
11141136 "futures-util",
1137+ "g1t-actions",
11151138 "g1t-contracts",
11161139 "g1t-kit",
11171140 "g1t-scan",
11191142 "serde",
11201143 "serde_json",
11211144 "serde_yaml",
1145+ "toml",
11221146 "worker",
1147+ "yaml-rust2",
11231148 ]
11241149
11251150 [[package]]
11541179 name = "g1t-work"
11551180 version = "0.1.0"
11561181 dependencies = [
1182+ "base64 0.22.1",
11571183 "futures-util",
11581184 "g1t-contracts",
11591185 "g1t-kit",
12681294 version = "0.17.1"
12691295 source = "registry+https://github.com/rust-lang/crates.io-index"
12701296 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1297+dependencies = [
1298+ "foldhash",
1299+]
12711300
12721301 [[package]]
1302+name = "hashlink"
1303+version = "0.12.2"
1304+source = "registry+https://github.com/rust-lang/crates.io-index"
1305+checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
1306+dependencies = [
1307+ "hashbrown",
1308+]
1309+
1310+[[package]]
12731311 name = "heck"
12741312 version = "0.5.0"
12751313 source = "registry+https://github.com/rust-lang/crates.io-index"
22672305 ]
22682306
22692307 [[package]]
2308+name = "regex"
2309+version = "1.13.1"
2310+source = "registry+https://github.com/rust-lang/crates.io-index"
2311+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
2312+dependencies = [
2313+ "aho-corasick",
2314+ "memchr",
2315+ "regex-automata",
2316+ "regex-syntax",
2317+]
2318+
2319+[[package]]
2320+name = "regex-automata"
2321+version = "0.4.18"
2322+source = "registry+https://github.com/rust-lang/crates.io-index"
2323+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
2324+dependencies = [
2325+ "aho-corasick",
2326+ "memchr",
2327+ "regex-syntax",
2328+]
2329+
2330+[[package]]
2331+name = "regex-syntax"
2332+version = "0.8.11"
2333+source = "registry+https://github.com/rust-lang/crates.io-index"
2334+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
2335+
2336+[[package]]
22702337 name = "reqwest"
22712338 version = "0.13.5"
22722339 source = "registry+https://github.com/rust-lang/crates.io-index"
38693936 checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
38703937
38713938 [[package]]
3939+name = "yaml-rust2"
3940+version = "0.13.0"
3941+source = "registry+https://github.com/rust-lang/crates.io-index"
3942+checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
3943+dependencies = [
3944+ "arraydeque",
3945+ "hashlink",
3946+]
3947+
3948+[[package]]
38723949 name = "yoke"
38733950 version = "0.8.3"
38743951 source = "registry+https://github.com/rust-lang/crates.io-index"
+603−0
1+//! Billing: a workspace's usage, budget, AI credit and invoices. The
2+//! billing service keeps them and answers in camelCase; this is their
3+//! public shape, in snake_case, with money as whole millionths of a dollar
4+//! (`_micros`) or, for invoices, cents (`_cents`).
5+//!
6+//! Reading is for the workspace's members, a workspace's own token
7+//! included. Changing the budget and buying AI credit are for its owners,
8+//! as people: signed in or with a personal access token. A workspace's
9+//! token and g1t's agents never change billing, whatever their scopes say.
10+
11+use std::collections::BTreeMap;
12+
13+use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
14+use serde_json::{Map, Value, json};
15+use worker::Result;
16+
17+use crate::operations::{Op, Services};
18+
19+/// The product families usage is grouped into, in order.
20+pub(crate) const PRODUCTS: [&str; 8] =
21+ ["agent", "sandboxes", "gateway", "deployments", "git_storage", "packages", "security", "search"];
22+
23+/// Where a budget's alerts can be, in percent of its limit.
24+pub(crate) const ALERT_LEVELS: [u32; 4] = [50, 75, 90, 100];
25+
26+/// How usage can be added up over its range.
27+pub(crate) const GROUPS: [&str; 3] = ["product", "project", "day"];
28+
29+fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
30+ Ok(Outcome::fail(code, message))
31+}
32+
33+/// `camelCase` as `snake_case`.
34+fn snake_key(key: &str) -> String {
35+ let mut out = String::with_capacity(key.len() + 4);
36+ for c in key.chars() {
37+ if c.is_ascii_uppercase() {
38+ if !out.is_empty() {
39+ out.push('_');
40+ }
41+ out.push(c.to_ascii_lowercase());
42+ } else {
43+ out.push(c);
44+ }
45+ }
46+ out
47+}
48+
49+/// A service's answer with every object key in `snake_case`, all the way
50+/// down. Billing's answers have no keys that are data, so all of them are
51+/// names.
52+pub(crate) fn snake(value: &Value) -> Value {
53+ match value {
54+ Value::Object(fields) => {
55+ Value::Object(fields.iter().map(|(key, value)| (snake_key(key), snake(value))).collect())
56+ }
57+ Value::Array(items) => Value::Array(items.iter().map(snake).collect()),
58+ other => other.clone(),
59+ }
60+}
61+
62+/// Strings given as an array, or as one string separated by commas (a
63+/// query string's way).
64+fn list(input: &Value, key: &str) -> Vec<String> {
65+ let items: Vec<String> = match &input[key] {
66+ Value::Array(items) => items.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect(),
67+ Value::String(text) => text.split(',').map(str::to_owned).collect(),
68+ _ => Vec::new(),
69+ };
70+ items.into_iter().map(|item| item.trim().to_owned()).filter(|item| !item.is_empty()).collect()
71+}
72+
73+fn workspace(input: &Value) -> Option<String> {
74+ input["workspace"].as_str().map(str::trim).filter(|slug| !slug.is_empty()).map(str::to_lowercase)
75+}
76+
77+/// `YYYY-MM-DD`.
78+fn is_day(text: &str) -> bool {
79+ let bytes = text.as_bytes();
80+ bytes.len() == 10
81+ && bytes.iter().enumerate().all(|(at, byte)| if at == 4 || at == 7 { *byte == b'-' } else { byte.is_ascii_digit() })
82+}
83+
84+/// The UTC day `days` after 1970-01-01, as `(year, month, day)`.
85+fn civil(days: i64) -> (i64, u32, u32) {
86+ let z = days + 719_468;
87+ let era = z.div_euclid(146_097);
88+ let doe = z.rem_euclid(146_097);
89+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
90+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
91+ let mp = (5 * doy + 2) / 153;
92+ let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
93+ let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
94+ let year = yoe + era * 400 + i64::from(month <= 2);
95+ (year, month, day)
96+}
97+
98+/// The first day of the current UTC month, and today, at `now_ms`.
99+pub(crate) fn this_month(now_ms: f64) -> (String, String) {
100+ let (year, month, day) = civil((now_ms / 86_400_000.0).floor() as i64);
101+ (format!("{year:04}-{month:02}-01"), format!("{year:04}-{month:02}-{day:02}"))
102+}
103+
104+/// The person who may change a workspace's billing: a person, never a
105+/// workspace's own token or one of g1t's agents. `agent_scoped` is whether
106+/// the request came with an agent's token.
107+pub(crate) fn person(viewer: &Viewer, agent_scoped: bool) -> std::result::Result<&User, (FailureCode, &'static str)> {
108+ match viewer {
109+ None => Err((FailureCode::Unauthenticated, "This needs a g1t access token.")),
110+ Some(user) if agent_scoped || user.kind == PrincipalKind::Agent => Err((
111+ FailureCode::Forbidden,
112+ "g1t's agents never change billing: a workspace's budget and AI credit are for its owners.",
113+ )),
114+ Some(user) if user.kind != PrincipalKind::User => Err((
115+ FailureCode::Forbidden,
116+ "Changing billing needs a person: sign in, or use a personal access token. A workspace's own token can read billing, not change it.",
117+ )),
118+ Some(user) => Ok(user),
119+ }
120+}
121+
122+/// A usage report (camelCase, as billing answers) in its public shape,
123+/// with `groups` when `group_by` asks for them.
124+pub(crate) fn usage_json(report: &Value, group_by: Option<&str>) -> Value {
125+ let mut out = snake(report);
126+ if let (Some(by), Some(fields)) = (group_by, out.as_object_mut()) {
127+ fields.insert("group_by".to_owned(), json!(by));
128+ fields.insert("groups".to_owned(), groups(report, by));
129+ }
130+ out
131+}
132+
133+fn micros(value: &Value) -> i64 {
134+ value.as_i64().or_else(|| value.as_f64().map(|n| n as i64)).unwrap_or(0)
135+}
136+
137+/// The report's range added up by product (every family, in order), by
138+/// project (most first; `key` null for usage that is no one project's) or
139+/// by day (oldest first).
140+fn groups(report: &Value, by: &str) -> Value {
141+ let products = report["products"].as_array().cloned().unwrap_or_default();
142+ match by {
143+ "product" => Value::Array(
144+ products
145+ .iter()
146+ .map(|product| json!({ "key": product["key"], "label": product["label"], "micros": micros(&product["micros"]) }))
147+ .collect(),
148+ ),
149+ "project" => {
150+ let mut sums: BTreeMap<String, i64> = BTreeMap::new();
151+ for product in &products {
152+ for meter in product["meters"].as_array().into_iter().flatten() {
153+ for part in meter["byProject"].as_array().into_iter().flatten() {
154+ *sums.entry(part["project"].as_str().unwrap_or_default().to_owned()).or_default() += micros(&part["micros"]);
155+ }
156+ }
157+ }
158+ let mut sums: Vec<(String, i64)> = sums.into_iter().collect();
159+ sums.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
160+ Value::Array(
161+ sums.into_iter()
162+ .map(|(project, micros)| {
163+ let key = if project.is_empty() { Value::Null } else { Value::String(project) };
164+ json!({ "key": key, "micros": micros })
165+ })
166+ .collect(),
167+ )
168+ }
169+ _ => {
170+ let mut sums: BTreeMap<String, i64> = BTreeMap::new();
171+ for day in report["days"].as_array().into_iter().flatten() {
172+ *sums.entry(day["day"].as_str().unwrap_or_default().to_owned()).or_default() += micros(&day["micros"]);
173+ }
174+ Value::Array(sums.into_iter().map(|(day, micros)| json!({ "key": day, "micros": micros })).collect())
175+ }
176+ }
177+}
178+
179+/// A workspace's limit (camelCase, as billing answers) as its budget.
180+pub(crate) fn budget_json(limit: &Value) -> Value {
181+ json!({
182+ "workspace": limit["workspace"],
183+ "amount_micros": limit["spendLimitMicros"],
184+ "automatic": limit["defaultSpendLimit"].as_bool().unwrap_or(false),
185+ "spent_micros": micros(&limit["spentMicros"]),
186+ "max_amount_micros": limit["availableMicros"],
187+ "alerts": limit["alertLevels"].as_array().cloned().unwrap_or_default(),
188+ "pause_at_limit": limit["pauseAtLimit"].as_bool().unwrap_or(true),
189+ "webhook": limit["budgetWebhook"],
190+ "state": limit["state"],
191+ "message": limit["message"],
192+ })
193+}
194+
195+/// What set_budget asks billing for: the fields given, and the rest as
196+/// they are in `current` (the workspace's limit, camelCase).
197+#[derive(Debug, PartialEq)]
198+pub(crate) struct BudgetChange {
199+ /// No amount was given: billing leaves the limit as it is, whatever
200+ /// it is by the time it is asked.
201+ pub keep_limit: bool,
202+ pub amount_micros: Option<i64>,
203+ pub alerts: Vec<u32>,
204+ pub pause_at_limit: bool,
205+ pub webhook: Option<String>,
206+}
207+
208+pub(crate) fn budget_change(input: &Value, current: &Value) -> std::result::Result<BudgetChange, String> {
209+ let amount_micros = match input.get("amount_micros") {
210+ None if current["defaultSpendLimit"].as_bool() == Some(true) => None,
211+ None => current["spendLimitMicros"].as_i64(),
212+ Some(Value::Null) => None,
213+ Some(value) => {
214+ let amount = value.as_i64().or_else(|| value.as_str().and_then(|digits| digits.trim().parse().ok()));
215+ match amount {
216+ Some(amount) if amount >= 0 => Some(amount),
217+ _ => return Err("amount_micros is a whole number of millionths of a dollar, or null for the automatic limit.".to_owned()),
218+ }
219+ }
220+ };
221+ let alerts = match input.get("alerts") {
222+ None | Some(Value::Null) => current["alertLevels"]
223+ .as_array()
224+ .map(|levels| levels.iter().filter_map(|level| level.as_u64()).filter_map(|level| u32::try_from(level).ok()).collect())
225+ .unwrap_or_default(),
226+ Some(Value::Array(levels)) => {
227+ let mut chosen = Vec::new();
228+ for level in levels {
229+ let level = level.as_u64().or_else(|| level.as_str().and_then(|digits| digits.trim().parse().ok()));
230+ match level.and_then(|level| u32::try_from(level).ok()).filter(|level| ALERT_LEVELS.contains(level)) {
231+ Some(level) if !chosen.contains(&level) => chosen.push(level),
232+ Some(_) => {}
233+ None => return Err("alerts are some of 50, 75, 90 and 100.".to_owned()),
234+ }
235+ }
236+ chosen.sort_unstable();
237+ chosen
238+ }
239+ Some(_) => return Err("alerts is a list: some of 50, 75, 90 and 100.".to_owned()),
240+ };
241+ let pause_at_limit = match input.get("pause_at_limit") {
242+ None | Some(Value::Null) => current["pauseAtLimit"].as_bool().unwrap_or(true),
243+ Some(Value::Bool(pause)) => *pause,
244+ Some(Value::String(word)) if word == "true" || word == "false" => word == "true",
245+ Some(_) => return Err("pause_at_limit is true or false.".to_owned()),
246+ };
247+ let webhook = match input.get("webhook") {
248+ None => current["budgetWebhook"].as_str().map(str::to_owned),
249+ Some(Value::Null) => None,
250+ Some(Value::String(url)) if url.trim().is_empty() => None,
251+ Some(Value::String(url)) if url.trim().starts_with("https://") => Some(url.trim().to_owned()),
252+ Some(_) => return Err("webhook is an https:// address, or null for none.".to_owned()),
253+ };
254+ Ok(BudgetChange { keep_limit: input.get("amount_micros").is_none(), amount_micros, alerts, pause_at_limit, webhook })
255+}
256+
257+/// Billing details without the invoices, which list_invoices gives.
258+pub(crate) fn details_json(details: &Value) -> Value {
259+ let mut out = snake(details);
260+ if let Some(fields) = out.as_object_mut() {
261+ fields.remove("invoices");
262+ fields.remove("upcoming");
263+ fields.remove("unavailable");
264+ }
265+ out
266+}
267+
268+/// Every invoice billed to the workspace, g1t's itemised usage invoices,
269+/// and what the next one comes to so far.
270+pub(crate) fn invoices_json(details: &Value, usage: &[Value]) -> Value {
271+ let usage: Vec<Value> = usage
272+ .iter()
273+ .map(|invoice| {
274+ let mut fields = Map::new();
275+ fields.insert("id".to_owned(), invoice["invoiceId"].clone());
276+ if let Value::Object(rest) = snake(invoice) {
277+ fields.extend(rest.into_iter().filter(|(key, _)| key != "invoice_id"));
278+ }
279+ Value::Object(fields)
280+ })
281+ .collect();
282+ json!({
283+ "invoices": snake(&details["invoices"]).as_array().cloned().unwrap_or_default(),
284+ "usage_invoices": usage,
285+ "upcoming": snake(&details["upcoming"]),
286+ "unavailable": details["unavailable"],
287+ })
288+}
289+
290+/// Runs one of the billing operations.
291+pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
292+ if viewer.is_none() {
293+ return failed(FailureCode::Unauthenticated, "This needs a g1t access token.");
294+ }
295+ let Some(workspace) = workspace(input) else {
296+ return failed(FailureCode::Invalid, "Give the workspace's slug.");
297+ };
298+ let billing = &services.billing;
299+ let shaped = |outcome: Outcome<Value>, shape: &dyn Fn(&Value) -> Value| -> Result<Outcome<Value>> {
300+ Ok(match outcome {
301+ Outcome::Ok(value) => Outcome::Ok(shape(&value)),
302+ Outcome::Fail(failure) => Outcome::Fail(failure),
303+ })
304+ };
305+ let account = json!({ "workspace": workspace, "viewer": viewer });
306+ match op {
307+ Op::GetUsage => {
308+ let group_by = input["group_by"].as_str().map(str::trim).filter(|by| !by.is_empty()).map(str::to_lowercase);
309+ if let Some(by) = &group_by
310+ && !GROUPS.contains(&by.as_str())
311+ {
312+ return failed(FailureCode::Invalid, "group_by is product, project or day.");
313+ }
314+ let products = list(input, "products");
315+ if let Some(unknown) = products.iter().find(|product| !PRODUCTS.contains(&product.as_str())) {
316+ return failed(
317+ FailureCode::Invalid,
318+ &format!("{unknown} is not a product. Give some of {}.", PRODUCTS.join(", ")),
319+ );
320+ }
321+ let (month_start, today) = this_month(worker::Date::now().as_millis() as f64);
322+ let day = |key: &str, default: String| -> std::result::Result<String, String> {
323+ match input[key].as_str().map(str::trim).filter(|day| !day.is_empty()) {
324+ None => Ok(default),
325+ Some(day) if is_day(day) => Ok(day.to_owned()),
326+ Some(day) => Err(format!("{key} is a day, YYYY-MM-DD, not {day}.")),
327+ }
328+ };
329+ let (from, until) = match (day("from", month_start), day("until", today)) {
330+ (Ok(from), Ok(until)) => (from, until),
331+ (Err(message), _) | (_, Err(message)) => return failed(FailureCode::Invalid, &message),
332+ };
333+ let report: Outcome<Value> = g1t_kit::call(
334+ billing,
335+ "usage_report",
336+ &json!({
337+ "workspace": workspace,
338+ "viewer": viewer,
339+ "from": from,
340+ "until": until,
341+ "products": products,
342+ "projects": list(input, "projects"),
343+ }),
344+ )
345+ .await?;
346+ shaped(report, &|report| usage_json(report, group_by.as_deref()))
347+ }
348+ Op::GetBudget => shaped(g1t_kit::call(billing, "limit", &account).await?, &budget_json),
349+ Op::SetBudget => {
350+ let actor = match person(viewer, services.scope.is_some()) {
351+ Ok(user) => user,
352+ Err((code, message)) => return failed(code, message),
353+ };
354+ let current: Outcome<Value> = g1t_kit::call(billing, "limit", &account).await?;
355+ let current = match current {
356+ Outcome::Ok(limit) => limit,
357+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
358+ };
359+ let change = match budget_change(input, &current) {
360+ Ok(change) => change,
361+ Err(message) => return failed(FailureCode::Invalid, &message),
362+ };
363+ let set: Outcome<Value> = g1t_kit::call(
364+ billing,
365+ "set_budget",
366+ &json!({
367+ "actor": actor,
368+ "workspace": workspace,
369+ "keepLimit": change.keep_limit,
370+ "amountMicros": change.amount_micros,
371+ "alerts": change.alerts,
372+ "pauseAtLimit": change.pause_at_limit,
373+ "webhook": change.webhook,
374+ }),
375+ )
376+ .await?;
377+ shaped(set, &budget_json)
378+ }
379+ Op::GetAiCredit => shaped(g1t_kit::call(billing, "ai_credit", &account).await?, &snake),
380+ Op::BuyAiCredit => {
381+ let actor = match person(viewer, services.scope.is_some()) {
382+ Ok(user) => user,
383+ Err((code, message)) => return failed(code, message),
384+ };
385+ let cents = match &input["amount_cents"] {
386+ Value::Number(number) => number.as_u64(),
387+ Value::String(digits) => digits.trim().parse().ok(),
388+ _ => None,
389+ };
390+ let Some(cents) = cents.and_then(|cents| u32::try_from(cents).ok()).filter(|cents| *cents > 0) else {
391+ return failed(FailureCode::Invalid, "Give amount_cents: the credit in cents, in whole dollars, such as 5000 for $50.");
392+ };
393+ let return_url = format!("{}/{workspace}/-/billing", services.addresses.site.trim_end_matches('/'));
394+ let checkout: Outcome<Value> = g1t_kit::call(
395+ billing,
396+ "buy_ai_credit",
397+ &json!({ "actor": actor, "workspace": workspace, "amountCents": cents, "returnUrl": return_url }),
398+ )
399+ .await?;
400+ shaped(checkout, &|checkout| json!({ "url": checkout["url"] }))
401+ }
402+ Op::ListInvoices => {
403+ let details: Outcome<Value> = g1t_kit::call(billing, "billing_details", &account).await?;
404+ let details = match details {
405+ Outcome::Ok(details) => details,
406+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
407+ };
408+ let usage: Outcome<Vec<Value>> = g1t_kit::call(billing, "invoices", &account).await?;
409+ Ok(match usage {
410+ Outcome::Ok(usage) => Outcome::Ok(invoices_json(&details, &usage)),
411+ Outcome::Fail(failure) => Outcome::Fail(failure),
412+ })
413+ }
414+ Op::GetBillingDetails => shaped(g1t_kit::call(billing, "billing_details", &account).await?, &details_json),
415+ _ => failed(FailureCode::Invalid, "Not a billing operation."),
416+ }
417+}
418+
419+#[cfg(test)]
420+mod tests {
421+ use super::*;
422+
423+ #[test]
424+ fn a_usage_report_is_snake_case_all_the_way_down() {
425+ let report = json!({
426+ "from": "2026-10-01", "until": "2026-10-07",
427+ "totals": { "priceMicros": 12_500_000, "discountMicros": 0, "includedMicros": 2_000_000, "creditsMicros": 500_000,
428+ "chargedMicros": 10_000_000, "pendingMicros": 300_000, "costMicros": 9_000_000 },
429+ "days": [
430+ { "day": "2026-10-02", "product": "agent", "micros": 4_000_000 },
431+ { "day": "2026-10-02", "product": "sandboxes", "micros": 500_000 },
432+ { "day": "2026-10-01", "product": "agent", "micros": 8_000_000 },
433+ ],
434+ "products": [
435+ { "key": "agent", "label": "Agent", "micros": 12_000_000, "features": [{ "key": "runs", "label": "Runs", "micros": 12_000_000, "count": 3 }],
436+ "meters": [{ "key": "agent_models", "label": "Models", "product": "agent", "unit": "tokens", "quantity": 1.5e6,
437+ "micros": 12_000_000, "pendingMicros": 0, "daily": [8_000_000, 4_000_000], "allowance": null,
438+ "byProject": [{ "project": "acme/web", "micros": 9_000_000, "quantity": 1e6 },
439+ { "project": "", "micros": 3_000_000, "quantity": 5e5 }] }] },
440+ { "key": "sandboxes", "label": "Sandboxes", "micros": 500_000, "features": [],
441+ "meters": [{ "key": "sandbox", "label": "Sandbox time", "product": "sandboxes", "unit": "seconds", "quantity": 600.0,
442+ "micros": 500_000, "pendingMicros": 0, "daily": [0, 500_000],
443+ "allowance": { "used": 600.0, "of": 3600.0, "unit": "seconds" },
444+ "byProject": [{ "project": "acme/web", "micros": 500_000, "quantity": 600.0 }] }] },
445+ ],
446+ "projects": ["acme/web"], "included": null, "discountPercent": null,
447+ "aiCreditMicros": 40_000_000, "creditMicros": 0, "trialMicros": null, "plan": "pro", "free": false,
448+ });
449+ let usage = usage_json(&report, None);
450+ assert_eq!(usage["totals"]["charged_micros"], 10_000_000);
451+ assert_eq!(usage["products"][0]["meters"][0]["by_project"][0]["project"], "acme/web");
452+ assert_eq!(usage["products"][0]["meters"][0]["pending_micros"], 0);
453+ assert_eq!(usage["ai_credit_micros"], 40_000_000);
454+ assert!(usage.get("groups").is_none());
455+ let text = usage.to_string();
456+ for camel in ["Micros", "byProject", "discountPercent"] {
457+ assert!(!text.contains(camel), "{camel} in {text}");
458+ }
459+
460+ let by_project = usage_json(&report, Some("project"));
461+ assert_eq!(by_project["group_by"], "project");
462+ assert_eq!(
463+ by_project["groups"],
464+ json!([{ "key": "acme/web", "micros": 9_500_000 }, { "key": null, "micros": 3_000_000 }])
465+ );
466+ let by_day = usage_json(&report, Some("day"));
467+ assert_eq!(by_day["groups"], json!([{ "key": "2026-10-01", "micros": 8_000_000 }, { "key": "2026-10-02", "micros": 4_500_000 }]));
468+ let by_product = usage_json(&report, Some("product"));
469+ assert_eq!(by_product["groups"][1], json!({ "key": "sandboxes", "label": "Sandboxes", "micros": 500_000 }));
470+ }
471+
472+ #[test]
473+ fn ai_credit_is_snake_case() {
474+ let credit = json!({
475+ "balanceMicros": 42_000_000, "purchasedMicros": 40_000_000, "givenMicros": 2_000_000,
476+ "grants": [{ "id": "crd_1", "kind": "purchase", "amountMicros": 40_000_000, "usedMicros": 0, "leftMicros": 40_000_000, "expiresAt": null }],
477+ "freeViaDiscount": false, "postpaid": false, "blocked": false, "canBuy": true,
478+ "presetsCents": [2500, 5000], "minCents": 1000, "maxCents": 100_000,
479+ "cardFee": { "on": true, "percentMicros": 29_000.0, "fixedCents": 30 },
480+ "reload": { "enabled": false, "thresholdMicros": 0, "targetMicros": 0, "monthlyMaxMicros": 0, "reloadedMicros": 0, "failedAt": null, "error": null },
481+ "agentRateMicros": 3.6, "modelMarkupPercent": 10, "gatewayMarkupPercent": 5, "upgradeCreditMicros": 0, "expiresDays": 365,
482+ });
483+ let out = snake(&credit);
484+ assert_eq!(out["balance_micros"], 42_000_000);
485+ assert_eq!(out["grants"][0]["left_micros"], 40_000_000);
486+ assert_eq!(out["card_fee"]["fixed_cents"], 30);
487+ assert_eq!(out["reload"]["monthly_max_micros"], 0);
488+ assert_eq!(out["can_buy"], true);
489+ assert!(out.get("balanceMicros").is_none());
490+ assert_eq!(snake_key("line1"), "line1");
491+ assert_eq!(snake_key("last4"), "last4");
492+ assert_eq!(snake_key("taxIdType"), "tax_id_type");
493+ }
494+
495+ #[test]
496+ fn agents_and_workspace_tokens_never_change_billing() {
497+ let person_user = User { username: "ana".into(), ..User::default() };
498+ assert!(person(&Some(person_user.clone()), false).is_ok());
499+ // A person's token used by an agent's run is still an agent's.
500+ assert_eq!(person(&Some(person_user), true).unwrap_err().0, FailureCode::Forbidden);
501+ let agent = User { username: "g1t".into(), kind: PrincipalKind::Agent, ..User::default() };
502+ let (code, message) = person(&Some(agent), false).unwrap_err();
503+ assert_eq!(code, FailureCode::Forbidden);
504+ assert!(message.contains("agents never change billing"));
505+ let workspace = User { username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() };
506+ let (code, message) = person(&Some(workspace), false).unwrap_err();
507+ assert_eq!(code, FailureCode::Forbidden);
508+ assert!(message.contains("personal access token"));
509+ assert_eq!(person(&None, false).unwrap_err().0, FailureCode::Unauthenticated);
510+ }
511+
512+ #[test]
513+ fn a_budget_change_keeps_what_was_not_given() {
514+ let current = json!({
515+ "workspace": "acme", "spendLimitMicros": 300_000_000, "defaultSpendLimit": false, "spentMicros": 12_000_000,
516+ "availableMicros": 1_000_000_000, "alertLevels": [100, 75, 50], "pauseAtLimit": true,
517+ "budgetWebhook": "https://acme.dev/hooks/budget", "state": "ok", "message": null,
518+ });
519+ let kept = budget_change(&json!({}), &current).unwrap();
520+ assert_eq!(
521+ kept,
522+ BudgetChange { keep_limit: true, amount_micros: Some(300_000_000), alerts: vec![100, 75, 50], pause_at_limit: true, webhook: Some("https://acme.dev/hooks/budget".into()) }
523+ );
524+ let changed = budget_change(&json!({ "amount_micros": null, "alerts": [90, 50, 90], "pause_at_limit": false, "webhook": null }), &current).unwrap();
525+ assert_eq!(changed, BudgetChange { keep_limit: false, amount_micros: None, alerts: vec![50, 90], pause_at_limit: false, webhook: None });
526+ for bad in [json!({ "alerts": [60] }), json!({ "alerts": "50" }), json!({ "amount_micros": -1 }), json!({ "webhook": "http://x" }), json!({ "pause_at_limit": "yes" })] {
527+ assert!(budget_change(&bad, &current).is_err(), "{bad}");
528+ }
529+ // The automatic limit stays automatic when no amount is given.
530+ let automatic = json!({ "spendLimitMicros": 200_000_000, "defaultSpendLimit": true });
531+ assert_eq!(budget_change(&json!({}), &automatic).unwrap().amount_micros, None);
532+ let budget = budget_json(&current);
533+ assert_eq!(budget["amount_micros"], 300_000_000);
534+ assert_eq!(budget["max_amount_micros"], 1_000_000_000);
535+ assert_eq!(budget["alerts"], json!([100, 75, 50]));
536+ assert_eq!(budget["automatic"], false);
537+ }
538+
539+ #[test]
540+ fn invoices_put_every_invoice_beside_the_itemised_usage_ones() {
541+ let details = json!({
542+ "customer": true, "email": "billing@acme.dev",
543+ "invoices": [{ "id": "in_1", "number": "ACME-0001", "status": "paid", "totalCents": 2000, "currency": "usd",
544+ "createdAt": "2026-10-01T00:00:00Z", "description": null, "hostedUrl": null, "pdfUrl": null }],
545+ "upcoming": { "closesAt": "2026-11-01T00:00:00Z", "subscriptionsMicros": 20_000_000, "usageMicros": 5_000_000, "totalMicros": 25_000_000 },
546+ "unavailable": null,
547+ });
548+ let usage = [json!({ "invoiceId": "inv_1", "workspace": "acme", "reason": "month", "period": "2026-09", "amountMicros": 5_000_000,
549+ "status": "paid", "hostedUrl": null, "pdfUrl": null, "lines": [{ "description": "Agent", "amountMicros": 5_000_000 }],
550+ "createdAt": "2026-10-01T00:00:00Z" })];
551+ let out = invoices_json(&details, &usage);
552+ assert_eq!(out["invoices"][0]["total_cents"], 2000);
553+ assert_eq!(out["usage_invoices"][0]["id"], "inv_1");
554+ assert!(out["usage_invoices"][0].get("invoice_id").is_none());
555+ assert_eq!(out["usage_invoices"][0]["lines"][0]["amount_micros"], 5_000_000);
556+ assert_eq!(out["upcoming"]["total_micros"], 25_000_000);
557+ let shown = details_json(&details);
558+ assert_eq!(shown["email"], "billing@acme.dev");
559+ assert!(shown.get("invoices").is_none() && shown.get("upcoming").is_none());
560+ }
561+
562+ const OPS: [Op; 7] =
563+ [Op::GetUsage, Op::GetBudget, Op::SetBudget, Op::GetAiCredit, Op::BuyAiCredit, Op::ListInvoices, Op::GetBillingDetails];
564+
565+ /// Billing belongs to a workspace, needs someone signed in, and is one
566+ /// MCP tool whose writes no preset but full access reaches.
567+ #[test]
568+ fn billing_operations_name_a_workspace_and_agents_only_read() {
569+ use crate::tools::{Gate, Tool};
570+ use g1t_contracts::scopes::{Preset, TokenAccess, scope_for};
571+ for op in OPS {
572+ assert!(!op.needs_repo(), "{}", op.name());
573+ assert!(op.needs_user(), "{}", op.name());
574+ assert!(op.required().contains(&"workspace".to_owned()), "{}", op.name());
575+ assert!(scope_for(op.name()).is_some(), "{}", op.name());
576+ }
577+ let tool = Tool::by_name("billing").unwrap();
578+ let token = |preset: Preset| TokenAccess {
579+ token_id: "tok_1".into(),
580+ scopes: preset.scopes().map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
581+ legacy: false,
582+ };
583+ for preset in [Preset::ReadOnly, Preset::Agent] {
584+ let access = token(preset);
585+ let seen: Vec<&str> = tool.visible(&Gate::Token(&access)).iter().map(|action| action.name).collect();
586+ assert_eq!(seen, ["usage", "budget", "ai_credit", "invoices", "billing_details"], "{}", preset.as_str());
587+ }
588+ let full = TokenAccess::full();
589+ assert_eq!(tool.visible(&Gate::Token(&full)).len(), OPS.len());
590+ }
591+
592+ #[test]
593+ fn the_month_so_far_is_read_from_the_clock() {
594+ // 2026-10-07T12:00:00Z.
595+ assert_eq!(this_month(1_791_374_400_000.0), ("2026-10-01".to_owned(), "2026-10-07".to_owned()));
596+ assert_eq!(this_month(0.0), ("1970-01-01".to_owned(), "1970-01-01".to_owned()));
597+ // 2024-02-29.
598+ assert_eq!(this_month(1_709_208_000_000.0), ("2024-02-01".to_owned(), "2024-02-29".to_owned()));
599+ assert!(is_day("2026-10-07") && !is_day("2026-10-7") && !is_day("20261007xx"));
600+ assert_eq!(list(&json!({ "products": "agent, sandboxes,," }), "products"), vec!["agent", "sandboxes"]);
601+ assert_eq!(list(&json!({ "products": ["agent"] }), "products"), vec!["agent"]);
602+ }
603+}
+2−0
77 mod addresses;
88 mod alerts;
99 mod audit;
10+mod billing;
1011 mod blobs;
1112 mod mcp;
1213 mod notifications;
1920 mod responses;
2021 mod rest;
2122 mod runners;
23+mod security;
2224 mod tools;
2325
2426 use g1t_contracts::billing::FinishRunArgs;
+2−1
1111 const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
1212
1313 const INSTRUCTIONS: &str = "g1t is a git forge where people and agents work through issues and pull requests. Repositories are named \"owner/name\"; issues and pull requests in one share a sequence of numbers.
14−Tools are resources, each with an `action`: search, repository, issue, pull_request, agent, plan, memory, workflow, secret, webhook, access, workspace, account. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs.
14+Tools are resources, each with an `action`: search, repository, issue, pull_request, agent, plan, memory, workflow, secret, security, webhook, access, workspace, account, notifications. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs.
1515 Find a repository: account whoami lists your workspaces; repository list or search finds one.
1616 Work on an issue: issue get (read it and the pull requests already made for it), memory recall, then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Record your reasoning with pull_request record_session as you go, push, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again.
1717 Hand work to g1t's agent: agent delegate opens an issue and starts it in one step; agent assign starts it on an existing issue. Each costs the workspace money.
18+Security: security code_alerts, vulnerability_alerts and secret_alerts show what to fix; fix it in your pull request, which the Code scanning and Dependency review checks judge.
1819 When you learn something the next agent needs, memory remember it (scope project or workspace). Never a secret.";
1920
2021 fn result(id: &Value, value: Value) -> Value {
+2−2
119119 "token_endpoint_auth_methods_supported": ["none"],
120120 // A client may ask for some of these with `scope`; the person
121121 // approving can trim them. Asking for none gives the agent preset.
122− "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()),
122+ "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()).to_vec(),
123123 "service_documentation": "https://docs.g1t.sh/guides/authentication/",
124124 })
125125 }
245245 "authorization_servers": [services.addresses.api],
246246 "bearer_methods_supported": ["header"],
247247 "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/",
248− "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()),
248+ "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()).to_vec(),
249249 }))?
250250 }
251251 ("POST", "/oauth/register") => register(request).await?,
+153−0
88 use serde_json::{Map, Value, json};
99
1010 use crate::operations::Op;
11+use crate::security::SecurityOp;
1112 use crate::rest::{ROUTES, Route};
1213
1314 /// The sections of the API reference: a name, what it covers, and its
6162 ],
6263 ),
6364 (
65+ "Billing",
66+ "A workspace's usage, its budget, its AI credit and its invoices. Members read them; owners change the budget and buy credit, as people. g1t's agents never change billing.",
67+ &[
68+ Op::GetUsage,
69+ Op::GetBudget,
70+ Op::SetBudget,
71+ Op::GetAiCredit,
72+ Op::BuyAiCredit,
73+ Op::ListInvoices,
74+ Op::GetBillingDetails,
75+ ],
76+ ),
77+ (
6478 "Repositories",
6579 "A repository, how it handles pull requests, and its timeline: renaming, archiving, moving and deleting it.",
6680 &[
8195 Op::GetRepoSettings,
8296 Op::UpdateRepoSettings,
8397 Op::ListCheckNames,
98+ Op::GetCodeownersErrors,
8499 Op::ListEvents,
85100 ],
86101 ),
103118 ],
104119 ),
105120 (
121+ "Teams",
122+ "Groups of a workspace's members: given a role on repositories together, mentioned together as @workspace/team, and asked to review together. Any member may create a team; the workspace's owners and the team's maintainers manage it.",
123+ &[
124+ Op::ListTeams,
125+ Op::CreateTeam,
126+ Op::GetTeam,
127+ Op::UpdateTeam,
128+ Op::DeleteTeam,
129+ Op::ListTeamMembers,
130+ Op::SetTeamMember,
131+ Op::RemoveTeamMember,
132+ Op::ListChildTeams,
133+ Op::ListTeamRepos,
134+ Op::SetTeamRepo,
135+ Op::RemoveTeamRepo,
136+ Op::SetTeamReviewAssignment,
137+ Op::ListUserTeams,
138+ ],
139+ ),
140+ (
106141 "Security",
107142 "Secrets found in what is pushed and in a repository's history, and dependencies with known vulnerabilities: listing the alerts, and dismissing or reopening them.",
108143 &[Op::ListSecurityAlerts, Op::DismissSecurityAlert, Op::ReopenSecurityAlert],
109144 ),
110145 (
146+ "Secret scanning",
147+ "Secrets found in pushes and history, where each one is, pushing past push protection with a reason (and asking for approval when the workspace delegates bypasses), checking with a secret's issuer whether it still works, and custom patterns.",
148+ &[
149+ Op::Security(SecurityOp::ListSecretAlerts),
150+ Op::Security(SecurityOp::GetSecretAlert),
151+ Op::Security(SecurityOp::UpdateSecretAlert),
152+ Op::Security(SecurityOp::ListSecretLocations),
153+ Op::Security(SecurityOp::BypassPushProtection),
154+ Op::Security(SecurityOp::CheckSecretValidity),
155+ Op::Security(SecurityOp::ListBypassRequests),
156+ Op::Security(SecurityOp::ReviewBypassRequest),
157+ Op::Security(SecurityOp::ListCustomPatterns),
158+ Op::Security(SecurityOp::CreateCustomPattern),
159+ Op::Security(SecurityOp::UpdateCustomPattern),
160+ Op::Security(SecurityOp::DeleteCustomPattern),
161+ Op::Security(SecurityOp::DryRunCustomPattern),
162+ ],
163+ ),
164+ (
165+ "Code scanning",
166+ "Results of static analysis tools, uploaded as SARIF: alerts on the default branch, the analyses that made them, uploads, and putting g1t on an alert to fix it.",
167+ &[
168+ Op::Security(SecurityOp::ListCodeAlerts),
169+ Op::Security(SecurityOp::GetCodeAlert),
170+ Op::Security(SecurityOp::UpdateCodeAlert),
171+ Op::Security(SecurityOp::ListAnalyses),
172+ Op::Security(SecurityOp::UploadSarif),
173+ Op::Security(SecurityOp::GetSarifUpload),
174+ Op::Security(SecurityOp::FixAlert),
175+ ],
176+ ),
177+ (
178+ "Supply chain",
179+ "What a repository depends on: vulnerability alerts, the dependency graph, an SPDX SBOM of it, and comparing two commits' dependencies as dependency review does.",
180+ &[
181+ Op::Security(SecurityOp::ListVulnerabilityAlerts),
182+ Op::Security(SecurityOp::GetVulnerabilityAlert),
183+ Op::Security(SecurityOp::UpdateVulnerabilityAlert),
184+ Op::Security(SecurityOp::GetDependencyGraph),
185+ Op::Security(SecurityOp::GetSbom),
186+ Op::Security(SecurityOp::CompareDependencies),
187+ ],
188+ ),
189+ (
190+ "Security settings",
191+ "When pull request checks fail, dependency review's policy, delegated bypass and validity checks, and a workspace's security overview.",
192+ &[
193+ Op::Security(SecurityOp::GetSettings),
194+ Op::Security(SecurityOp::UpdateSettings),
195+ Op::Security(SecurityOp::GetWorkspaceSettings),
196+ Op::Security(SecurityOp::UpdateWorkspaceSettings),
197+ Op::Security(SecurityOp::GetOverview),
198+ ],
199+ ),
200+ (
111201 "Issues",
112202 "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.",
113203 &[
120210 Op::AssignIssue,
121211 Op::Delegate,
122212 Op::AddComment,
213+ Op::ListIssueLabels,
214+ Op::AddIssueLabels,
215+ Op::SetIssueLabels,
216+ Op::RemoveIssueLabels,
217+ ],
218+ ),
219+ (
220+ "Labels and milestones",
221+ "A repository's labels, which issues and pull requests carry by name, and its milestones, which gather them under a goal and a due date.",
222+ &[
123223 Op::ListLabels,
224+ Op::CreateLabel,
225+ Op::UpdateLabel,
226+ Op::DeleteLabel,
227+ Op::AddDefaultLabels,
228+ Op::ListMilestones,
229+ Op::CreateMilestone,
230+ Op::GetMilestone,
231+ Op::UpdateMilestone,
232+ Op::DeleteMilestone,
124233 ],
125234 ),
126235 (
135244 Op::ListPullRequests,
136245 Op::CreatePullRequest,
137246 Op::GetPullRequest,
247+ Op::UpdatePullRequest,
138248 Op::GetPullRequestChanges,
139249 Op::MarkPullRequestReady,
250+ Op::RequestReviewers,
251+ Op::RemoveRequestedReviewers,
140252 Op::ReviewPullRequest,
141253 Op::MergePullRequest,
142254 Op::ClosePullRequest,
299411 Op::GetPlan => "Get a plan",
300412 Op::ApplyPlan => "Apply a plan",
301413 Op::ListLabels => "List labels",
414+ Op::CreateLabel => "Create a label",
415+ Op::UpdateLabel => "Update a label",
416+ Op::DeleteLabel => "Delete a label",
417+ Op::AddDefaultLabels => "Add the default labels",
418+ Op::ListIssueLabels => "List an issue's labels",
419+ Op::AddIssueLabels => "Add labels to an issue",
420+ Op::SetIssueLabels => "Set an issue's labels",
421+ Op::RemoveIssueLabels => "Remove labels from an issue",
422+ Op::ListMilestones => "List milestones",
423+ Op::GetMilestone => "Get a milestone",
424+ Op::CreateMilestone => "Create a milestone",
425+ Op::UpdateMilestone => "Update a milestone",
426+ Op::DeleteMilestone => "Delete a milestone",
427+ Op::UpdatePullRequest => "Update a pull request",
302428 Op::AddComment => "Add a comment",
303429 Op::ReviewPullRequest => "Review a pull request",
304430 Op::ListPullRequests => "List pull requests",
379505 Op::DeleteRepoSubscription => "Stop watching a repository",
380506 Op::ListWatchedRepos => "List repositories you watch",
381507 Op::ListPinnedProjects => "List your pinned projects",
508+ Op::GetUsage => "Get a workspace's usage",
509+ Op::GetBudget => "Get a workspace's budget",
510+ Op::SetBudget => "Change a workspace's budget",
511+ Op::GetAiCredit => "Get a workspace's AI credit",
512+ Op::BuyAiCredit => "Buy AI credit",
513+ Op::ListInvoices => "List a workspace's invoices",
514+ Op::GetBillingDetails => "Get a workspace's billing details",
382515 Op::PinProject => "Pin a project",
383516 Op::UnpinProject => "Unpin a project",
384517 Op::ReorderPinnedProjects => "Reorder your pinned projects",
518+ Op::ListTeams => "List teams",
519+ Op::GetTeam => "Get a team",
520+ Op::CreateTeam => "Create a team",
521+ Op::UpdateTeam => "Update a team",
522+ Op::DeleteTeam => "Delete a team",
523+ Op::ListTeamMembers => "List a team's members",
524+ Op::SetTeamMember => "Add or change a team member",
525+ Op::RemoveTeamMember => "Remove a team member",
526+ Op::ListChildTeams => "List child teams",
527+ Op::ListTeamRepos => "List a team's repositories",
528+ Op::SetTeamRepo => "Give a team a role on a repository",
529+ Op::RemoveTeamRepo => "Remove a team from a repository",
530+ Op::SetTeamReviewAssignment => "Set a team's review assignment",
531+ Op::ListUserTeams => "List someone's teams",
532+ Op::RequestReviewers => "Request reviewers",
533+ Op::RemoveRequestedReviewers => "Remove requested reviewers",
534+ Op::GetCodeownersErrors => "List CODEOWNERS errors",
535+ Op::Security(op) => op.title(),
385536 }
386537 }
387538
476627 "PUT" => "set_issue_subscription".to_owned(),
477628 _ => "delete_issue_subscription".to_owned(),
478629 },
630+ // One label off an issue, by its name in the path.
631+ ("DELETE", ":label") if route.path.contains("/issues/:number/") => "remove_issue_label".to_owned(),
479632 ("DELETE", "saved") => "unsave_thread".to_owned(),
480633 ("DELETE", "snooze") => "unsnooze_thread".to_owned(),
481634 _ => op.name().to_owned(),
+1343−17
99 OutsideCollaboratorsArgs, RemoveCollaboratorArgs, RepoAccess, RepoAccessArgs, RepoInvitation, RepoRole,
1010 RespondRepoInvitationArgs, RevokeRepoInvitationArgs, SetBasePermissionArgs, SetCollaboratorRoleArgs,
1111 };
12+use g1t_contracts::codeowners::CodeOwnersErrorsArgs;
1213 use g1t_contracts::identity::AgentScope;
1314 use g1t_contracts::events::{Event, ListArgs as ListEventsArgs};
1415 use g1t_contracts::identity::{CreateWorkspaceArgs, UpdateWorkspaceArgs, Workspace};
1516 use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath};
17+use g1t_contracts::teams::{
18+ CreateTeamArgs, DeleteTeamArgs, ListTeamsArgs, RemoveTeamMemberArgs, RemoveTeamRepoArgs, ReviewAlgorithm,
19+ ReviewAssignment, SetTeamMemberArgs, SetTeamRepoArgs, Team, TeamArgs, TeamRole, TeamVisibility, UpdateTeamArgs,
20+ UserTeamsArgs,
21+};
1622 use g1t_contracts::security::{
1723 AlertChange, AlertState, DismissArgs, DismissReason, OverviewArgs as SecurityOverviewArgs, ReopenArgs,
1824 SecurityOverview,
1925 };
2026
2127 use crate::alerts::{AlertKind, SecurityAlert};
28+use crate::security::SecurityOp;
2229 use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel};
2330 use g1t_contracts::work::*;
2431 use g1t_contracts::{FailureCode, Outcome, Viewer};
131138 GetPlan,
132139 ApplyPlan,
133140 ListLabels,
141+ CreateLabel,
142+ UpdateLabel,
143+ DeleteLabel,
144+ AddDefaultLabels,
145+ ListIssueLabels,
146+ AddIssueLabels,
147+ SetIssueLabels,
148+ RemoveIssueLabels,
149+ ListMilestones,
150+ GetMilestone,
151+ CreateMilestone,
152+ UpdateMilestone,
153+ DeleteMilestone,
134154 AddComment,
135155 ReviewPullRequest,
136156 ListPullRequests,
137157 GetPullRequest,
138158 CreatePullRequest,
159+ UpdatePullRequest,
139160 RecordSession,
140161 ReadSession,
141162 MarkPullRequestReady,
214235 PinProject,
215236 UnpinProject,
216237 ReorderPinnedProjects,
238+ ListTeams,
239+ GetTeam,
240+ CreateTeam,
241+ UpdateTeam,
242+ DeleteTeam,
243+ ListTeamMembers,
244+ SetTeamMember,
245+ RemoveTeamMember,
246+ ListChildTeams,
247+ ListTeamRepos,
248+ SetTeamRepo,
249+ RemoveTeamRepo,
250+ SetTeamReviewAssignment,
251+ ListUserTeams,
252+ GetUsage,
253+ GetBudget,
254+ SetBudget,
255+ GetAiCredit,
256+ BuyAiCredit,
257+ ListInvoices,
258+ GetBillingDetails,
259+ RequestReviewers,
260+ RemoveRequestedReviewers,
261+ GetCodeownersErrors,
262+ /// The security suite's operations: see [`crate::security`].
263+ Security(SecurityOp),
217264 }
218265
219266 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
415462 g1t_contracts::webhooks::EVENT_TYPES.to_vec()
416463 }
417464
465+fn label_schema() -> Value {
466+ json!({ "type": "string", "description": "The label's name, e.g. \"good first issue\". URL-encode spaces in the path." })
467+}
468+
469+fn milestone_schema() -> Value {
470+ json!({ "type": "integer", "description": "The milestone's number, from list_milestones." })
471+}
472+
473+/// A milestone given as a number, or as null or 0 for none: `Some(0)` for
474+/// none, `None` when it was not given.
475+fn milestone_input(input: &Value) -> Option<u32> {
476+ match input.get("milestone") {
477+ None => None,
478+ Some(Value::Null) => Some(0),
479+ Some(_) => integer(input, "milestone"),
480+ }
481+}
482+
418483 fn repo_schema() -> Value {
419484 json!({
420485 "type": "string",
435500 })
436501 }
437502
503+fn team_schema() -> Value {
504+ json!({
505+ "type": "string",
506+ "description": "The team's slug, as in its mention @workspace/slug, e.g. \"backend\".",
507+ })
508+}
509+
510+/// A person's place in a team.
511+fn team_role_schema() -> Value {
512+ json!({
513+ "type": "string",
514+ "enum": [TeamRole::Member.as_str(), TeamRole::Maintainer.as_str()],
515+ "description": "member, or maintainer: also manages the team's people and settings. Defaults to member.",
516+ })
517+}
518+
519+fn team_visibility_schema() -> Value {
520+ json!({
521+ "type": "string",
522+ "enum": [TeamVisibility::Visible.as_str(), TeamVisibility::Secret.as_str()],
523+ "description": "visible: every member of the workspace sees it. secret: only its own people and the workspace's owners.",
524+ })
525+}
526+
527+fn include_child_teams_schema() -> Value {
528+ json!({
529+ "type": "boolean",
530+ "description": "Also the people of its child teams: listed with list_members, picked from with review assignment.",
531+ })
532+}
533+
534+/// The fields of a team's review assignment, each optional.
535+fn review_assignment_properties() -> Value {
536+ json!({
537+ "enabled": {
538+ "type": "boolean",
539+ "description": "On: g1t picks count people from the team to ask. Off: everyone in it is asked.",
540+ },
541+ "algorithm": {
542+ "type": "string",
543+ "enum": [ReviewAlgorithm::RoundRobin.as_str(), ReviewAlgorithm::LoadBalance.as_str()],
544+ "description": "round_robin: whoever this team asked least recently. load_balance: whoever has the fewest pull requests waiting on their review.",
545+ },
546+ "count": {
547+ "type": "integer",
548+ "minimum": 1,
549+ "maximum": g1t_contracts::teams::MAX_ASSIGNED,
550+ "description": "How many people to pick, 1 to 10. People from the team already asked count towards it.",
551+ },
552+ "skip_busy": {
553+ "type": "boolean",
554+ "description": "Leave out anyone with busy_at or more pull requests waiting on their review.",
555+ },
556+ "busy_at": {
557+ "type": "integer",
558+ "minimum": 1,
559+ "maximum": 100,
560+ "description": "With skip_busy: how many waiting reviews make someone busy, 1 to 100.",
561+ },
562+ "include_child_teams": include_child_teams_schema(),
563+ "excluded": {
564+ "type": "array",
565+ "items": { "type": "string" },
566+ "description": "Usernames never picked. Replaces the whole list.",
567+ },
568+ "notify_team": {
569+ "type": "boolean",
570+ "description": "Also tell the rest of the team when people are picked.",
571+ },
572+ })
573+}
574+
575+/// The inputs naming a team, with `more` added.
576+fn team_target(more: Value) -> Value {
577+ let mut properties = json!({ "workspace": workspace_schema(), "team": team_schema() });
578+ if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) {
579+ all.extend(more);
580+ }
581+ properties
582+}
583+
584+/// The people and teams to ask, or stop asking, to review a pull request.
585+fn requested_reviewers_properties() -> Value {
586+ numbered(json!({
587+ "reviewers": {
588+ "type": "array",
589+ "items": { "type": "string" },
590+ "description": "Usernames. g1t asks a g1t agent.",
591+ },
592+ "team_reviewers": {
593+ "type": "array",
594+ "items": { "type": "string" },
595+ "description": "Teams, as \"workspace/team\", or the team's slug in the repository's workspace.",
596+ },
597+ }))
598+}
599+
438600 fn thread_id_schema() -> Value {
439601 json!({ "type": "string", "description": "The thread's id, from list_notifications." })
440602 }
461623 }
462624
463625 impl Op {
464− pub const ALL: [Op; 135] = [
626+ pub const ALL: [Op; 204] = [
465627 Op::Whoami,
466628 Op::CreateWorkspace,
467629 Op::DeleteWorkspace,
514676 Op::GetPlan,
515677 Op::ApplyPlan,
516678 Op::ListLabels,
679+ Op::CreateLabel,
680+ Op::UpdateLabel,
681+ Op::DeleteLabel,
682+ Op::AddDefaultLabels,
683+ Op::ListIssueLabels,
684+ Op::AddIssueLabels,
685+ Op::SetIssueLabels,
686+ Op::RemoveIssueLabels,
687+ Op::ListMilestones,
688+ Op::GetMilestone,
689+ Op::CreateMilestone,
690+ Op::UpdateMilestone,
691+ Op::DeleteMilestone,
517692 Op::AddComment,
518693 Op::ReviewPullRequest,
519694 Op::ListPullRequests,
520695 Op::GetPullRequest,
521696 Op::CreatePullRequest,
697+ Op::UpdatePullRequest,
522698 Op::RecordSession,
523699 Op::ReadSession,
524700 Op::MarkPullRequestReady,
597773 Op::PinProject,
598774 Op::UnpinProject,
599775 Op::ReorderPinnedProjects,
776+ Op::ListTeams,
777+ Op::GetTeam,
778+ Op::CreateTeam,
779+ Op::UpdateTeam,
780+ Op::DeleteTeam,
781+ Op::ListTeamMembers,
782+ Op::SetTeamMember,
783+ Op::RemoveTeamMember,
784+ Op::ListChildTeams,
785+ Op::ListTeamRepos,
786+ Op::SetTeamRepo,
787+ Op::RemoveTeamRepo,
788+ Op::SetTeamReviewAssignment,
789+ Op::ListUserTeams,
790+ Op::GetUsage,
791+ Op::GetBudget,
792+ Op::SetBudget,
793+ Op::GetAiCredit,
794+ Op::BuyAiCredit,
795+ Op::ListInvoices,
796+ Op::GetBillingDetails,
797+ Op::RequestReviewers,
798+ Op::RemoveRequestedReviewers,
799+ Op::GetCodeownersErrors,
800+ Op::Security(SecurityOp::ListSecretAlerts),
801+ Op::Security(SecurityOp::GetSecretAlert),
802+ Op::Security(SecurityOp::UpdateSecretAlert),
803+ Op::Security(SecurityOp::ListSecretLocations),
804+ Op::Security(SecurityOp::BypassPushProtection),
805+ Op::Security(SecurityOp::CheckSecretValidity),
806+ Op::Security(SecurityOp::ListBypassRequests),
807+ Op::Security(SecurityOp::ReviewBypassRequest),
808+ Op::Security(SecurityOp::ListCustomPatterns),
809+ Op::Security(SecurityOp::CreateCustomPattern),
810+ Op::Security(SecurityOp::UpdateCustomPattern),
811+ Op::Security(SecurityOp::DeleteCustomPattern),
812+ Op::Security(SecurityOp::DryRunCustomPattern),
813+ Op::Security(SecurityOp::ListCodeAlerts),
814+ Op::Security(SecurityOp::GetCodeAlert),
815+ Op::Security(SecurityOp::UpdateCodeAlert),
816+ Op::Security(SecurityOp::ListAnalyses),
817+ Op::Security(SecurityOp::UploadSarif),
818+ Op::Security(SecurityOp::GetSarifUpload),
819+ Op::Security(SecurityOp::ListVulnerabilityAlerts),
820+ Op::Security(SecurityOp::GetVulnerabilityAlert),
821+ Op::Security(SecurityOp::UpdateVulnerabilityAlert),
822+ Op::Security(SecurityOp::FixAlert),
823+ Op::Security(SecurityOp::GetDependencyGraph),
824+ Op::Security(SecurityOp::GetSbom),
825+ Op::Security(SecurityOp::CompareDependencies),
826+ Op::Security(SecurityOp::GetSettings),
827+ Op::Security(SecurityOp::UpdateSettings),
828+ Op::Security(SecurityOp::GetWorkspaceSettings),
829+ Op::Security(SecurityOp::UpdateWorkspaceSettings),
830+ Op::Security(SecurityOp::GetOverview),
600831 ];
601832
602833 pub fn by_name(name: &str) -> Option<Op> {
658889 Op::GetPlan => "get_plan",
659890 Op::ApplyPlan => "apply_plan",
660891 Op::ListLabels => "list_labels",
892+ Op::CreateLabel => "create_label",
893+ Op::UpdateLabel => "update_label",
894+ Op::DeleteLabel => "delete_label",
895+ Op::AddDefaultLabels => "add_default_labels",
896+ Op::ListIssueLabels => "list_issue_labels",
897+ Op::AddIssueLabels => "add_issue_labels",
898+ Op::SetIssueLabels => "set_issue_labels",
899+ Op::RemoveIssueLabels => "remove_issue_labels",
900+ Op::ListMilestones => "list_milestones",
901+ Op::GetMilestone => "get_milestone",
902+ Op::CreateMilestone => "create_milestone",
903+ Op::UpdateMilestone => "update_milestone",
904+ Op::DeleteMilestone => "delete_milestone",
661905 Op::AddComment => "add_comment",
662906 Op::ReviewPullRequest => "review_pull_request",
663907 Op::ListPullRequests => "list_pull_requests",
664908 Op::GetPullRequest => "get_pull_request",
665909 Op::CreatePullRequest => "create_pull_request",
910+ Op::UpdatePullRequest => "update_pull_request",
666911 Op::RecordSession => "record_session",
667912 Op::ReadSession => "read_session",
668913 Op::MarkPullRequestReady => "mark_pull_request_ready",
741986 Op::PinProject => "pin_project",
742987 Op::UnpinProject => "unpin_project",
743988 Op::ReorderPinnedProjects => "reorder_pinned_projects",
989+ Op::ListTeams => "list_teams",
990+ Op::GetTeam => "get_team",
991+ Op::CreateTeam => "create_team",
992+ Op::UpdateTeam => "update_team",
993+ Op::DeleteTeam => "delete_team",
994+ Op::ListTeamMembers => "list_team_members",
995+ Op::SetTeamMember => "set_team_member",
996+ Op::RemoveTeamMember => "remove_team_member",
997+ Op::ListChildTeams => "list_child_teams",
998+ Op::ListTeamRepos => "list_team_repos",
999+ Op::SetTeamRepo => "set_team_repo",
1000+ Op::RemoveTeamRepo => "remove_team_repo",
1001+ Op::SetTeamReviewAssignment => "set_team_review_assignment",
1002+ Op::ListUserTeams => "list_user_teams",
1003+ Op::GetUsage => "get_usage",
1004+ Op::GetBudget => "get_budget",
1005+ Op::SetBudget => "set_budget",
1006+ Op::GetAiCredit => "get_ai_credit",
1007+ Op::BuyAiCredit => "buy_ai_credit",
1008+ Op::ListInvoices => "list_invoices",
1009+ Op::GetBillingDetails => "get_billing_details",
1010+ Op::RequestReviewers => "request_reviewers",
1011+ Op::RemoveRequestedReviewers => "remove_requested_reviewers",
1012+ Op::GetCodeownersErrors => "get_codeowners_errors",
1013+ Op::Security(op) => op.name(),
7441014 }
7451015 }
7461016
8221092 "Move a repository to another workspace, keeping its name. You must own both workspaces, and the destination must not already have a repository of that name; a free destination takes a private repository only if its private storage has room. Everything moves with it: git data, issues, pull requests, comments, labels, workflow runs, deployments, its project, and its own secrets, variables and webhooks. Its old address keeps working: web pages, git remotes and API calls redirect to the new one until a repository is made at the old address. Usage from now on is charged to the new workspace."
8231093 }
8241094 Op::GetRepoSettings => {
825− "How a repository handles pull requests, as its default branch's protection: the checks that must pass (required_checks), the approvals a merge needs, whether required checks can be bypassed, whether a pull request must be up to date, and how g1t's agents are reviewed, revised and merged. The same rules hold for a person's pull request and an agent's."
1095+ "How a repository handles pull requests, as its default branch's protection: the checks that must pass (required_checks), the approvals a merge needs, whether its code owners must approve (`require_code_owner_review`), whether required checks can be bypassed, whether a pull request must be up to date, and how g1t's agents are reviewed, revised and merged. The same rules hold for a person's pull request and an agent's."
8261096 }
8271097 Op::UpdateRepoSettings => {
828− "Change how a repository handles pull requests. Only the fields given are changed; required_checks replaces the whole list. A required check is named as list_check_names gives it: a workflow's name, such as CI, or another status's context, such as g1t / deploy. Needs the Maintain role or higher."
1098+ "Change how a repository handles pull requests. Only the fields given are changed; required_checks replaces the whole list. A required check is named as list_check_names gives it: a workflow's name, such as CI, or another status's context, such as g1t / deploy. With `require_code_owner_review`, a pull request merges only once the code owners of every file it changes, as the CODEOWNERS file of the branch it merges into names them, have approved it. Needs the Maintain role or higher."
8291099 }
8301100 Op::ListCheckNames => {
8311101 "The check names reported on a repository's commits in the last 30 days, most recent first, with the events each was reported for: the names update_repo_settings takes in required_checks. A workflow's runs report a check named after the workflow; a check required on the default branch must be reported on a pull request's head (pull_request events) and, with the merge queue on, on its queued state (merge_group events)."
8611131 "Create a repository in one of your workspaces, empty or as a copy of a public git repository elsewhere."
8621132 }
8631133 Op::ListIssues => {
864− "Issues on a repository, newest first. An issue is something that should change: a bug, a feature, a question. Pull requests are made against it."
1134+ "Issues on a repository, newest first. An issue is something that should change: a bug, a feature, a question. Pull requests are made against it. Filter by state, by a label's name, or by a milestone's number."
8651135 }
8661136 Op::GetIssue => {
8671137 "An issue: its description (which may say what done means, under \"Definition of done\"), labels, its comments, and every pull request made against it with its status. If the issue is closed, resolved_by is the number of the pull request that was merged for it. Read this before opening a pull request, to see what others have already tried."
8681138 }
8691139 Op::CreateIssue => {
870− "Open an issue on a repository. Say what done means in the body if it helps, for instance under a \"Definition of done\" heading; what must pass before a pull request for it merges is the default branch's required checks, the same for every pull request."
1140+ "Open an issue on a repository. Say what done means in the body if it helps, for instance under a \"Definition of done\" heading; what must pass before a pull request for it merges is the default branch's required checks, the same for every pull request. labels are the repository's labels by name; a name it does not have yet is created when you have the Triage role or higher, and refused otherwise. milestone, a milestone's number, needs the Triage role."
8711141 }
8721142 Op::UpdateIssue => {
873− "Change an issue's title, body, labels or the people it is assigned to. Only the fields given are changed; labels and assignees each replace the whole set. Its author may change their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher."
1143+ "Change an issue's title, body, labels, milestone or the people it is assigned to. Only the fields given are changed; labels and assignees each replace the whole set, and milestone null or 0 takes it out of its milestone. Its author may change their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher, and so does the milestone. Each label added or removed is an issue.labeled or issue.unlabeled event."
8741144 }
8751145 Op::CloseIssue => {
8761146 "Close an issue without a pull request. Merging a pull request made for an issue closes it for you. Its author may close their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher."
8911161 Op::Delegate => {
8921162 "Put an agent on something in one step: open an issue and assign it to g1t at once. Say what you want done in plain words, with what done means if you know it. What must pass before its pull request merges is the default branch's required checks. Needs the Write role or higher, and nothing is opened without it. The issue is opened whatever happens next: agent.status is started (pull is the draft pull request the agent opened; follow it with get_pull_request), queued (every agent slot of the workspace is busy; it starts by itself when one frees up) or not_started, with agent.code saying why (not_paid, trial_used, limit, paused, issue_cap, billing_unavailable or no_model), agent.message saying what to do, and agent.fix_url where. There is no model or agent count to choose."
8931163 }
894− Op::ListLabels => "The labels available on a repository's issues.",
1164+ Op::ListLabels => {
1165+ "A repository's labels, by name: each one's color (six hex digits), description, and how many issues and pull requests carry it. A new repository starts with bug, documentation, duplicate, enhancement, good first issue, help wanted, invalid, question, wontfix, dependencies and security."
1166+ }
1167+ Op::CreateLabel => {
1168+ "Create a label, named by label. Names are lowercase and unique in a repository, at most 50 characters; color is six hex digits (one is chosen from the name when left out), description at most 100 characters. Needs the Triage role or higher."
1169+ }
1170+ Op::UpdateLabel => {
1171+ "Change a label's name, color or description; only the fields given change. Renaming it renames it on every issue and pull request that carries it. Needs the Triage role or higher."
1172+ }
1173+ Op::DeleteLabel => {
1174+ "Delete a label. It is taken off every issue and pull request that carries it, without events for each. Needs the Triage role or higher."
1175+ }
1176+ Op::AddDefaultLabels => {
1177+ "Add the default labels a repository does not have yet: bug, documentation, duplicate, enhancement, good first issue, help wanted, invalid, question, wontfix, dependencies and security. Labels it has already are left as they are. Returns every label it has now. Needs the Triage role or higher."
1178+ }
1179+ Op::ListIssueLabels => {
1180+ "The labels an issue or a pull request carries, with their colors and descriptions. Issues and pull requests share numbers."
1181+ }
1182+ Op::AddIssueLabels => {
1183+ "Add labels to an issue or a pull request, keeping the ones it has. A name the repository does not have yet is created when you have the Triage role or higher; without it, you may use the repository's labels on what you opened. Each label added is an issue.labeled or pull.labeled event. Returns its labels now, at most 20."
1184+ }
1185+ Op::SetIssueLabels => {
1186+ "Replace the labels of an issue or a pull request with these; an empty list takes them all off. The same rules as add_issue_labels. Returns its labels now."
1187+ }
1188+ Op::RemoveIssueLabels => {
1189+ "Take labels off an issue or a pull request: label for one, labels for several, or neither for all of them. The labels stay on the repository. Returns its labels now."
1190+ }
1191+ Op::ListMilestones => {
1192+ "A repository's milestones: open ones soonest due first (those without a due date after), then closed ones, most recently closed first. Each has its number, title, description, due_on (YYYY-MM-DD), state, and open_items and closed_items: its issues and pull requests, a merged pull request counting as closed."
1193+ }
1194+ Op::GetMilestone => "A milestone, with every issue and pull request in it, newest first.",
1195+ Op::CreateMilestone => {
1196+ "Create a milestone: a title, unique in the repository, at most 100 characters; a description in Markdown; and a due_on day (YYYY-MM-DD). Milestones are numbered from 1 in each repository, apart from issues. Needs the Triage role or higher."
1197+ }
1198+ Op::UpdateMilestone => {
1199+ "Change a milestone's title, description, due date or state (open or closed); only the fields given change, and due_on \"\" clears its due date. Needs the Triage role or higher."
1200+ }
1201+ Op::DeleteMilestone => {
1202+ "Delete a milestone. The issues and pull requests in it are in no milestone afterwards. Needs the Triage role or higher."
1203+ }
8951204 Op::AddComment => {
8961205 "Comment on an issue or a pull request. On a pull request, give path and line to comment on one line of the change."
8971206 }
8991208 "Give a verdict on a pull request: approve it, or request changes and say what. Read get_pull_request_changes first. You cannot review a pull request you opened, or one g1t made for you (you are its requested_by)."
9001209 }
9011210 Op::ListPullRequests => {
902− "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed."
1211+ "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed. Filter by a label's name, a milestone's number, or base, the branch they merge into."
9031212 }
9041213 Op::GetPullRequest => {
905− "A pull request's status, head commit, comments and reviews, the issue it is for, its checks (statuses: what each workflow run reported on its head, with a link to the run; get_workflow_run and get_job_logs say why one failed), required_checks (each check the default branch requires, as success, failure, pending or expected when nothing has reported it yet), whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files."
1214+ "A pull request's status, base (the branch it merges into), head commit, labels, milestone, comments and reviews, the issue it is for, its checks (statuses: what each workflow run reported on its head, with a link to the run; get_workflow_run and get_job_logs say why one failed), required_checks (each check the default branch requires, as success, failure, pending or expected when nothing has reported it yet; empty for a pull request into another branch, which the default branch's protection does not cover), whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files. `pull.reviewers` lists the people asked to review it and `pull.team_reviewers` the teams, as `workspace/team`. `code_owners` is there when the branch it merges into has a CODEOWNERS file: its `path`, whether code owners' approval is `required`, `reviews` (one per section and rule that owns a changed file, with its `section`, `line`, `pattern`, `owners`, `files`, whether it is `optional`, the approvals `required`, who it was `approved_by` and `changes_requested_by`, and whether it is `satisfied`), what is still `missing`, and how many `errors` the file has (get_codeowners_errors lists them)."
9061215 }
9071216 Op::CreatePullRequest => {
908− "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one. If the change is already on a branch pushed to the repository, give that branch instead: no fork is made and the pull request is ready for review at once."
1217+ "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one. If the change is already on a branch pushed to the repository, give that branch instead: no fork is made and the pull request is ready for review at once. It merges into the default branch unless base names another existing branch; leave base out unless you were asked for another."
9091218 }
1219+ Op::UpdatePullRequest => {
1220+ "Change an open pull request: base, the branch it merges into (an existing branch; needs the Write role or higher); its labels (replacing the set, as set_issue_labels does); its milestone (a number, or null or 0 for none; needs the Triage role); and assignees and reviewers (each replacing the set). Only the fields given change. Its author, or whoever asked g1t for it, may change it; anyone else needs the Triage role or higher. A new base is a pull.base_changed event: it leaves the merge queue, and whether it is behind, merges cleanly and has the checks it needs is worked out against the new base."
1221+ }
9101222 Op::RecordSession => {
9111223 "Append entries to a pull request's session: the prompt you were given, your reasoning, the tools you ran. This is how people later see why a change was made, so record as you work, not only at the end."
9121224 }
9191231 "What a pull request changes: the files it touches and their line-by-line diff against the commit it started from. Use it to review a pull request or to compare several made for the same issue."
9201232 }
9211233 Op::MergePullRequest => {
922− "Land a pull request on the repository's main branch. Merging needs the Write role or higher, and only once it is marked ready and every check the default branch requires has passed on its head (see required_checks on get_pull_request); with ignore_checks, someone who may merge can bypass them where the repository allows it. Merging resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded. Where the repository has a merge queue, it joins the queue instead of landing at once. If main has moved since the pull request was opened, it is brought up to date first and lands when that is done; a repository that requires pull requests to be up to date refuses instead, so pull main into its fork or branch, push, and merge again. Check status in the result to see whether it has landed."
1234+ "Land a pull request on its base, the branch it merges into (the default branch unless it names another). Merging needs the Write role or higher, and only once it is marked ready and, into the default branch, every check the default branch requires has passed on its head (see required_checks on get_pull_request); with ignore_checks, someone who may merge can bypass them where the repository allows it. Merging into the default branch resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded; merging into another branch leaves the issue open. Where the repository has a merge queue, a pull request into the default branch joins the queue instead of landing at once. If its base has moved since the pull request was opened, it is brought up to date first and lands when that is done; a repository that requires pull requests into its default branch to be up to date refuses instead, so pull the base into its fork or branch, push, and merge again. Check status in the result to see whether it has landed."
9231235 }
9241236 Op::ListEvents => {
9251237 "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first."
11171429 Op::ReorderPinnedProjects => {
11181430 "Put your pins in a workspace in a new order: `projects` names every pinned project's slug, once, in the order you want them. Returns your pins, in order."
11191431 }
1432+ Op::ListTeams => {
1433+ "A workspace's teams that you can see, yours first, then by name. A team is a group of the workspace's members, given roles on repositories together, mentioned as @workspace/team and asked to review together. A secret team is seen only by its own people and the workspace's owners. Each team has its `slug`, `name`, `description`, `visibility` (`visible` or `secret`), `parent`, whether its people are notified when it is mentioned (`notify`), its `review_assignment`, how many people, repositories and child teams it has (`members_count`, `repos_count`, `child_teams_count`), your own `viewer_role` in it, and whether you may change it (`can_manage`). `query` narrows them by name or slug. Members of the workspace only."
1434+ }
1435+ Op::GetTeam => {
1436+ "One team, by its slug, as list_teams describes it. A secret team is found only by its own people and the workspace's owners; anyone else is told it does not exist. Members of the workspace only."
1437+ }
1438+ Op::CreateTeam => {
1439+ "Create a team in a workspace. Any member may create one, and becomes its first maintainer; `members` adds more people by username, each a member of the workspace. `slug` is made from the name unless you give one: lowercase letters, digits and single hyphens. `visibility` is `visible` (the default: every member sees it) or `secret` (only its people and the owners). A team under a `parent` inherits the parent's roles on repositories, and a mention or review request for the parent reaches it too; giving it a parent needs an owner, or a maintainer of the parent. Secret teams cannot be nested. People only, signed in or with a personal access token. Returns the team."
1440+ }
1441+ Op::UpdateTeam => {
1442+ "Change a team's `name`, `slug`, `description`, `visibility`, `parent` (an empty string takes it out from under its parent), `notify` or `review_assignment`. Only the fields given change; give at least one. A new slug changes how it is mentioned, @workspace/slug. Owners of the workspace and the team's maintainers. People only. Returns the team as it is now."
1443+ }
1444+ Op::DeleteTeam => {
1445+ "Delete a team. Its child teams move up to its parent, and the roles it gave on repositories go with it: its people keep only what they have otherwise. Owners of the workspace and the team's maintainers. People only. Returns true."
1446+ }
1447+ Op::ListTeamMembers => {
1448+ "The people in a team, each with their `username`, `name`, `avatar` and `role` in it (`member` or `maintainer`). With `include_child_teams`, the people of its child teams are listed too, each with `via`, the child team they are in. Anyone who can see the team."
1449+ }
1450+ Op::SetTeamMember => {
1451+ "Add a member of the workspace to a team, or change their role in it: `member` (the default) or `maintainer`, who manages the team's people and settings. Someone who is not a member of the workspace must join it first. Owners of the workspace and the team's maintainers. People only. Returns the person as list_team_members lists them."
1452+ }
1453+ Op::RemoveTeamMember => {
1454+ "Take someone out of a team. They lose the roles the team gave them on repositories, unless they have them otherwise. Owners of the workspace and the team's maintainers; anyone may leave a team themselves. People only. Returns true."
1455+ }
1456+ Op::ListChildTeams => {
1457+ "The teams nested directly under a team, as list_teams describes them. Anyone who can see the team."
1458+ }
1459+ Op::ListTeamRepos => {
1460+ "The repositories a team has a role on: each one's `repo` (`workspace/name`), the team's `role` there (read, triage, write, maintain or admin), and `inherited_from`, the parent team it comes from when the team inherits it, or null for its own. Everyone in the team gets the role; where someone has a higher one otherwise, the higher one counts. Anyone who can see the team."
1461+ }
1462+ Op::SetTeamRepo => {
1463+ "Give a team a role on a repository in its workspace, or change it: read, triage, write, maintain or admin. Everyone in the team and in its child teams gets the role. Needs the Admin role on the repository. People only. Returns the repository as list_team_repos lists it."
1464+ }
1465+ Op::RemoveTeamRepo => {
1466+ "Take a team's role on a repository away. Its people keep only the roles they have otherwise. Needs the Admin role on the repository, or to be an owner or one of the team's maintainers. People only. Returns true."
1467+ }
1468+ Op::SetTeamReviewAssignment => {
1469+ "Choose what happens when a team is asked to review a pull request. Off, everyone in it is asked. On (`enabled`), g1t picks `count` people from it (1 to 10, never the pull request's author) and asks them, and the team stays shown as asked beside them: `round_robin` picks whoever this team asked least recently, `load_balance` whoever has the fewest pull requests waiting on their review. `skip_busy` leaves out anyone with `busy_at` or more waiting; `include_child_teams` also picks from its child teams' people; `excluded` lists usernames never picked; `notify_team` also tells the rest of the team. Fields left out keep their current value. Owners of the workspace and the team's maintainers. People only. Returns the team."
1470+ }
1471+ Op::GetUsage => {
1472+ "A workspace's usage over a range of days, at price, and what paid for it. `from` and `until` are UTC days, `YYYY-MM-DD`, with `until` included and at most 400 days in all; left out, the current month so far. `products` narrows it to product families (agent, sandboxes, gateway, deployments, git_storage, packages, security, search) and `projects` to repositories (\"owner/name\"). Returns `totals`: `price_micros` less `discount_micros`, `included_micros` and `credits_micros` is `charged_micros`, what is left for the workspace to pay; `pending_micros` is metered this month and charged when it closes; `cost_micros` is what it cost g1t. Then `days` (each day and product with usage), `products` (every family, with its meters: quantity, unit, amount, a `daily` amount for each day of the range, any `allowance` and the split `by_project`), `projects` (every repository with usage in the range), and the AI credit and other credit left now. With `group_by` (`product`, `project` or `day`), `groups` adds up the range that way. Amounts are whole millionths of a dollar. Members of the workspace only."
1473+ }
1474+ Op::GetBudget => {
1475+ "A workspace's budget: its monthly spend limit (`amount_micros`; `automatic` is true while the owners have not set one, and it is then $200 or twice last month's spend), what was charged this month (`spent_micros`), the most the owners may set it to themselves (`max_amount_micros`), its `alerts` (percent of the limit, each emailed to the owners once a month), whether usage pauses at the limit (`pause_at_limit`), the `webhook` told of each alert, and `state`: `ok`, `warning` or `stopped`, with a `message` when work is stopped or close to it. Members of the workspace only."
1476+ }
1477+ Op::SetBudget => {
1478+ "Change a workspace's budget. Give only what you change; the rest stays as it is. `amount_micros` is the monthly spend limit, up to `max_amount_micros`, or null for the automatic one. `alerts` is some of 50, 75, 90 and 100, in percent of the limit. `pause_at_limit` false makes the limit alert only, without pausing usage; g1t's own ceiling still applies. `webhook` is an https:// address sent a JSON POST for each alert, or null for none. Owners only, as a person: signed in or with a personal access token. A workspace's own token and g1t's agents can read the budget but never change it. Returns the budget."
1479+ }
1480+ Op::GetAiCredit => {
1481+ "A workspace's AI credit, which pays for agent and AI gateway usage: what is left (`balance_micros`), how much of it was bought and given, its `grants` newest first, whether new runs on g1t's models are refused for want of it (`blocked`), whether it can be bought (`can_buy`) and for how much (`min_cents`, `max_cents`, `presets_cents`, and the `card_fee` added on top), auto-reload, the agent rate and the markups on models. `free_via_discount` or `postpaid` mean no credit is needed. Members of the workspace only."
1482+ }
1483+ Op::BuyAiCredit => {
1484+ "Start buying AI credit. Returns `url`, a payment page to open in a browser and pay by card; it comes back to the workspace's billing page. `amount_cents` is the credit, in whole dollars from $10 (1000) to $1,000 (100000); any card fee is added on top. The credit is added once the payment goes through. Owners only, as a person: signed in or with a personal access token. A workspace's own token and g1t's agents never buy credit."
1485+ }
1486+ Op::ListInvoices => {
1487+ "A workspace's invoices, newest first. `invoices` is every invoice billed to it (the plan, activations, AI credit and usage), each with its `status`, `total_cents`, `currency` and links to view it and its PDF. `usage_invoices` are g1t's itemised invoices for usage, one when each month closes and one each time the card is charged near the limit, with their `lines` in millionths of a dollar. `upcoming` is what the next invoice comes to so far. `unavailable` says why `invoices` could not be read just now, when it could not. Members of the workspace only."
1488+ }
1489+ Op::GetBillingDetails => {
1490+ "Who a workspace's invoices are made out to: the billing `email`, `name`, `address`, tax ID (`tax_id_type`, `tax_id`), `po_number` and the invoices' `language`, with the default `payment_method` as far as it is safe to show (its kind, brand, last four digits and expiry). `customer` is false until the workspace has been set up to pay. Members of the workspace only."
1491+ }
1492+ Op::ListUserTeams => {
1493+ "The teams someone is in within a workspace, as list_teams describes them, leaving out secret teams you cannot see. Members of the workspace only."
1494+ }
1495+ Op::RequestReviewers => {
1496+ "Ask more people or teams to review a pull request. `reviewers` are usernames, and may include `g1t` to ask a g1t agent; `team_reviewers` are teams, as `workspace/team` or the team's slug in the repository's workspace. They are added to whoever is asked already. Asking a team asks everyone in it, or with its review assignment on, the people it picks. Nobody is asked to review their own pull request, and a team must be one you can see. Whoever opened the pull request, or anyone with the Triage role or higher, while it is open. Returns the pull request, with `reviewers` and `team_reviewers` as they are now."
1497+ }
1498+ Op::RemoveRequestedReviewers => {
1499+ "Stop asking people or teams to review a pull request: `reviewers` by username and `team_reviewers` as `workspace/team` or the team's slug. Reviews they already gave stay. The same people may do this as may ask. Returns the pull request, with `reviewers` and `team_reviewers` as they are now."
1500+ }
1501+ Op::GetCodeownersErrors => {
1502+ "Check a repository's CODEOWNERS file as a linter would. g1t reads it from one branch (`ref`, the default branch unless you say): the first of `.g1t/CODEOWNERS`, `.github/CODEOWNERS`, `CODEOWNERS`, `docs/CODEOWNERS` and `.gitlab/CODEOWNERS` that exists. Returns its `path` (null when there is none), the `ref` read, its `size`, how many `rules` it has, its `sections`, and `errors`: each with its `line` (0 for the file as a whole), `kind`, the `token` at fault and a `message` saying how to fix it. `kind` is `too_large`, `negation`, `character_range`, `bad_pattern`, `bad_owner`, `bad_section`, `unknown_user`, `unknown_team`, `unknown_email`, `no_write_access` or `team_no_access`. Needs the Read role; a public repository's is open to anyone."
1503+ }
1504+ Op::Security(op) => op.description(),
11201505 }
11211506 }
11221507
12491634 }),
12501635 &["repo", "to"],
12511636 ),
1252− Op::GetRepo | Op::ListLabels => repo_only(),
1637+ Op::GetRepo | Op::ListLabels | Op::AddDefaultLabels => repo_only(),
1638+ Op::CreateLabel => object(
1639+ json!({
1640+ "repo": repo_schema(),
1641+ "label": { "type": "string", "description": "Its name: lowercase, at most 50 characters, e.g. \"good first issue\"." },
1642+ "color": { "type": "string", "description": "Six hex digits, with or without #, e.g. \"d73a4a\". Chosen from the name when left out." },
1643+ "description": { "type": "string", "description": "What it means, at most 100 characters." },
1644+ }),
1645+ &["repo", "label"],
1646+ ),
1647+ Op::UpdateLabel => object(
1648+ json!({
1649+ "repo": repo_schema(),
1650+ "label": label_schema(),
1651+ "new_name": { "type": "string", "description": "Rename it, on everything that carries it." },
1652+ "color": { "type": "string", "description": "Six hex digits." },
1653+ "description": { "type": "string", "description": "An empty string clears it." },
1654+ }),
1655+ &["repo", "label"],
1656+ ),
1657+ Op::DeleteLabel => object(json!({ "repo": repo_schema(), "label": label_schema() }), &["repo", "label"]),
1658+ Op::ListIssueLabels => just_numbered(),
1659+ Op::AddIssueLabels | Op::SetIssueLabels => object(
1660+ numbered(json!({
1661+ "labels": {
1662+ "type": "array",
1663+ "items": { "type": "string" },
1664+ "description": "Label names, e.g. [\"bug\", \"help wanted\"]. Names the repository does not have yet are created for someone with the Triage role.",
1665+ },
1666+ })),
1667+ &["repo", "number", "labels"],
1668+ ),
1669+ Op::RemoveIssueLabels => object(
1670+ numbered(json!({
1671+ "label": label_schema(),
1672+ "labels": {
1673+ "type": "array",
1674+ "items": { "type": "string" },
1675+ "description": "Instead of label: several to take off. With neither, all of them.",
1676+ },
1677+ })),
1678+ &["repo", "number"],
1679+ ),
1680+ Op::ListMilestones => object(
1681+ json!({ "repo": repo_schema(), "state": states }),
1682+ &["repo"],
1683+ ),
1684+ Op::GetMilestone | Op::DeleteMilestone => {
1685+ object(json!({ "repo": repo_schema(), "milestone": milestone_schema() }), &["repo", "milestone"])
1686+ }
1687+ Op::CreateMilestone | Op::UpdateMilestone => {
1688+ let mut properties = json!({
1689+ "repo": repo_schema(),
1690+ "title": { "type": "string", "description": "Unique in the repository, at most 100 characters." },
1691+ "description": { "type": "string", "description": "Markdown." },
1692+ "due_on": { "type": "string", "description": "The day it is due, YYYY-MM-DD. On update, \"\" clears it." },
1693+ "state": states,
1694+ });
1695+ if self == Op::UpdateMilestone {
1696+ properties["milestone"] = milestone_schema();
1697+ object(properties, &["repo", "milestone"])
1698+ } else {
1699+ object(properties, &["repo", "title"])
1700+ }
1701+ }
12531702 Op::UpdateRepo => object(
12541703 json!({
12551704 "repo": repo_schema(),
14651914 "type": "boolean",
14661915 "description": "Ask a person before merging a g1t agent's change whose confidence is low: auto-merge and the merge queue leave it until a person approves it. On by default.",
14671916 },
1917+ "require_code_owner_review": {
1918+ "type": "boolean",
1919+ "description": "Refuse to merge until the code owners of every file a pull request changes, as the CODEOWNERS file of the branch it merges into names them, have approved it, as many as each section asks. Only people's approvals count, and g1t's only where the file names @g1t.",
1920+ },
14681921 }),
14691922 &["repo"],
14701923 ),
14891942 "repo": repo_schema(),
14901943 "state": states,
14911944 "label": { "type": "string", "description": "Only issues carrying this label." },
1945+ "milestone": { "type": "integer", "description": "Only issues in the milestone of this number." },
14921946 }),
14931947 &["repo"],
14941948 ),
15081962 "labels": {
15091963 "type": "array",
15101964 "items": { "type": "string" },
1511− "description": "What kind of issue this is, e.g. \"bug\" or \"feature\". list_labels shows the labels in use; a new name creates a new label.",
1965+ "description": "What kind of issue this is, e.g. \"bug\" or \"enhancement\": the repository's labels, as list_labels gives them. A name it does not have yet is created for someone with the Triage role.",
15121966 },
15131967 "checks": {
15141968 "type": "array",
15161970 "deprecated": true,
15171971 "description": "Deprecated. Commands are added to the body under \"Definition of done\", and the response says so in deprecation. What must pass before a pull request merges is the default branch's required checks.",
15181972 },
1973+ "milestone": { "type": "integer", "description": "The number of the milestone to put it in. Needs the Triage role." },
15191974 }),
15201975 &["repo", "title"],
15211976 ),
15231978 numbered(json!({
15241979 "title": { "type": "string" },
15251980 "body": { "type": "string" },
1526− "labels": { "type": "array", "items": { "type": "string" } },
1981+ "labels": {
1982+ "type": "array",
1983+ "items": { "type": "string" },
1984+ "description": "Replaces the whole set. Names the repository does not have yet are created for someone with the Triage role.",
1985+ },
1986+ "milestone": {
1987+ "type": ["integer", "null"],
1988+ "description": "The number of the milestone to put it in; null or 0 takes it out. Needs the Triage role.",
1989+ },
15271990 "assignees": {
15281991 "type": "array",
15291992 "items": { "type": "string" },
16302093 })),
16312094 &["repo", "number", "verdict"],
16322095 ),
1633− Op::ListPullRequests => {
1634− object(json!({ "repo": repo_schema(), "state": states }), &["repo"])
1635− }
2096+ Op::ListPullRequests => object(
2097+ json!({
2098+ "repo": repo_schema(),
2099+ "state": states,
2100+ "label": { "type": "string", "description": "Only pull requests carrying this label." },
2101+ "milestone": { "type": "integer", "description": "Only pull requests in the milestone of this number." },
2102+ "base": { "type": "string", "description": "Only pull requests into this branch." },
2103+ }),
2104+ &["repo"],
2105+ ),
2106+ Op::UpdatePullRequest => object(
2107+ numbered(json!({
2108+ "base": {
2109+ "type": "string",
2110+ "description": "The branch it merges into: an existing branch other than its own. Needs the Write role.",
2111+ },
2112+ "labels": {
2113+ "type": "array",
2114+ "items": { "type": "string" },
2115+ "description": "Replaces the whole set.",
2116+ },
2117+ "milestone": {
2118+ "type": ["integer", "null"],
2119+ "description": "The number of the milestone to put it in; null or 0 takes it out. Needs the Triage role.",
2120+ },
2121+ "assignees": {
2122+ "type": "array",
2123+ "items": { "type": "string" },
2124+ "description": "Usernames; replaces the whole set.",
2125+ },
2126+ "reviewers": {
2127+ "type": "array",
2128+ "items": { "type": "string" },
2129+ "description": "Usernames whose review is asked for, and g1t for a g1t agent's; replaces the whole set.",
2130+ },
2131+ })),
2132+ &["repo", "number"],
2133+ ),
16362134 Op::CreatePullRequest => object(
16372135 json!({
16382136 "repo": repo_schema(),
16532151 "type": "string",
16542152 "description": "A label for the agent doing the work, e.g. \"claude-code\".",
16552153 },
2154+ "base": {
2155+ "type": "string",
2156+ "description": "The branch it merges into: the default branch when left out. Name another existing branch only when asked to.",
2157+ },
16562158 }),
16572159 &["repo"],
16582160 ),
21342636 }),
21352637 &["workspace", "projects"],
21362638 ),
2639+ Op::ListTeams => object(
2640+ json!({
2641+ "workspace": workspace_schema(),
2642+ "query": { "type": "string", "description": "Only teams whose name or slug has these letters." },
2643+ }),
2644+ &["workspace"],
2645+ ),
2646+ Op::GetTeam | Op::DeleteTeam | Op::ListChildTeams | Op::ListTeamRepos => {
2647+ object(team_target(json!({})), &["workspace", "team"])
2648+ }
2649+ Op::CreateTeam => object(
2650+ json!({
2651+ "workspace": workspace_schema(),
2652+ "name": { "type": "string", "description": "Its display name, at most 80 characters." },
2653+ "slug": {
2654+ "type": "string",
2655+ "description": "Its name in mentions and URLs: lowercase letters, digits and single hyphens. Made from the name if left out.",
2656+ },
2657+ "description": { "type": "string", "description": "What it is for, at most 280 characters." },
2658+ "visibility": team_visibility_schema(),
2659+ "parent": { "type": "string", "description": "The slug of the team to nest it under." },
2660+ "notify": {
2661+ "type": "boolean",
2662+ "description": "Whether its people are notified when it is mentioned. On unless you say.",
2663+ },
2664+ "members": {
2665+ "type": "array",
2666+ "items": { "type": "string" },
2667+ "description": "Usernames of members of the workspace to add, besides you.",
2668+ },
2669+ }),
2670+ &["workspace", "name"],
2671+ ),
2672+ Op::UpdateTeam => object(
2673+ team_target(json!({
2674+ "name": { "type": "string", "description": "A new display name." },
2675+ "slug": { "type": "string", "description": "A new slug, which changes its mention." },
2676+ "description": { "type": "string", "description": "A new description; an empty string clears it." },
2677+ "visibility": team_visibility_schema(),
2678+ "parent": {
2679+ "type": "string",
2680+ "description": "The slug of the team to nest it under; an empty string for none.",
2681+ },
2682+ "notify": { "type": "boolean", "description": "Whether its people are notified when it is mentioned." },
2683+ "review_assignment": {
2684+ "type": "object",
2685+ "properties": review_assignment_properties(),
2686+ "description": "What happens when it is asked to review; fields left out keep their value. See set_team_review_assignment.",
2687+ },
2688+ })),
2689+ &["workspace", "team"],
2690+ ),
2691+ Op::ListTeamMembers => object(
2692+ team_target(json!({ "include_child_teams": include_child_teams_schema() })),
2693+ &["workspace", "team"],
2694+ ),
2695+ Op::SetTeamMember => object(
2696+ team_target(json!({ "username": username_schema(), "role": team_role_schema() })),
2697+ &["workspace", "team", "username"],
2698+ ),
2699+ Op::RemoveTeamMember => object(
2700+ team_target(json!({ "username": username_schema() })),
2701+ &["workspace", "team", "username"],
2702+ ),
2703+ Op::SetTeamRepo | Op::RemoveTeamRepo => {
2704+ let mut properties = team_target(json!({
2705+ "repo": {
2706+ "type": "string",
2707+ "description": "The repository, in the team's workspace: its name, or \"owner/name\".",
2708+ },
2709+ }));
2710+ let mut required = vec!["workspace", "team", "repo"];
2711+ if self == Op::SetTeamRepo {
2712+ properties["role"] = role_schema();
2713+ required.push("role");
2714+ }
2715+ object(properties, &required)
2716+ }
2717+ Op::SetTeamReviewAssignment => object(team_target(review_assignment_properties()), &["workspace", "team"]),
2718+ Op::GetUsage => object(
2719+ json!({
2720+ "workspace": workspace_schema(),
2721+ "from": { "type": "string", "format": "date", "description": "The first day, YYYY-MM-DD (UTC). The first of this month if not given." },
2722+ "until": { "type": "string", "format": "date", "description": "The last day, included, YYYY-MM-DD (UTC). Today if not given." },
2723+ "products": {
2724+ "type": "array",
2725+ "items": { "type": "string", "enum": crate::billing::PRODUCTS },
2726+ "description": "Only these product families; all of them if not given. In a query string, separate them with commas.",
2727+ },
2728+ "projects": {
2729+ "type": "array",
2730+ "items": { "type": "string" },
2731+ "description": "Only these repositories, as \"owner/name\"; all of them if not given. In a query string, separate them with commas.",
2732+ },
2733+ "group_by": {
2734+ "type": "string",
2735+ "enum": crate::billing::GROUPS,
2736+ "description": "Also add up the range by product, project or day, as `groups`.",
2737+ },
2738+ }),
2739+ &["workspace"],
2740+ ),
2741+ Op::GetBudget | Op::GetAiCredit | Op::ListInvoices | Op::GetBillingDetails => {
2742+ object(json!({ "workspace": workspace_schema() }), &["workspace"])
2743+ }
2744+ Op::SetBudget => object(
2745+ json!({
2746+ "workspace": workspace_schema(),
2747+ "amount_micros": {
2748+ "type": ["integer", "null"],
2749+ "minimum": 0,
2750+ "description": "The monthly spend limit, in millionths of a dollar: 500000000 is $500. Null for the automatic limit. Left out: unchanged.",
2751+ },
2752+ "alerts": {
2753+ "type": "array",
2754+ "items": { "type": "integer", "enum": crate::billing::ALERT_LEVELS },
2755+ "description": "When to alert, in percent of the limit: some of 50, 75, 90 and 100. Replaces the whole list. Left out: unchanged.",
2756+ },
2757+ "pause_at_limit": { "type": "boolean", "description": "Pause usage at the limit (the default), or with false, only alert. Left out: unchanged." },
2758+ "webhook": {
2759+ "type": ["string", "null"],
2760+ "description": "An https:// address sent a JSON POST for each alert, or null for none. Left out: unchanged.",
2761+ },
2762+ }),
2763+ &["workspace"],
2764+ ),
2765+ Op::BuyAiCredit => object(
2766+ json!({
2767+ "workspace": workspace_schema(),
2768+ "amount_cents": {
2769+ "type": "integer",
2770+ "minimum": 1000,
2771+ "maximum": 100000,
2772+ "multipleOf": 100,
2773+ "description": "The credit to buy, in cents, in whole dollars: 5000 is $50.",
2774+ },
2775+ }),
2776+ &["workspace", "amount_cents"],
2777+ ),
2778+ Op::ListUserTeams => object(
2779+ json!({ "workspace": workspace_schema(), "username": username_schema() }),
2780+ &["workspace", "username"],
2781+ ),
2782+ Op::RequestReviewers | Op::RemoveRequestedReviewers => {
2783+ object(requested_reviewers_properties(), &["repo", "number"])
2784+ }
2785+ Op::GetCodeownersErrors => object(
2786+ json!({
2787+ "repo": repo_schema(),
2788+ "ref": {
2789+ "type": "string",
2790+ "description": "The branch, tag or commit to read the file from. The default branch if left out.",
2791+ },
2792+ }),
2793+ &["repo"],
2794+ ),
2795+ Op::Security(op) => op.input(),
21372796 }
21382797 }
21392798
21472806 | Op::ListIssues
21482807 | Op::GetIssue
21492808 | Op::ListLabels
2809+ | Op::ListIssueLabels
2810+ | Op::ListMilestones
2811+ | Op::GetMilestone
21502812 | Op::ListPullRequests
21512813 | Op::GetPullRequest
21522814 | Op::ReadSession
21552817 | Op::GetRepoSettings
21562818 | Op::ListCheckNames
21572819 | Op::GetMergeQueue
2820+ | Op::GetCodeownersErrors
21582821 )
21592822 }
21602823
21652828
21662829 /// Whether the operation is about one repository, named by `repo`.
21672830 pub(crate) fn needs_repo(self) -> bool {
2831+ if let Op::Security(op) = self {
2832+ return op.needs_repo();
2833+ }
21682834 !matches!(
21692835 self,
21702836 Op::Whoami
22352901 | Op::PinProject
22362902 | Op::UnpinProject
22372903 | Op::ReorderPinnedProjects
2904+ | Op::ListTeams
2905+ | Op::GetTeam
2906+ | Op::CreateTeam
2907+ | Op::UpdateTeam
2908+ | Op::DeleteTeam
2909+ | Op::ListTeamMembers
2910+ | Op::SetTeamMember
2911+ | Op::RemoveTeamMember
2912+ | Op::ListChildTeams
2913+ | Op::ListTeamRepos
2914+ | Op::SetTeamRepo
2915+ | Op::RemoveTeamRepo
2916+ | Op::SetTeamReviewAssignment
2917+ | Op::ListUserTeams
2918+ | Op::GetUsage
2919+ | Op::GetBudget
2920+ | Op::SetBudget
2921+ | Op::GetAiCredit
2922+ | Op::BuyAiCredit
2923+ | Op::ListInvoices
2924+ | Op::GetBillingDetails
22382925 )
22392926 }
22402927
29023589 max_revisions: integer(input, "max_revisions").unwrap_or(current.max_revisions),
29033590 merge_queue: flag("merge_queue", current.merge_queue),
29043591 hold_low_confidence: flag("hold_low_confidence", current.hold_low_confidence),
3592+ require_code_owner_review: flag(
3593+ "require_code_owner_review",
3594+ current.require_code_owner_review,
3595+ ),
29053596 ..current
29063597 };
29073598 pass(
29483639 viewer: viewer.clone(),
29493640 state: state(input),
29503641 label: optional_text(input, "label"),
3642+ milestone: integer(input, "milestone"),
29513643 },
29523644 )
29533645 .await
29653657 body: text(input, "body"),
29663658 labels: strings(input, "labels").unwrap_or_default(),
29673659 checks: checks.clone(),
3660+ milestone: integer(input, "milestone"),
29683661 },
29693662 )
29703663 .await?;
29823675 body: input["body"].as_str().map(str::to_owned),
29833676 labels: strings(input, "labels"),
29843677 assignees: strings(input, "assignees"),
3678+ milestone: milestone_input(input),
29853679 },
29863680 )
29873681 .await
30733767 .await
30743768 }
30753769 Op::ListLabels => pass(work, "list_labels", &view()).await,
3770+ Op::CreateLabel | Op::UpdateLabel => {
3771+ let creating = self == Op::CreateLabel;
3772+ pass(
3773+ work,
3774+ "save_label",
3775+ &SaveLabelArgs {
3776+ actor: actor(),
3777+ repo,
3778+ name: (!creating).then(|| text(input, "label")),
3779+ new_name: if creating { Some(text(input, "label")) } else { optional_text(input, "new_name") },
3780+ color: optional_text(input, "color"),
3781+ description: input["description"].as_str().map(str::to_owned),
3782+ },
3783+ )
3784+ .await
3785+ }
3786+ Op::DeleteLabel => {
3787+ pass(work, "delete_label", &DeleteLabelArgs { actor: actor(), repo, name: text(input, "label") }).await
3788+ }
3789+ Op::AddDefaultLabels => pass(work, "add_default_labels", &RepoActorArgs { actor: actor(), repo }).await,
3790+ Op::ListIssueLabels => {
3791+ // The item's names, with each label's color and description.
3792+ let labels = call::<_, Vec<Label>>(work, "list_labels", &view()).await?;
3793+ let item = call::<_, IssueDetail>(work, "get_issue", &view()).await?;
3794+ let names = match item {
3795+ Outcome::Ok(detail) => detail.issue.labels,
3796+ Outcome::Fail(_) => match call::<_, PullDetail>(work, "get_pull", &view()).await? {
3797+ Outcome::Ok(detail) => detail.pull.labels,
3798+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
3799+ },
3800+ };
3801+ let labels = match labels {
3802+ Outcome::Ok(labels) => labels,
3803+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
3804+ };
3805+ ok(&names
3806+ .iter()
3807+ .filter_map(|name| labels.iter().find(|label| label.name == *name))
3808+ .collect::<Vec<_>>())
3809+ }
3810+ Op::AddIssueLabels | Op::SetIssueLabels | Op::RemoveIssueLabels => {
3811+ let (change, labels) = match self {
3812+ Op::AddIssueLabels => (LabelChange::Add, strings(input, "labels").unwrap_or_default()),
3813+ Op::SetIssueLabels => (LabelChange::Set, strings(input, "labels").unwrap_or_default()),
3814+ // One, several, or with neither, all of them.
3815+ _ => match (optional_text(input, "label"), strings(input, "labels")) {
3816+ (Some(one), _) => (LabelChange::Remove, vec![one]),
3817+ (None, Some(several)) => (LabelChange::Remove, several),
3818+ (None, None) => (LabelChange::Set, Vec::new()),
3819+ },
3820+ };
3821+ pass(work, "set_labels", &SetLabelsArgs { actor: actor(), repo, number, labels, change }).await
3822+ }
3823+ Op::ListMilestones => {
3824+ pass(work, "list_milestones", &ListMilestonesArgs { repo, viewer: viewer.clone(), state: state(input) }).await
3825+ }
3826+ Op::GetMilestone => {
3827+ let asked = ViewArgs { number: integer(input, "milestone").unwrap_or_default(), ..view() };
3828+ pass(work, "get_milestone", &asked).await
3829+ }
3830+ Op::CreateMilestone | Op::UpdateMilestone => {
3831+ pass(
3832+ work,
3833+ "save_milestone",
3834+ &SaveMilestoneArgs {
3835+ actor: actor(),
3836+ repo,
3837+ number: (self == Op::UpdateMilestone).then(|| integer(input, "milestone").unwrap_or_default()),
3838+ title: input["title"].as_str().map(str::to_owned),
3839+ description: input["description"].as_str().map(str::to_owned),
3840+ due_on: input["due_on"].as_str().map(str::to_owned),
3841+ state: state(input),
3842+ },
3843+ )
3844+ .await
3845+ }
3846+ Op::DeleteMilestone => {
3847+ pass(
3848+ work,
3849+ "delete_milestone",
3850+ &DeleteMilestoneArgs { actor: actor(), repo, number: integer(input, "milestone").unwrap_or_default() },
3851+ )
3852+ .await
3853+ }
3854+ Op::UpdatePullRequest => {
3855+ pass(
3856+ work,
3857+ "update_pull",
3858+ &UpdatePullArgs {
3859+ actor: actor(),
3860+ repo,
3861+ number,
3862+ assignees: strings(input, "assignees"),
3863+ reviewers: strings(input, "reviewers"),
3864+ labels: strings(input, "labels"),
3865+ milestone: milestone_input(input),
3866+ base: optional_text(input, "base"),
3867+ },
3868+ )
3869+ .await
3870+ }
30763871 Op::AddComment | Op::ReviewPullRequest => {
30773872 let verdict = match (self, input["verdict"].as_str()) {
30783873 (Op::AddComment, _) => None,
31083903 repo,
31093904 viewer: viewer.clone(),
31103905 state: state(input),
3906+ label: optional_text(input, "label"),
3907+ milestone: integer(input, "milestone"),
3908+ base: optional_text(input, "base"),
31113909 },
31123910 )
31133911 .await
31273925 branch: optional_text(input, "branch"),
31283926 agent: optional_text(input, "agent").unwrap_or_else(|| "agent".into()),
31293927 runtime: Runtime::External,
3928+ base: optional_text(input, "base"),
31303929 },
31313930 )
31323931 .await?;
36654464 .await?;
36664465 changed_alert(changed)
36674466 }
4467+ // Teams: identity decides who may see and change each, and
4468+ // refuses every token but a person's for changes. See
4469+ // g1t_contracts::teams.
4470+ Op::ListTeams => {
4471+ pass(
4472+ identity,
4473+ "list_teams",
4474+ &ListTeamsArgs { viewer: viewer.clone(), workspace: workspace(), query: optional_text(input, "query") },
4475+ )
4476+ .await
4477+ }
4478+ Op::GetTeam | Op::ListChildTeams | Op::ListTeamRepos | Op::ListTeamMembers => {
4479+ let method = match self {
4480+ Op::GetTeam => "get_team",
4481+ Op::ListChildTeams => "child_teams",
4482+ Op::ListTeamRepos => "team_repos",
4483+ _ => "team_members",
4484+ };
4485+ pass(
4486+ identity,
4487+ method,
4488+ &TeamArgs {
4489+ viewer: viewer.clone(),
4490+ workspace: workspace(),
4491+ team: team_slug(input),
4492+ include_child_teams: self == Op::ListTeamMembers && yes(input, "include_child_teams") == Some(true),
4493+ },
4494+ )
4495+ .await
4496+ }
4497+ Op::CreateTeam => {
4498+ let visibility = match team_visibility(input) {
4499+ Ok(visibility) => visibility,
4500+ Err(message) => return failed(FailureCode::Invalid, &message),
4501+ };
4502+ pass(
4503+ identity,
4504+ "create_team",
4505+ &CreateTeamArgs {
4506+ actor: actor(),
4507+ workspace: workspace(),
4508+ name: text(input, "name").trim().to_owned(),
4509+ slug: optional_text(input, "slug"),
4510+ description: optional_text(input, "description"),
4511+ visibility,
4512+ parent: optional_text(input, "parent"),
4513+ notify: yes(input, "notify"),
4514+ members: strings(input, "members").unwrap_or_default(),
4515+ surface: Some(services.audit.surface),
4516+ },
4517+ )
4518+ .await
4519+ }
4520+ Op::UpdateTeam | Op::SetTeamReviewAssignment => {
4521+ let visibility = match team_visibility(input) {
4522+ Ok(visibility) if self == Op::UpdateTeam => visibility,
4523+ Ok(_) => None,
4524+ Err(message) => return failed(FailureCode::Invalid, &message),
4525+ };
4526+ // The review assignment's fields: in `review_assignment` to
4527+ // update a team, or at the top level to set it.
4528+ let given = match self {
4529+ Op::UpdateTeam => input.get("review_assignment").filter(|value| !value.is_null()),
4530+ _ => Some(input),
4531+ };
4532+ if given.is_some_and(|given| !given.is_object()) {
4533+ return failed(FailureCode::Invalid, "review_assignment is an object, such as {\"enabled\": true, \"count\": 2}.");
4534+ }
4535+ let review = match given {
4536+ None => None,
4537+ Some(given) => {
4538+ if !REVIEW_ASSIGNMENT_FIELDS.iter().any(|key| given.get(*key).is_some_and(|value| !value.is_null())) {
4539+ return failed(
4540+ FailureCode::Invalid,
4541+ &format!("Give the review assignment to change: {}.", REVIEW_ASSIGNMENT_FIELDS.join(", ")),
4542+ );
4543+ }
4544+ // What is not given stays as it is.
4545+ let current: Outcome<Team> = call(
4546+ identity,
4547+ "get_team",
4548+ &TeamArgs { viewer: viewer.clone(), workspace: workspace(), team: team_slug(input), include_child_teams: false },
4549+ )
4550+ .await?;
4551+ let current = match current {
4552+ Outcome::Ok(team) => team.review_assignment,
4553+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
4554+ };
4555+ match review_assignment(given, current) {
4556+ Ok(review) => Some(review),
4557+ Err(message) => return failed(FailureCode::Invalid, &message),
4558+ }
4559+ }
4560+ };
4561+ let words = |key: &str| match self {
4562+ Op::UpdateTeam => input[key].as_str().map(str::to_owned),
4563+ _ => None,
4564+ };
4565+ let args = UpdateTeamArgs {
4566+ actor: actor(),
4567+ workspace: workspace(),
4568+ team: team_slug(input),
4569+ name: words("name"),
4570+ slug: words("slug"),
4571+ description: words("description"),
4572+ visibility,
4573+ parent: words("parent"),
4574+ notify: if self == Op::UpdateTeam { yes(input, "notify") } else { None },
4575+ review_assignment: review,
4576+ surface: Some(services.audit.surface),
4577+ };
4578+ if args.name.is_none()
4579+ && args.slug.is_none()
4580+ && args.description.is_none()
4581+ && args.visibility.is_none()
4582+ && args.parent.is_none()
4583+ && args.notify.is_none()
4584+ && args.review_assignment.is_none()
4585+ {
4586+ return failed(
4587+ FailureCode::Invalid,
4588+ "Give name, slug, description, visibility, parent, notify or review_assignment to change.",
4589+ );
4590+ }
4591+ pass(identity, "update_team", &args).await
4592+ }
4593+ Op::DeleteTeam => {
4594+ pass(
4595+ identity,
4596+ "delete_team",
4597+ &DeleteTeamArgs {
4598+ actor: actor(),
4599+ workspace: workspace(),
4600+ team: team_slug(input),
4601+ surface: Some(services.audit.surface),
4602+ },
4603+ )
4604+ .await
4605+ }
4606+ Op::SetTeamMember => {
4607+ let role = match team_role(input) {
4608+ Ok(role) => role,
4609+ Err(message) => return failed(FailureCode::Invalid, &message),
4610+ };
4611+ pass(
4612+ identity,
4613+ "set_team_member",
4614+ &SetTeamMemberArgs {
4615+ actor: actor(),
4616+ workspace: workspace(),
4617+ team: team_slug(input),
4618+ username: text(input, "username").trim().trim_start_matches('@').to_owned(),
4619+ role,
4620+ surface: Some(services.audit.surface),
4621+ },
4622+ )
4623+ .await
4624+ }
4625+ Op::RemoveTeamMember => {
4626+ pass(
4627+ identity,
4628+ "remove_team_member",
4629+ &RemoveTeamMemberArgs {
4630+ actor: actor(),
4631+ workspace: workspace(),
4632+ team: team_slug(input),
4633+ username: text(input, "username").trim().trim_start_matches('@').to_owned(),
4634+ surface: Some(services.audit.surface),
4635+ },
4636+ )
4637+ .await
4638+ }
4639+ Op::SetTeamRepo | Op::RemoveTeamRepo => {
4640+ let Some(path) = team_repo(input, &workspace()) else {
4641+ return failed(
4642+ FailureCode::Invalid,
4643+ "Give the repository: its name in the team's workspace, or \"owner/name\".",
4644+ );
4645+ };
4646+ if self == Op::RemoveTeamRepo {
4647+ return pass(
4648+ identity,
4649+ "remove_team_repo",
4650+ &RemoveTeamRepoArgs {
4651+ actor: actor(),
4652+ workspace: workspace(),
4653+ team: team_slug(input),
4654+ repo: path,
4655+ surface: Some(services.audit.surface),
4656+ },
4657+ )
4658+ .await;
4659+ }
4660+ let Some(role) = repo_role(input) else {
4661+ return failed(FailureCode::Invalid, ROLE_NEEDED);
4662+ };
4663+ pass(
4664+ identity,
4665+ "set_team_repo",
4666+ &SetTeamRepoArgs {
4667+ actor: actor(),
4668+ workspace: workspace(),
4669+ team: team_slug(input),
4670+ repo: path,
4671+ role,
4672+ surface: Some(services.audit.surface),
4673+ },
4674+ )
4675+ .await
4676+ }
4677+ // A workspace's billing: the billing service decides, this gives
4678+ // each answer its public shape.
4679+ Op::GetUsage
4680+ | Op::GetBudget
4681+ | Op::SetBudget
4682+ | Op::GetAiCredit
4683+ | Op::BuyAiCredit
4684+ | Op::ListInvoices
4685+ | Op::GetBillingDetails => crate::billing::run(self, services, viewer, input).await,
4686+ Op::ListUserTeams => {
4687+ pass(
4688+ identity,
4689+ "user_teams",
4690+ &UserTeamsArgs {
4691+ viewer: viewer.clone(),
4692+ workspace: workspace(),
4693+ username: text(input, "username").trim().trim_start_matches('@').to_owned(),
4694+ },
4695+ )
4696+ .await
4697+ }
4698+ // Who is asked to review: the whole list, people and teams,
4699+ // replaces who is asked, so read it and change it.
4700+ Op::RequestReviewers | Op::RemoveRequestedReviewers => {
4701+ let (people, teams) = reviewer_names(input, &repo.namespace);
4702+ if people.is_empty() && teams.is_empty() {
4703+ return failed(
4704+ FailureCode::Invalid,
4705+ "Give reviewers (usernames) or team_reviewers (\"workspace/team\").",
4706+ );
4707+ }
4708+ let found: Outcome<PullDetail> = call(work, "get_pull", &view()).await?;
4709+ let pull = match found {
4710+ Outcome::Ok(detail) => detail.pull,
4711+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
4712+ };
4713+ let reviewers = reviewers_after(
4714+ &pull.reviewers,
4715+ &pull.team_reviewers,
4716+ &people,
4717+ &teams,
4718+ self == Op::RequestReviewers,
4719+ );
4720+ pass(
4721+ work,
4722+ "update_pull",
4723+ &UpdatePullArgs { actor: actor(), repo: repo.clone(), number, assignees: None, reviewers: Some(reviewers), labels: None, milestone: None, base: None },
4724+ )
4725+ .await
4726+ }
4727+ Op::GetCodeownersErrors => {
4728+ pass(
4729+ work,
4730+ "codeowners_errors",
4731+ &CodeOwnersErrorsArgs { viewer: viewer.clone(), repo, git_ref: optional_text(input, "ref") },
4732+ )
4733+ .await
4734+ }
36684735 // A person's own inbox: the events service keeps it.
36694736 Op::ListNotifications
36704737 | Op::MarkNotificationsRead
36844751 Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => {
36854752 crate::pins::run(self, services, viewer, input).await
36864753 }
4754+ // The security suite: the security service decides, this gives
4755+ // each answer its public shape.
4756+ Op::Security(op) => crate::security::run(op, services, viewer, input).await,
36874757 Op::ReopenSecurityAlert => {
36884758 let changed: Outcome<AlertChange> = call(
36894759 &services.security,
37574827 input["role"].as_str().and_then(RepoRole::parse)
37584828 }
37594829
4830+/// A yes or no, given as a boolean or, in a URL, as text.
4831+fn yes(input: &Value, key: &str) -> Option<bool> {
4832+ match &input[key] {
4833+ Value::Bool(value) => Some(*value),
4834+ Value::String(text) => match text.trim().to_ascii_lowercase().as_str() {
4835+ "true" | "1" | "yes" => Some(true),
4836+ "false" | "0" | "no" => Some(false),
4837+ _ => None,
4838+ },
4839+ _ => None,
4840+ }
4841+}
4842+
4843+/// The team named by `team`, by its slug.
4844+fn team_slug(input: &Value) -> String {
4845+ text(input, "team").trim().trim_start_matches('@').to_lowercase()
4846+}
4847+
4848+/// `visibility`, when it is given.
4849+fn team_visibility(input: &Value) -> std::result::Result<Option<TeamVisibility>, String> {
4850+ match input.get("visibility").filter(|value| !value.is_null()) {
4851+ None => Ok(None),
4852+ Some(value) => value
4853+ .as_str()
4854+ .and_then(TeamVisibility::parse)
4855+ .map(Some)
4856+ .ok_or_else(|| "visibility is visible or secret.".to_owned()),
4857+ }
4858+}
4859+
4860+/// A person's `role` in a team: member when it is left out.
4861+fn team_role(input: &Value) -> std::result::Result<TeamRole, String> {
4862+ match input.get("role").filter(|value| !value.is_null()) {
4863+ None => Ok(TeamRole::Member),
4864+ Some(value) => value
4865+ .as_str()
4866+ .and_then(TeamRole::parse)
4867+ .ok_or_else(|| "role is member or maintainer.".to_owned()),
4868+ }
4869+}
4870+
4871+/// The fields of a team's review assignment, as inputs name them.
4872+const REVIEW_ASSIGNMENT_FIELDS: [&str; 8] =
4873+ ["enabled", "algorithm", "count", "skip_busy", "busy_at", "include_child_teams", "excluded", "notify_team"];
4874+
4875+/// `current` with the fields `given` has changed, each checked.
4876+fn review_assignment(given: &Value, current: ReviewAssignment) -> std::result::Result<ReviewAssignment, String> {
4877+ let mut next = current;
4878+ let present = |key: &str| given.get(key).is_some_and(|value| !value.is_null());
4879+ let boolean = |key: &str, now: bool| -> std::result::Result<bool, String> {
4880+ if !present(key) {
4881+ return Ok(now);
4882+ }
4883+ yes(given, key).ok_or_else(|| format!("{key} is true or false."))
4884+ };
4885+ let within = |key: &str, now: u32, most: u32| -> std::result::Result<u32, String> {
4886+ if !present(key) {
4887+ return Ok(now);
4888+ }
4889+ integer(given, key)
4890+ .filter(|n| (1..=most).contains(n))
4891+ .ok_or_else(|| format!("{key} is a whole number from 1 to {most}."))
4892+ };
4893+ next.enabled = boolean("enabled", next.enabled)?;
4894+ if present("algorithm") {
4895+ next.algorithm = given["algorithm"]
4896+ .as_str()
4897+ .and_then(ReviewAlgorithm::parse)
4898+ .ok_or_else(|| "algorithm is round_robin or load_balance.".to_owned())?;
4899+ }
4900+ next.count = within("count", next.count, g1t_contracts::teams::MAX_ASSIGNED)?;
4901+ next.skip_busy = boolean("skip_busy", next.skip_busy)?;
4902+ next.busy_at = within("busy_at", next.busy_at, 100)?;
4903+ next.include_child_teams = boolean("include_child_teams", next.include_child_teams)?;
4904+ if present("excluded") {
4905+ next.excluded = strings(given, "excluded").ok_or_else(|| "excluded is a list of usernames.".to_owned())?;
4906+ }
4907+ next.notify_team = boolean("notify_team", next.notify_team)?;
4908+ Ok(next)
4909+}
4910+
4911+/// The repository `repo` names for a team of `workspace`: `owner/name`, or
4912+/// a name in the workspace.
4913+fn team_repo(input: &Value, workspace: &str) -> Option<RepoPath> {
4914+ repo_path(input).or_else(|| {
4915+ let name = input["repo"].as_str()?.trim();
4916+ (!name.is_empty() && !name.contains('/')).then(|| RepoPath {
4917+ namespace: workspace.to_owned(),
4918+ name: name.to_owned(),
4919+ })
4920+ })
4921+}
4922+
4923+/// The people (`reviewers`) and teams (`team_reviewers`) a call names, each
4924+/// once, lowercase; a team as `workspace/team`, a bare slug being one of
4925+/// `workspace`'s. A name in `reviewers` with a `/` is a team too.
4926+fn reviewer_names(input: &Value, workspace: &str) -> (Vec<String>, Vec<String>) {
4927+ let (mut people, mut teams): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());
4928+ let clean = |name: &str| name.trim().trim_start_matches('@').to_lowercase();
4929+ for name in strings(input, "reviewers").unwrap_or_default() {
4930+ let name = clean(&name);
4931+ let list = if name.contains('/') { &mut teams } else { &mut people };
4932+ if !name.is_empty() && !list.contains(&name) {
4933+ list.push(name);
4934+ }
4935+ }
4936+ for name in strings(input, "team_reviewers").unwrap_or_default() {
4937+ let name = clean(&name);
4938+ if name.is_empty() {
4939+ continue;
4940+ }
4941+ let name = if name.contains('/') { name } else { format!("{}/{name}", workspace.to_lowercase()) };
4942+ if !teams.contains(&name) {
4943+ teams.push(name);
4944+ }
4945+ }
4946+ (people, teams)
4947+}
4948+
4949+/// Who is asked to review once `people` and `teams` are added (or, with
4950+/// `add` false, taken away), as update_pull takes it: people, then teams.
4951+fn reviewers_after(
4952+ current_people: &[String],
4953+ current_teams: &[String],
4954+ people: &[String],
4955+ teams: &[String],
4956+ add: bool,
4957+) -> Vec<String> {
4958+ let has = |list: &[String], name: &str| list.iter().any(|item| item.eq_ignore_ascii_case(name));
4959+ let mut out = Vec::new();
4960+ for (current, change) in [(current_people, people), (current_teams, teams)] {
4961+ let mut kept: Vec<String> = current.iter().filter(|name| add || !has(change, name)).cloned().collect();
4962+ if add {
4963+ for name in change {
4964+ if !has(&kept, name) {
4965+ kept.push(name.clone());
4966+ }
4967+ }
4968+ }
4969+ out.extend(kept);
4970+ }
4971+ out
4972+}
4973+
37604974 impl Op {
37614975 /// The properties of the operation's input schema.
37624976 pub fn properties(self) -> Map<String, Value> {
39205134 }
39215135 assert!(!NEVER.contains(&Op::ListSecurityAlerts.name()));
39225136 }
5137+
5138+ const TEAMS: [Op; 14] = [
5139+ Op::ListTeams,
5140+ Op::GetTeam,
5141+ Op::CreateTeam,
5142+ Op::UpdateTeam,
5143+ Op::DeleteTeam,
5144+ Op::ListTeamMembers,
5145+ Op::SetTeamMember,
5146+ Op::RemoveTeamMember,
5147+ Op::ListChildTeams,
5148+ Op::ListTeamRepos,
5149+ Op::SetTeamRepo,
5150+ Op::RemoveTeamRepo,
5151+ Op::SetTeamReviewAssignment,
5152+ Op::ListUserTeams,
5153+ ];
5154+
5155+ /// A team belongs to a workspace: its operations name the workspace,
5156+ /// never need a repository, and need someone signed in.
5157+ #[test]
5158+ fn team_operations_name_a_workspace() {
5159+ for op in TEAMS {
5160+ assert!(!op.needs_repo(), "{}", op.name());
5161+ assert!(op.needs_user(), "{}", op.name());
5162+ assert!(op.required().contains(&"workspace".to_owned()), "{}", op.name());
5163+ }
5164+ for op in [Op::RequestReviewers, Op::RemoveRequestedReviewers, Op::GetCodeownersErrors] {
5165+ assert!(op.needs_repo(), "{}", op.name());
5166+ }
5167+ // A public repository's CODEOWNERS file is anyone's to check.
5168+ assert!(!Op::GetCodeownersErrors.needs_user());
5169+ }
5170+
5171+ #[test]
5172+ fn team_words_are_checked() {
5173+ assert_eq!(team_visibility(&json!({})), Ok(None));
5174+ assert_eq!(team_visibility(&json!({ "visibility": "Secret" })), Ok(Some(TeamVisibility::Secret)));
5175+ assert!(team_visibility(&json!({ "visibility": "hidden" })).is_err());
5176+ assert_eq!(team_role(&json!({})), Ok(TeamRole::Member));
5177+ assert_eq!(team_role(&json!({ "role": "maintainer" })), Ok(TeamRole::Maintainer));
5178+ assert!(team_role(&json!({ "role": "admin" })).is_err());
5179+ assert_eq!(Op::SetTeamMember.input()["properties"]["role"]["enum"], json!(["member", "maintainer"]));
5180+ assert_eq!(Op::CreateTeam.input()["properties"]["visibility"]["enum"], json!(["visible", "secret"]));
5181+ assert_eq!(
5182+ Op::SetTeamRepo.input()["properties"]["role"]["enum"],
5183+ json!(["read", "triage", "write", "maintain", "admin"])
5184+ );
5185+ assert_eq!(
5186+ Op::SetTeamReviewAssignment.input()["properties"]["algorithm"]["enum"],
5187+ json!(["round_robin", "load_balance"])
5188+ );
5189+ assert_eq!(yes(&json!({ "a": "true" }), "a"), Some(true));
5190+ assert_eq!(yes(&json!({ "a": false }), "a"), Some(false));
5191+ assert_eq!(yes(&json!({ "a": "maybe" }), "a"), None);
5192+ assert_eq!(team_slug(&json!({ "team": " @Backend " })), "backend");
5193+ }
5194+
5195+ /// Fields left out keep their value; a bad one is refused before
5196+ /// identity is asked.
5197+ #[test]
5198+ fn review_assignment_changes_only_what_is_given() {
5199+ let current = ReviewAssignment { count: 2, excluded: vec!["bo".into()], ..ReviewAssignment::default() };
5200+ let next = review_assignment(&json!({ "enabled": true, "algorithm": "load_balance" }), current.clone()).unwrap();
5201+ assert!(next.enabled);
5202+ assert_eq!(next.algorithm, ReviewAlgorithm::LoadBalance);
5203+ assert_eq!((next.count, next.excluded.clone()), (2, vec!["bo".to_owned()]));
5204+ let next = review_assignment(&json!({ "count": "3", "excluded": [], "skip_busy": "true", "busy_at": 4 }), current.clone()).unwrap();
5205+ assert_eq!((next.count, next.busy_at, next.skip_busy), (3, 4, true));
5206+ assert!(next.excluded.is_empty());
5207+ for bad in [
5208+ json!({ "algorithm": "random" }),
5209+ json!({ "count": 0 }),
5210+ json!({ "count": 11 }),
5211+ json!({ "busy_at": 101 }),
5212+ json!({ "enabled": "sometimes" }),
5213+ json!({ "excluded": "ana" }),
5214+ ] {
5215+ assert!(review_assignment(&bad, current.clone()).is_err(), "{bad}");
5216+ }
5217+ }
5218+
5219+ #[test]
5220+ fn a_team_names_a_repository_by_itself_or_in_full() {
5221+ let path = team_repo(&json!({ "repo": "rocket" }), "acme").unwrap();
5222+ assert_eq!((path.namespace.as_str(), path.name.as_str()), ("acme", "rocket"));
5223+ let path = team_repo(&json!({ "repo": "acme/rocket" }), "other").unwrap();
5224+ assert_eq!((path.namespace.as_str(), path.name.as_str()), ("acme", "rocket"));
5225+ assert!(team_repo(&json!({ "repo": "" }), "acme").is_none());
5226+ assert!(team_repo(&json!({}), "acme").is_none());
5227+ }
5228+
5229+ /// Requested reviewers are added to, or taken from, who is asked; a
5230+ /// team's bare slug is one of the repository's workspace.
5231+ #[test]
5232+ fn requested_reviewers_change_the_whole_list() {
5233+ let input = json!({ "reviewers": ["@Ana", "g1t", "acme/web"], "team_reviewers": ["Backend", "acme/web"] });
5234+ let (people, teams) = reviewer_names(&input, "Acme");
5235+ assert_eq!(people, vec!["ana", "g1t"]);
5236+ assert_eq!(teams, vec!["acme/web", "acme/backend"]);
5237+ let current_people = vec!["bo".to_owned(), "ana".to_owned()];
5238+ let current_teams = vec!["acme/web".to_owned()];
5239+ assert_eq!(
5240+ reviewers_after(&current_people, &current_teams, &people, &teams, true),
5241+ vec!["bo", "ana", "g1t", "acme/web", "acme/backend"]
5242+ );
5243+ assert_eq!(
5244+ reviewers_after(&current_people, &current_teams, &["ANA".to_owned()], &["acme/web".to_owned()], false),
5245+ vec!["bo"]
5246+ );
5247+ assert_eq!(reviewer_names(&json!({}), "acme"), (vec![], vec![]));
5248+ }
39235249 }
+2778−22
726726 "max_revisions": 2,
727727 "merge_queue": false,
728728 "hold_low_confidence": true,
729+ "require_code_owner_review": false,
729730 "updated_by": null,
730731 "updated_at": null
731732 }
738739 ],
739740 "required_approvals": 1,
740741 "count_agent_approvals": false,
741− "merge_queue": true
742+ "merge_queue": true,
743+ "require_code_owner_review": true
742744 },
743745 "response": {
744746 "auto_merge": false,
754756 "max_revisions": 2,
755757 "merge_queue": true,
756758 "hold_low_confidence": true,
759+ "require_code_owner_review": true,
757760 "updated_by": "syntaqx",
758761 "updated_at": "2026-10-04T16:20:37.508Z"
759762 },
760− "notes": "`required_checks` replaces the whole list: at most 20 names, each a workflow's name or another status's context, as [`list_check_names`](/reference/api/repositories/list-check-names/) gives them. A pull request merges only once each passes on its head; one nothing has reported holds it too. With the merge queue on, each must also pass on the queued state, so the workflows behind them need `merge_group` in their `on:`. `required_approvals` is at most 6 and `max_revisions` at most 5. See [what a repository can ask for](/guides/working-with-g1t/#what-a-repository-can-ask-for). `hold_low_confidence` is on unless turned off: see [confidence](/guides/working-with-g1t/#how-sure-the-agent-is)."
763+ "notes": "`required_checks` replaces the whole list: at most 20 names, each a workflow's name or another status's context, as [`list_check_names`](/reference/api/repositories/list-check-names/) gives them. A pull request merges only once each passes on its head; one nothing has reported holds it too. With the merge queue on, each must also pass on the queued state, so the workflows behind them need `merge_group` in their `on:`. `required_approvals` is at most 6 and `max_revisions` at most 5. See [what a repository can ask for](/guides/working-with-g1t/#what-a-repository-can-ask-for). `hold_low_confidence` is on unless turned off: see [confidence](/guides/working-with-g1t/#how-sure-the-agent-is). With `require_code_owner_review`, a pull request into the default branch merges only once the code owners of every file it changes have approved it, as many as each section of its CODEOWNERS file asks: see [Code owners](/guides/codeowners/)."
761764 },
762765 "list_check_names": {
763766 "response": [
778781 ],
779782 "notes": "The names reported on the repository's commits in the last 30 days, most recent first. A workflow reports a check named after it (`CI`), for each event it ran for; another tool, such as a deployment, reports its own name (`g1t / deploy`). Empty until something has reported on a commit: add a workflow in `.g1t/workflows` first."
780783 },
784+ "get_codeowners_errors": {
785+ "params": {
786+ "owner": "flagon-io",
787+ "name": "hello"
788+ },
789+ "query": {
790+ "ref": "main"
791+ },
792+ "response": {
793+ "path": ".github/CODEOWNERS",
794+ "ref": "main",
795+ "size": 412,
796+ "rules": 9,
797+ "sections": [
798+ "Docs"
799+ ],
800+ "errors": [
801+ {
802+ "line": 4,
803+ "kind": "unknown_team",
804+ "token": "@flagon-io/platform",
805+ "message": "@flagon-io/platform is not a team of flagon-io."
806+ },
807+ {
808+ "line": 7,
809+ "kind": "negation",
810+ "token": "!docs/internal/",
811+ "message": "!docs/internal/ starts with !, and negation is not supported; this line is skipped. Give the path a later rule with no owners instead."
812+ }
813+ ]
814+ },
815+ "notes": "The file is the first of `.g1t/CODEOWNERS`, `.github/CODEOWNERS`, `CODEOWNERS`, `docs/CODEOWNERS` and `.gitlab/CODEOWNERS` found on `ref` (the default branch when left out); `path` is null when there is none, with no errors. Each error has its `line` (0 for the file as a whole), the `token` at fault, a `message`, and a `kind`:\n\n| `kind` | |\n| --- | --- |\n| `too_large` | The file is over 3 MB and was ignored. |\n| `negation` | A pattern starting with `!`; the line was skipped. |\n| `character_range` | A pattern with `[` or `]`; the line was skipped. |\n| `bad_pattern` | A pattern that names no path; the line was skipped. |\n| `bad_owner` | An owner that is not `@user`, `@workspace/team` or an email address. |\n| `bad_section` | A section header that could not be read. |\n| `unknown_user` | No account has that username. |\n| `unknown_team` | The workspace has no team of that slug. |\n| `unknown_email` | No account has confirmed that address. |\n| `no_write_access` | The person cannot write to the repository. |\n| `team_no_access` | The team has no write access to the repository. |\n\nSee [Code owners](/guides/codeowners/)."
816+ },
781817 "list_events": {
782818 "query": {
783819 "before": "evt_01m43t2a6c9e3g7j1m5q9t3x7b"
849885 "assignees": [],
850886 "blocked_by": [],
851887 "queued": false,
852− "agent": "claude-code"
888+ "agent": "claude-code",
889+ "milestone": null
853890 }
854891 ],
855892 "notes": "Returns at most 100 issues, newest first. `comment_count` counts comments, not events such as \"opened #14 for this\"."
890927 "assignees": [],
891928 "blocked_by": [],
892929 "queued": false,
893− "agent": null
930+ "agent": null,
931+ "milestone": null
894932 },
895933 "notes": "Labels are lowercased. A label not used before is created."
896934 },
924962 "assignees": [],
925963 "blocked_by": [],
926964 "queued": false,
927− "agent": null
965+ "agent": null,
966+ "milestone": null
928967 },
929968 "pulls": [
930969 {
952991 "files": [],
953992 "assignees": [],
954993 "reviewers": [],
994+ "labels": [],
995+ "milestone": null,
996+ "base": "main",
955997 "author": {
956998 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
957999 "username": "syntaqx",
9961038 "reviewers": [
9971039 "ana"
9981040 ],
1041+ "labels": [],
1042+ "milestone": null,
1043+ "base": "main",
9991044 "author": {
10001045 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
10011046 "username": "syntaqx",
10371082 ],
10381083 "assignees": [
10391084 "syntaqx"
1040− ]
1085+ ],
1086+ "milestone": 3
10411087 },
10421088 "response": {
10431089 "id": "iss_01m43shrzpfe49x74ga7sj1c6v",
10701116 ],
10711117 "blocked_by": [],
10721118 "queued": false,
1073− "agent": null
1119+ "agent": null,
1120+ "milestone": {
1121+ "number": 3,
1122+ "title": "Launch"
1123+ }
10741124 }
10751125 },
10761126 "close_issue": {
11051155 "assignees": [],
11061156 "blocked_by": [],
11071157 "queued": false,
1108− "agent": null
1158+ "agent": null,
1159+ "milestone": null
11091160 }
11101161 },
11111162 "reopen_issue": {
11371188 "assignees": [],
11381189 "blocked_by": [],
11391190 "queued": false,
1140− "agent": null
1191+ "agent": null,
1192+ "milestone": null
11411193 }
11421194 },
11431195 "assign_issue": {
11691221 "files": [],
11701222 "assignees": [],
11711223 "reviewers": [],
1224+ "labels": [],
1225+ "milestone": null,
1226+ "base": "main",
11721227 "author": {
11731228 "id": "usr_g1t_agent",
11741229 "username": "g1t",
12211276 "assignees": [],
12221277 "blocked_by": [],
12231278 "queued": false,
1224− "agent": "g1t"
1279+ "agent": "g1t",
1280+ "milestone": null
12251281 },
12261282 "pull": {
12271283 "id": "pr_01m4a2c9b6e0h4m8q2t6x0a4d8",
12481304 "files": [],
12491305 "assignees": [],
12501306 "reviewers": [],
1307+ "labels": [],
1308+ "milestone": null,
1309+ "base": "main",
12511310 "author": {
12521311 "id": "usr_g1t_agent",
12531312 "username": "g1t",
13031362 },
13041363 "list_labels": {
13051364 "response": [
1306− "bug",
1307− "feature",
1308− "docs",
1309− "chore",
1310− "question",
1311− "good first issue"
1365+ {
1366+ "name": "bug",
1367+ "color": "d73a4a",
1368+ "description": "Something isn't working",
1369+ "issues": 4,
1370+ "pulls": 1
1371+ },
1372+ {
1373+ "name": "dependencies",
1374+ "color": "0366d6",
1375+ "description": "Updates a dependency",
1376+ "issues": 0,
1377+ "pulls": 6
1378+ },
1379+ {
1380+ "name": "good first issue",
1381+ "color": "7057ff",
1382+ "description": "Good for newcomers",
1383+ "issues": 2,
1384+ "pulls": 0
1385+ }
13121386 ],
1313− "notes": "The five default labels come first, then the others used on the repository's issues, alphabetically."
1387+ "notes": "By name. A new repository starts with the default labels; add_default_labels adds those an older one is missing."
13141388 },
13151389 "plan_work": {
13161390 "request": {
14791553 "reviewers": [
14801554 "ana"
14811555 ],
1556+ "team_reviewers": [],
14821557 "author": {
14831558 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
14841559 "username": "syntaqx",
14881563 },
14891564 "requested_by": null,
14901565 "created_at": "2026-10-01T18:20:02.117Z",
1491− "updated_at": "2026-10-01T18:35:44.902Z"
1566+ "updated_at": "2026-10-01T18:35:44.902Z",
1567+ "labels": [],
1568+ "milestone": null,
1569+ "base": "main"
14921570 }
14931571 ],
14941572 "notes": "Returns at most 100 pull requests, newest first. `check_status` is `queued`, `running`, `passed`, `failed`, `errored`, or `null` when no checks have run against the head."
15241602 "files": [],
15251603 "assignees": [],
15261604 "reviewers": [],
1605+ "labels": [],
1606+ "milestone": null,
1607+ "base": "main",
15271608 "author": {
15281609 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
15291610 "username": "syntaqx",
15781659 "reviewers": [
15791660 "ana"
15801661 ],
1662+ "team_reviewers": [
1663+ "flagon-io/backend"
1664+ ],
15811665 "author": {
15821666 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
15831667 "username": "syntaqx",
15881672 "requested_by": null,
15891673 "created_at": "2026-10-01T18:20:02.117Z",
15901674 "updated_at": "2026-10-01T18:35:44.902Z",
1591− "confidence": null
1675+ "confidence": null,
1676+ "labels": [],
1677+ "milestone": null,
1678+ "base": "main"
15921679 },
15931680 "issue": {
15941681 "id": "iss_01m43shrzpfe49x74ga7sj1c6v",
16181705 "assignees": [],
16191706 "blocked_by": [],
16201707 "queued": false,
1621− "agent": "claude-code"
1708+ "agent": "claude-code",
1709+ "milestone": null
16221710 },
16231711 "comments": [
16241712 {
16751763 "description": "CI passed",
16761764 "target_url": "https://g1t.sh/syntaqx/hello/actions/runs/run_01m43sw8e2g6j0m4q8t2x6a0c4"
16771765 }
1678− ]
1766+ ],
1767+ "code_owners": {
1768+ "path": ".github/CODEOWNERS",
1769+ "required": true,
1770+ "reviews": [
1771+ {
1772+ "section": null,
1773+ "line": 3,
1774+ "pattern": "/src/",
1775+ "owners": [
1776+ "@flagon-io/backend"
1777+ ],
1778+ "files": [
1779+ "src/main.rs"
1780+ ],
1781+ "optional": false,
1782+ "required": 1,
1783+ "approved_by": [],
1784+ "changes_requested_by": [],
1785+ "satisfied": false
1786+ }
1787+ ],
1788+ "missing": "Code owners have not approved: @flagon-io/backend for /src/.",
1789+ "errors": 0
1790+ }
16791791 },
1680− "notes": "| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `statuses` | Its checks: what each workflow run (or another tool, such as a deployment) reported on its head, as `pending`, `success`, `failure` or `error`, with a link to the run. |\n| `required_checks` | Each check the default branch requires (see [`update_repo_settings`](/reference/api/repositories/update-repo-settings/)), as it stands on the head: `success`, `failure`, `pending`, or `expected` when nothing has reported it yet. It merges only once all are `success`. |\n| `checks` | Set when the merge queue took it out, with why in `error`. Older pull requests may show a run of commands from their issue here, from before checks were workflows. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request g1t is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n\n| `mergeable` | Whether it merges cleanly into its target: `clean`, `conflicting`, `checking` (being worked out) or `unknown`. Worked out ahead of time whenever it or its target moves. |\n| `conflicts` | When `mergeable` is `conflicting`, the files that conflict. Resolve them by merging the target in, or have g1t do it. |\n| `earlier_checks` | Earlier records like `checks`, newest first. |\n\nChecks are the repository's workflows: they run on `pull_request` events when it is opened, marked ready and pushed to. Read why one failed with [`get_workflow_run`](/reference/api/actions/get-workflow-run/) and [`get_job_logs`](/reference/api/actions/get-job-logs/)."
1792+ "notes": "| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `statuses` | Its checks: what each workflow run (or another tool, such as a deployment) reported on its head, as `pending`, `success`, `failure` or `error`, with a link to the run. |\n| `required_checks` | Each check the default branch requires (see [`update_repo_settings`](/reference/api/repositories/update-repo-settings/)), as it stands on the head: `success`, `failure`, `pending`, or `expected` when nothing has reported it yet. It merges only once all are `success`. |\n| `checks` | Set when the merge queue took it out, with why in `error`. Older pull requests may show a run of commands from their issue here, from before checks were workflows. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request g1t is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n| `pull.reviewers`, `pull.team_reviewers` | The people asked to review it, `g1t` among them when a g1t agent was, and the teams, as `workspace/team`. Change them with [`request_reviewers`](/reference/api/pull-requests/request-reviewers/). |\n| `code_owners` | Present when the branch it merges into has a CODEOWNERS file: its `path`, whether code owners' approval is `required` there, `reviews` (one per section and rule that owns a changed file: `section`, `line`, `pattern`, `owners`, `files`, `optional`, the approvals `required`, `approved_by`, `changes_requested_by` and `satisfied`), what is still `missing`, as the merge box says it, and how many `errors` the file has; [`get_codeowners_errors`](/reference/api/repositories/get-codeowners-errors/) lists them. |\n| `mergeable` | Whether it merges cleanly into its target: `clean`, `conflicting`, `checking` (being worked out) or `unknown`. Worked out ahead of time whenever it or its target moves. |\n| `conflicts` | When `mergeable` is `conflicting`, the files that conflict. Resolve them by merging the target in, or have g1t do it. |\n| `earlier_checks` | Earlier records like `checks`, newest first. |\n\nChecks are the repository's workflows: they run on `pull_request` events when it is opened, marked ready and pushed to. Read why one failed with [`get_workflow_run`](/reference/api/actions/get-workflow-run/) and [`get_job_logs`](/reference/api/actions/get-job-logs/)."
16811793 },
16821794 "get_pull_request_changes": {
16831795 "response": {
17611873 ],
17621874 "assignees": [],
17631875 "reviewers": [],
1876+ "labels": [],
1877+ "milestone": null,
1878+ "base": "main",
17641879 "author": {
17651880 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
17661881 "username": "syntaqx",
18001915 "created_at": "2026-10-01T18:40:05.019Z"
18011916 }
18021917 },
1918+ "request_reviewers": {
1919+ "params": {
1920+ "owner": "flagon-io",
1921+ "name": "hello",
1922+ "number": 14
1923+ },
1924+ "request": {
1925+ "reviewers": [
1926+ "bo"
1927+ ],
1928+ "team_reviewers": [
1929+ "backend"
1930+ ]
1931+ },
1932+ "response": {
1933+ "id": "pr_01m43smh3vexsr5pmp60qwv0vs",
1934+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
1935+ "number": 14,
1936+ "issue": 12,
1937+ "title": "Greeting should name the caller",
1938+ "body": "Reads a name from the first argument. `hello ana` prints \"Hello, ana!\".",
1939+ "agent": "claude-code",
1940+ "runtime": "external",
1941+ "status": "open",
1942+ "fork": {
1943+ "namespace": "pulls",
1944+ "name": "pr_01m43smh3vexsr5pmp60qwv0vs"
1945+ },
1946+ "fork_repo_id": "rep_01m43smh5xd7aw2kq9tv0b4c8e",
1947+ "branch": null,
1948+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
1949+ "merge_base": null,
1950+ "merged_by": null,
1951+ "merged_at": null,
1952+ "superseded_by": null,
1953+ "check_status": "passed",
1954+ "files": [
1955+ {
1956+ "path": "src/main.rs",
1957+ "additions": 6,
1958+ "deletions": 2
1959+ }
1960+ ],
1961+ "assignees": [],
1962+ "reviewers": [
1963+ "ana",
1964+ "bo"
1965+ ],
1966+ "team_reviewers": [
1967+ "flagon-io/backend"
1968+ ],
1969+ "author": {
1970+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
1971+ "username": "syntaqx",
1972+ "kind": "user",
1973+ "verified": false,
1974+ "workspaces": []
1975+ },
1976+ "requested_by": null,
1977+ "created_at": "2026-10-01T18:20:02.117Z",
1978+ "updated_at": "2026-10-01T18:40:12.331Z"
1979+ },
1980+ "notes": "Adds to who is asked: `reviewers` by username (`g1t` asks a g1t agent), `team_reviewers` as `workspace/team`, or a team's slug in the repository's workspace. A team with review assignment on has the people it picks added to `reviewers`, and stays in `team_reviewers`. Each is told in their inbox. Nobody is asked to review their own pull request. `422` for someone who is not an account, or a team that does not exist or that you cannot see. Whoever opened it, or the Triage role or higher, while it is open. See [Pull requests](/guides/pull-requests/) and [Teams](/guides/teams/)."
1981+ },
1982+ "remove_requested_reviewers": {
1983+ "params": {
1984+ "owner": "flagon-io",
1985+ "name": "hello",
1986+ "number": 14
1987+ },
1988+ "request": {
1989+ "reviewers": [
1990+ "bo"
1991+ ],
1992+ "team_reviewers": [
1993+ "flagon-io/backend"
1994+ ]
1995+ },
1996+ "response": {
1997+ "id": "pr_01m43smh3vexsr5pmp60qwv0vs",
1998+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
1999+ "number": 14,
2000+ "issue": 12,
2001+ "title": "Greeting should name the caller",
2002+ "body": "Reads a name from the first argument. `hello ana` prints \"Hello, ana!\".",
2003+ "agent": "claude-code",
2004+ "runtime": "external",
2005+ "status": "open",
2006+ "fork": {
2007+ "namespace": "pulls",
2008+ "name": "pr_01m43smh3vexsr5pmp60qwv0vs"
2009+ },
2010+ "fork_repo_id": "rep_01m43smh5xd7aw2kq9tv0b4c8e",
2011+ "branch": null,
2012+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2013+ "merge_base": null,
2014+ "merged_by": null,
2015+ "merged_at": null,
2016+ "superseded_by": null,
2017+ "check_status": "passed",
2018+ "files": [
2019+ {
2020+ "path": "src/main.rs",
2021+ "additions": 6,
2022+ "deletions": 2
2023+ }
2024+ ],
2025+ "assignees": [],
2026+ "reviewers": [
2027+ "ana"
2028+ ],
2029+ "team_reviewers": [],
2030+ "author": {
2031+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2032+ "username": "syntaqx",
2033+ "kind": "user",
2034+ "verified": false,
2035+ "workspaces": []
2036+ },
2037+ "requested_by": null,
2038+ "created_at": "2026-10-01T18:20:02.117Z",
2039+ "updated_at": "2026-10-01T18:41:30.904Z"
2040+ },
2041+ "notes": "Takes them off who is asked; anyone not asked is ignored. Reviews they already gave stay, as do the people a team's review assignment picked: remove them by username."
2042+ },
18032043 "merge_pull_request": {
18042044 "request": {
18052045 "keep_issue_open": false
18372077 "reviewers": [
18382078 "ana"
18392079 ],
2080+ "labels": [],
2081+ "milestone": null,
2082+ "base": "main",
18402083 "author": {
18412084 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
18422085 "username": "syntaqx",
18822125 ],
18832126 "assignees": [],
18842127 "reviewers": [],
2128+ "labels": [],
2129+ "milestone": null,
2130+ "base": "main",
18852131 "author": {
18862132 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
18872133 "username": "syntaqx",
41114357 ],
41124358 "notes": "By username, each with the repositories they have a role on. Refused with `403` for anyone but an owner. See [Access and roles](/guides/access-and-roles/)."
41134359 },
4360+ "list_teams": {
4361+ "params": {
4362+ "workspace": "flagon-io"
4363+ },
4364+ "query": {
4365+ "q": "back"
4366+ },
4367+ "response": [
4368+ {
4369+ "id": "team_01m52k8d3f7h1k5n9r3v7z1c5g",
4370+ "workspace": "flagon-io",
4371+ "slug": "backend",
4372+ "name": "Backend",
4373+ "description": "The API and the services behind it.",
4374+ "visibility": "visible",
4375+ "parent": {
4376+ "slug": "engineering",
4377+ "name": "Engineering"
4378+ },
4379+ "notify": true,
4380+ "review_assignment": {
4381+ "enabled": false,
4382+ "algorithm": "round_robin",
4383+ "count": 1,
4384+ "skip_busy": false,
4385+ "busy_at": 5,
4386+ "include_child_teams": false,
4387+ "excluded": [],
4388+ "notify_team": false
4389+ },
4390+ "members_count": 4,
4391+ "repos_count": 2,
4392+ "child_teams_count": 1,
4393+ "viewer_role": "maintainer",
4394+ "can_manage": true,
4395+ "created_at": "2026-10-06T15:02:11.480Z",
4396+ "updated_at": "2026-10-06T15:02:11.480Z"
4397+ }
4398+ ],
4399+ "notes": "Teams you are in come first, then the rest by name. A secret team is listed only for its own people and the workspace's owners. `review_assignment` is what happens when the team is asked to review: see [`set_team_review_assignment`](/reference/api/teams/set-team-review-assignment/). `403` for anyone who is not a member of the workspace. See [Teams](/guides/teams/)."
4400+ },
4401+ "create_team": {
4402+ "params": {
4403+ "workspace": "flagon-io"
4404+ },
4405+ "request": {
4406+ "name": "Backend",
4407+ "description": "The API and the services behind it.",
4408+ "visibility": "visible",
4409+ "parent": "engineering",
4410+ "members": [
4411+ "ana"
4412+ ]
4413+ },
4414+ "response": {
4415+ "id": "team_01m52k8d3f7h1k5n9r3v7z1c5g",
4416+ "workspace": "flagon-io",
4417+ "slug": "backend",
4418+ "name": "Backend",
4419+ "description": "The API and the services behind it.",
4420+ "visibility": "visible",
4421+ "parent": {
4422+ "slug": "engineering",
4423+ "name": "Engineering"
4424+ },
4425+ "notify": true,
4426+ "review_assignment": {
4427+ "enabled": false,
4428+ "algorithm": "round_robin",
4429+ "count": 1,
4430+ "skip_busy": false,
4431+ "busy_at": 5,
4432+ "include_child_teams": false,
4433+ "excluded": [],
4434+ "notify_team": false
4435+ },
4436+ "members_count": 2,
4437+ "repos_count": 0,
4438+ "child_teams_count": 0,
4439+ "viewer_role": "maintainer",
4440+ "can_manage": true,
4441+ "created_at": "2026-10-06T15:02:11.480Z",
4442+ "updated_at": "2026-10-06T15:02:11.480Z"
4443+ },
4444+ "notes": "You become the team's maintainer. `slug` is made from `name` when left out (`Web & Mobile` becomes `web-mobile`), and `409` says one is taken. A workspace has at most 500 teams, nested at most 8 deep; `name` is at most 80 characters and `description` 280. A `secret` team cannot have a parent or child teams. Refused with `403` for an agent's or a workspace's token. See [Teams](/guides/teams/)."
4445+ },
4446+ "get_team": {
4447+ "params": {
4448+ "workspace": "flagon-io",
4449+ "team": "backend"
4450+ },
4451+ "response": {
4452+ "id": "team_01m52k8d3f7h1k5n9r3v7z1c5g",
4453+ "workspace": "flagon-io",
4454+ "slug": "backend",
4455+ "name": "Backend",
4456+ "description": "The API and the services behind it.",
4457+ "visibility": "visible",
4458+ "parent": {
4459+ "slug": "engineering",
4460+ "name": "Engineering"
4461+ },
4462+ "notify": true,
4463+ "review_assignment": {
4464+ "enabled": false,
4465+ "algorithm": "round_robin",
4466+ "count": 1,
4467+ "skip_busy": false,
4468+ "busy_at": 5,
4469+ "include_child_teams": false,
4470+ "excluded": [],
4471+ "notify_team": false
4472+ },
4473+ "members_count": 4,
4474+ "repos_count": 2,
4475+ "child_teams_count": 1,
4476+ "viewer_role": "maintainer",
4477+ "can_manage": true,
4478+ "created_at": "2026-10-06T15:02:11.480Z",
4479+ "updated_at": "2026-10-06T15:02:11.480Z"
4480+ },
4481+ "notes": "`404` for a team that does not exist, or a secret one you are not in, unless you are an owner."
4482+ },
4483+ "update_team": {
4484+ "params": {
4485+ "workspace": "flagon-io",
4486+ "team": "backend"
4487+ },
4488+ "request": {
4489+ "description": "The API, the services behind it, and their on-call.",
4490+ "visibility": "secret",
4491+ "parent": ""
4492+ },
4493+ "response": {
4494+ "id": "team_01m52k8d3f7h1k5n9r3v7z1c5g",
4495+ "workspace": "flagon-io",
4496+ "slug": "backend",
4497+ "name": "Backend",
4498+ "description": "The API, the services behind it, and their on-call.",
4499+ "visibility": "secret",
4500+ "parent": null,
4501+ "notify": true,
4502+ "review_assignment": {
4503+ "enabled": false,
4504+ "algorithm": "round_robin",
4505+ "count": 1,
4506+ "skip_busy": false,
4507+ "busy_at": 5,
4508+ "include_child_teams": false,
4509+ "excluded": [],
4510+ "notify_team": false
4511+ },
4512+ "members_count": 4,
4513+ "repos_count": 2,
4514+ "child_teams_count": 1,
4515+ "viewer_role": "maintainer",
4516+ "can_manage": true,
4517+ "created_at": "2026-10-06T15:02:11.480Z",
4518+ "updated_at": "2026-10-07T09:41:52.006Z"
4519+ },
4520+ "notes": "Only the fields given change. `parent` set to `\"\"` takes the team out from under its parent; a team cannot be nested under itself or one of its own child teams. A new `slug` changes how it is mentioned: `@flagon-io/backend` no longer reaches it. `review_assignment` takes the fields [`set_team_review_assignment`](/reference/api/teams/set-team-review-assignment/) does. Owners of the workspace and the team's maintainers (`403` otherwise)."
4521+ },
4522+ "delete_team": {
4523+ "params": {
4524+ "workspace": "flagon-io",
4525+ "team": "backend"
4526+ },
4527+ "response": true,
4528+ "notes": "Its child teams move up to its parent, or to the top when it has none. The roles it gave on repositories are taken away, and it is no longer asked to review. Owners of the workspace and the team's maintainers (`403` otherwise)."
4529+ },
4530+ "list_team_members": {
4531+ "params": {
4532+ "workspace": "flagon-io",
4533+ "team": "backend"
4534+ },
4535+ "query": {
4536+ "include_child_teams": "true"
4537+ },
4538+ "response": [
4539+ {
4540+ "username": "syntaqx",
4541+ "name": "Chase Pierce",
4542+ "avatar": null,
4543+ "role": "maintainer",
4544+ "via": null
4545+ },
4546+ {
4547+ "username": "ana",
4548+ "name": "Ana Lima",
4549+ "avatar": null,
4550+ "role": "member",
4551+ "via": null
4552+ },
4553+ {
4554+ "username": "bo",
4555+ "name": null,
4556+ "avatar": null,
4557+ "role": "member",
4558+ "via": "payments"
4559+ }
4560+ ],
4561+ "notes": "Maintainers come first, then members, each by username. With `include_child_teams`, the people of its child teams (and theirs) follow, each with `via`, the child team they are in; someone in both is listed once, as the team's own."
4562+ },
4563+ "set_team_member": {
4564+ "params": {
4565+ "workspace": "flagon-io",
4566+ "team": "backend",
4567+ "username": "ana"
4568+ },
4569+ "request": {
4570+ "role": "maintainer"
4571+ },
4572+ "response": {
4573+ "username": "ana",
4574+ "name": "Ana Lima",
4575+ "avatar": null,
4576+ "role": "maintainer",
4577+ "via": null
4578+ },
4579+ "notes": "`role` is `member` (the default) or `maintainer`. `422` when they are not a member of the workspace: add them to it first. Owners of the workspace and the team's maintainers (`403` otherwise)."
4580+ },
4581+ "remove_team_member": {
4582+ "params": {
4583+ "workspace": "flagon-io",
4584+ "team": "backend",
4585+ "username": "ana"
4586+ },
4587+ "response": true,
4588+ "notes": "Anyone may take themselves out of a team. Leaving the workspace takes a person out of all its teams."
4589+ },
4590+ "list_child_teams": {
4591+ "params": {
4592+ "workspace": "flagon-io",
4593+ "team": "engineering"
4594+ },
4595+ "response": [
4596+ {
4597+ "id": "team_01m52k8d3f7h1k5n9r3v7z1c5g",
4598+ "workspace": "flagon-io",
4599+ "slug": "backend",
4600+ "name": "Backend",
4601+ "description": "The API and the services behind it.",
4602+ "visibility": "visible",
4603+ "parent": {
4604+ "slug": "engineering",
4605+ "name": "Engineering"
4606+ },
4607+ "notify": true,
4608+ "review_assignment": {
4609+ "enabled": false,
4610+ "algorithm": "round_robin",
4611+ "count": 1,
4612+ "skip_busy": false,
4613+ "busy_at": 5,
4614+ "include_child_teams": false,
4615+ "excluded": [],
4616+ "notify_team": false
4617+ },
4618+ "members_count": 4,
4619+ "repos_count": 2,
4620+ "child_teams_count": 0,
4621+ "viewer_role": "maintainer",
4622+ "can_manage": true,
4623+ "created_at": "2026-10-06T15:02:11.480Z",
4624+ "updated_at": "2026-10-06T15:02:11.480Z"
4625+ }
4626+ ],
4627+ "notes": "Only the teams directly under it; read each one's own with this again. A child team inherits its parent's roles on repositories, and a mention or review request for the parent reaches its people too."
4628+ },
4629+ "list_team_repos": {
4630+ "params": {
4631+ "workspace": "flagon-io",
4632+ "team": "backend"
4633+ },
4634+ "response": [
4635+ {
4636+ "repo": "flagon-io/hello",
4637+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
4638+ "role": "write",
4639+ "inherited_from": null
4640+ },
4641+ {
4642+ "repo": "flagon-io/docs",
4643+ "repo_id": "rep_01m3m5r2a8c4e6g8j0m2p4r6t8",
4644+ "role": "read",
4645+ "inherited_from": "engineering"
4646+ }
4647+ ],
4648+ "notes": "A role inherited from a parent team names it in `inherited_from`. Where the team has a role of its own on the same repository, the higher one is listed. Only repositories you can see are listed."
4649+ },
4650+ "set_team_repo": {
4651+ "params": {
4652+ "workspace": "flagon-io",
4653+ "team": "backend",
4654+ "repo": "hello"
4655+ },
4656+ "request": {
4657+ "role": "maintain"
4658+ },
4659+ "response": {
4660+ "repo": "flagon-io/hello",
4661+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
4662+ "role": "maintain",
4663+ "inherited_from": null
4664+ },
4665+ "notes": "`repo` in the path is the repository's name in the team's workspace; a team has roles only on its own workspace's repositories. `role` is `read`, `triage`, `write`, `maintain` or `admin`. Everyone in the team and its child teams gets it; someone with a higher role otherwise keeps that. Needs the Admin role on the repository (`403` otherwise), and the `access:admin` scope. See [Access and roles](/guides/access-and-roles/)."
4666+ },
4667+ "remove_team_repo": {
4668+ "params": {
4669+ "workspace": "flagon-io",
4670+ "team": "backend",
4671+ "repo": "hello"
4672+ },
4673+ "response": true,
4674+ "notes": "A role the team inherits from a parent is taken away on the parent. Needs the Admin role on the repository, or to be an owner or one of the team's maintainers."
4675+ },
4676+ "set_team_review_assignment": {
4677+ "params": {
4678+ "workspace": "flagon-io",
4679+ "team": "backend"
4680+ },
4681+ "request": {
4682+ "enabled": true,
4683+ "algorithm": "load_balance",
4684+ "count": 2,
4685+ "skip_busy": true,
4686+ "busy_at": 5,
4687+ "excluded": [
4688+ "syntaqx"
4689+ ]
4690+ },
4691+ "response": {
4692+ "id": "team_01m52k8d3f7h1k5n9r3v7z1c5g",
4693+ "workspace": "flagon-io",
4694+ "slug": "backend",
4695+ "name": "Backend",
4696+ "description": "The API and the services behind it.",
4697+ "visibility": "visible",
4698+ "parent": {
4699+ "slug": "engineering",
4700+ "name": "Engineering"
4701+ },
4702+ "notify": true,
4703+ "review_assignment": {
4704+ "enabled": true,
4705+ "algorithm": "load_balance",
4706+ "count": 2,
4707+ "skip_busy": true,
4708+ "busy_at": 5,
4709+ "include_child_teams": false,
4710+ "excluded": [
4711+ "syntaqx"
4712+ ],
4713+ "notify_team": false
4714+ },
4715+ "members_count": 4,
4716+ "repos_count": 2,
4717+ "child_teams_count": 1,
4718+ "viewer_role": "maintainer",
4719+ "can_manage": true,
4720+ "created_at": "2026-10-06T15:02:11.480Z",
4721+ "updated_at": "2026-10-07T09:44:03.512Z"
4722+ },
4723+ "notes": "| Field | |\n| --- | --- |\n| `enabled` | Off, everyone in the team is asked. On, `count` people are picked and asked, and the team stays shown as asked beside them. |\n| `algorithm` | `round_robin`: whoever this team asked least recently. `load_balance`: whoever has the fewest pull requests waiting on their review. |\n| `count` | How many to pick, 1 to 10. People from the team already asked count towards it. |\n| `skip_busy`, `busy_at` | Leave out anyone with `busy_at` (1 to 100) or more pull requests waiting on their review. |\n| `include_child_teams` | Also pick from its child teams' people. |\n| `excluded` | Usernames never picked. Replaces the whole list. |\n| `notify_team` | Also tell the rest of the team when people are picked. |\n\nFields left out keep their value. The pull request's author is never picked. See [Teams](/guides/teams/)."
4724+ },
4725+ "list_user_teams": {
4726+ "params": {
4727+ "workspace": "flagon-io",
4728+ "username": "ana"
4729+ },
4730+ "response": [
4731+ {
4732+ "id": "team_01m52k8d3f7h1k5n9r3v7z1c5g",
4733+ "workspace": "flagon-io",
4734+ "slug": "backend",
4735+ "name": "Backend",
4736+ "description": "The API and the services behind it.",
4737+ "visibility": "visible",
4738+ "parent": {
4739+ "slug": "engineering",
4740+ "name": "Engineering"
4741+ },
4742+ "notify": true,
4743+ "review_assignment": {
4744+ "enabled": false,
4745+ "algorithm": "round_robin",
4746+ "count": 1,
4747+ "skip_busy": false,
4748+ "busy_at": 5,
4749+ "include_child_teams": false,
4750+ "excluded": [],
4751+ "notify_team": false
4752+ },
4753+ "members_count": 4,
4754+ "repos_count": 2,
4755+ "child_teams_count": 1,
4756+ "viewer_role": null,
4757+ "can_manage": false,
4758+ "created_at": "2026-10-06T15:02:11.480Z",
4759+ "updated_at": "2026-10-06T15:02:11.480Z"
4760+ }
4761+ ],
4762+ "notes": "Only the teams they are in themselves, not the parents those teams are under. Secret teams you are not in are left out unless you are an owner. `403` for anyone who is not a member of the workspace."
4763+ },
41144764 "list_security_alerts": {
41154765 "params": {
41164766 "owner": "flagon-io",
49845634 }
49855635 ]
49865636 },
5637+ "get_usage": {
5638+ "params": {
5639+ "workspace": "flagon-io"
5640+ },
5641+ "query": {
5642+ "from": "2026-10-01",
5643+ "until": "2026-10-07",
5644+ "group_by": "project"
5645+ },
5646+ "response": {
5647+ "from": "2026-10-01",
5648+ "until": "2026-10-07",
5649+ "totals": {
5650+ "price_micros": 48210000,
5651+ "discount_micros": 0,
5652+ "included_micros": 20000000,
5653+ "credits_micros": 18000000,
5654+ "charged_micros": 10210000,
5655+ "pending_micros": 1340000,
5656+ "cost_micros": 40100000
5657+ },
5658+ "days": [
5659+ {
5660+ "day": "2026-10-06",
5661+ "product": "agent",
5662+ "micros": 12400000
5663+ },
5664+ {
5665+ "day": "2026-10-06",
5666+ "product": "sandboxes",
5667+ "micros": 2100000
5668+ },
5669+ {
5670+ "day": "2026-10-07",
5671+ "product": "agent",
5672+ "micros": 9800000
5673+ }
5674+ ],
5675+ "products": [
5676+ {
5677+ "key": "agent",
5678+ "label": "Agent",
5679+ "micros": 41000000,
5680+ "meters": [
5681+ {
5682+ "key": "agent_models",
5683+ "label": "Models",
5684+ "product": "agent",
5685+ "unit": "tokens",
5686+ "quantity": 9120000,
5687+ "micros": 41000000,
5688+ "pending_micros": 0,
5689+ "daily": [
5690+ 3100000,
5691+ 5200000,
5692+ 4400000,
5693+ 6000000,
5694+ 0,
5695+ 12400000,
5696+ 9800000
5697+ ],
5698+ "allowance": null,
5699+ "by_project": [
5700+ {
5701+ "project": "flagon-io/g1t",
5702+ "micros": 36000000,
5703+ "quantity": 8010000
5704+ },
5705+ {
5706+ "project": "flagon-io/hello",
5707+ "micros": 5000000,
5708+ "quantity": 1110000
5709+ }
5710+ ]
5711+ }
5712+ ],
5713+ "features": [
5714+ {
5715+ "key": "runs",
5716+ "label": "Runs",
5717+ "micros": 33000000,
5718+ "count": 14
5719+ },
5720+ {
5721+ "key": "reviews",
5722+ "label": "Reviews",
5723+ "micros": 8000000,
5724+ "count": 9
5725+ }
5726+ ]
5727+ },
5728+ {
5729+ "key": "sandboxes",
5730+ "label": "Sandboxes",
5731+ "micros": 7210000,
5732+ "meters": [
5733+ {
5734+ "key": "sandbox",
5735+ "label": "Sandbox time",
5736+ "product": "sandboxes",
5737+ "unit": "seconds",
5738+ "quantity": 41300,
5739+ "micros": 7210000,
5740+ "pending_micros": 0,
5741+ "daily": [
5742+ 900000,
5743+ 1200000,
5744+ 800000,
5745+ 1100000,
5746+ 0,
5747+ 2100000,
5748+ 1110000
5749+ ],
5750+ "allowance": {
5751+ "used": 41300,
5752+ "of": 108000,
5753+ "unit": "seconds"
5754+ },
5755+ "by_project": [
5756+ {
5757+ "project": "flagon-io/g1t",
5758+ "micros": 7210000,
5759+ "quantity": 41300
5760+ }
5761+ ]
5762+ }
5763+ ],
5764+ "features": []
5765+ }
5766+ ],
5767+ "projects": [
5768+ "flagon-io/g1t",
5769+ "flagon-io/hello"
5770+ ],
5771+ "included": {
5772+ "used": 20,
5773+ "of": 20,
5774+ "unit": "dollars"
5775+ },
5776+ "discount_percent": null,
5777+ "ai_credit_micros": 62000000,
5778+ "credit_micros": 0,
5779+ "trial_micros": null,
5780+ "plan": "pro",
5781+ "free": false,
5782+ "group_by": "project",
5783+ "groups": [
5784+ {
5785+ "key": "flagon-io/g1t",
5786+ "micros": 43210000
5787+ },
5788+ {
5789+ "key": "flagon-io/hello",
5790+ "micros": 5000000
5791+ }
5792+ ]
5793+ },
5794+ "notes": "Every product family is listed, in order, even with nothing used; this example shows two. `daily` has one amount for each day of the range, oldest first. `projects` and `products` take several values separated by commas: `?products=agent,sandboxes`. A range of more than 400 days, or one that ends before it starts, is refused with `invalid`."
5795+ },
5796+ "get_budget": {
5797+ "params": {
5798+ "workspace": "flagon-io"
5799+ },
5800+ "response": {
5801+ "workspace": "flagon-io",
5802+ "amount_micros": 500000000,
5803+ "automatic": false,
5804+ "spent_micros": 132450000,
5805+ "max_amount_micros": 2000000000,
5806+ "alerts": [
5807+ 50,
5808+ 75,
5809+ 90,
5810+ 100
5811+ ],
5812+ "pause_at_limit": true,
5813+ "webhook": "https://ops.example.com/g1t/budget",
5814+ "state": "ok",
5815+ "message": null
5816+ },
5817+ "notes": "All amounts are whole millionths of a dollar: 500000000 is $500. `max_amount_micros` is null for g1t's own workspaces, which have no ceiling. When `state` is `stopped`, new sandboxes, builds and app requests wait until the limit is raised or the month closes."
5818+ },
5819+ "set_budget": {
5820+ "params": {
5821+ "workspace": "flagon-io"
5822+ },
5823+ "request": {
5824+ "amount_micros": 500000000,
5825+ "alerts": [
5826+ 50,
5827+ 75,
5828+ 90,
5829+ 100
5830+ ],
5831+ "webhook": "https://ops.example.com/g1t/budget"
5832+ },
5833+ "response": {
5834+ "workspace": "flagon-io",
5835+ "amount_micros": 500000000,
5836+ "automatic": false,
5837+ "spent_micros": 132450000,
5838+ "max_amount_micros": 2000000000,
5839+ "alerts": [
5840+ 50,
5841+ 75,
5842+ 90,
5843+ 100
5844+ ],
5845+ "pause_at_limit": true,
5846+ "webhook": "https://ops.example.com/g1t/budget",
5847+ "state": "ok",
5848+ "message": null
5849+ },
5850+ "notes": "Fields left out keep their value. A limit above `max_amount_micros` is refused with `invalid`. Called by anyone but an owner signed in as a person, or by a workspace's own token or one of g1t's agents, it is refused with `forbidden`. Each alert is sent once a month, to the owners by email and, with `webhook`, as a JSON POST."
5851+ },
5852+ "get_ai_credit": {
5853+ "params": {
5854+ "workspace": "flagon-io"
5855+ },
5856+ "response": {
5857+ "balance_micros": 62000000,
5858+ "purchased_micros": 50000000,
5859+ "given_micros": 12000000,
5860+ "grants": [
5861+ {
5862+ "id": "crd_01kkr2m4c8f1t7qh3d6n9w5p0x",
5863+ "workspace": "flagon-io",
5864+ "kind": "purchase",
5865+ "amount_micros": 50000000,
5866+ "used_micros": 0,
5867+ "left_micros": 50000000,
5868+ "note": "AI credit bought",
5869+ "refund_for": null,
5870+ "refund_day": null,
5871+ "expires_at": "2027-10-02T00:00:00.000Z",
5872+ "created_by": "ana",
5873+ "created_at": "2026-10-02T16:20:00.000Z",
5874+ "state": "open",
5875+ "closed_at": null,
5876+ "closed_note": null,
5877+ "closed_by": null
5878+ }
5879+ ],
5880+ "free_via_discount": false,
5881+ "postpaid": false,
5882+ "blocked": false,
5883+ "can_buy": true,
5884+ "presets_cents": [
5885+ 2500,
5886+ 5000,
5887+ 10000,
5888+ 25000
5889+ ],
5890+ "min_cents": 1000,
5891+ "max_cents": 100000,
5892+ "card_fee": {
5893+ "on": true,
5894+ "percent_micros": 29000,
5895+ "fixed_cents": 30
5896+ },
5897+ "reload": {
5898+ "enabled": false,
5899+ "threshold_micros": 10000000,
5900+ "target_micros": 50000000,
5901+ "monthly_max_micros": 200000000,
5902+ "reloaded_micros": 0,
5903+ "failed_at": null,
5904+ "error": null
5905+ },
5906+ "agent_rate_micros": 3.6,
5907+ "model_markup_percent": 10,
5908+ "gateway_markup_percent": 5,
5909+ "upgrade_credit_micros": 10000000,
5910+ "expires_days": 365
5911+ },
5912+ "notes": "`card_fee.percent_micros` is per dollar charged, in millionths: 29000 is 2.9%. `blocked` is true when the credit has run out and new runs on g1t's models wait for more; runs on a model provider the workspace connected itself never need it."
5913+ },
5914+ "buy_ai_credit": {
5915+ "params": {
5916+ "workspace": "flagon-io"
5917+ },
5918+ "request": {
5919+ "amount_cents": 5000
5920+ },
5921+ "response": {
5922+ "url": "https://checkout.example.com/c/pay/cs_test_a1b2c3"
5923+ },
5924+ "notes": "Open `url` in a browser to pay. Nothing is charged until the payment is made there; the credit then shows in get_ai_credit. An amount outside `min_cents` to `max_cents`, or not in whole dollars, is refused with `invalid`; a workspace that cannot buy credit (see `can_buy`) gets `conflict` or `payment_required`."
5925+ },
5926+ "list_invoices": {
5927+ "params": {
5928+ "workspace": "flagon-io"
5929+ },
5930+ "response": {
5931+ "invoices": [
5932+ {
5933+ "id": "in_1Q2w3E4r5T6y7U8i",
5934+ "number": "FLAGON-0004",
5935+ "status": "paid",
5936+ "total_cents": 4210,
5937+ "currency": "usd",
5938+ "created_at": "2026-10-01T00:05:00.000Z",
5939+ "description": "Usage for 2026-09",
5940+ "hosted_url": "https://invoice.example.com/i/acct_1/in_1Q2w3E4r5T6y7U8i",
5941+ "pdf_url": "https://invoice.example.com/i/acct_1/in_1Q2w3E4r5T6y7U8i/pdf"
5942+ }
5943+ ],
5944+ "usage_invoices": [
5945+ {
5946+ "id": "inv_01kkqz8d3c6f9t2wq5n8h1m4r7",
5947+ "workspace": "flagon-io",
5948+ "reason": "month",
5949+ "period": "2026-09",
5950+ "amount_micros": 42100000,
5951+ "status": "paid",
5952+ "hosted_url": "https://invoice.example.com/i/acct_1/in_1Q2w3E4r5T6y7U8i",
5953+ "pdf_url": "https://invoice.example.com/i/acct_1/in_1Q2w3E4r5T6y7U8i/pdf",
5954+ "lines": [
5955+ {
5956+ "description": "Agent: models",
5957+ "amount_micros": 35900000
5958+ },
5959+ {
5960+ "description": "Sandbox time",
5961+ "amount_micros": 6200000
5962+ }
5963+ ],
5964+ "created_at": "2026-10-01T00:05:00.000Z"
5965+ }
5966+ ],
5967+ "upcoming": {
5968+ "closes_at": "2026-11-01T00:00:00.000Z",
5969+ "subscriptions_micros": 20000000,
5970+ "usage_micros": 10210000,
5971+ "total_micros": 30210000
5972+ },
5973+ "unavailable": null
5974+ },
5975+ "notes": "`invoices` are in cents (`total_cents`); `usage_invoices` and `upcoming` in millionths of a dollar. A usage invoice's `reason` is `month` (the month closed) or `threshold` (charged near the limit), and its `status` `paid`, `open`, `failed` or `void`. An invoice's `status` is `paid`, `open`, `void`, `uncollectible` or `draft`."
5976+ },
5977+ "get_billing_details": {
5978+ "params": {
5979+ "workspace": "flagon-io"
5980+ },
5981+ "response": {
5982+ "customer": true,
5983+ "email": "billing@flagon.example.com",
5984+ "name": "Flagon, Inc.",
5985+ "address": {
5986+ "line1": "100 Market Street",
5987+ "line2": "",
5988+ "city": "San Francisco",
5989+ "state": "CA",
5990+ "postal_code": "94105",
5991+ "country": "US"
5992+ },
5993+ "tax_id_type": "us_ein",
5994+ "tax_id": "12-3456789",
5995+ "po_number": null,
5996+ "language": "en",
5997+ "payment_method": {
5998+ "kind": "card",
5999+ "brand": "visa",
6000+ "last4": "4242",
6001+ "exp_month": 12,
6002+ "exp_year": 2028
6003+ }
6004+ },
6005+ "notes": "Owners change these on the workspace's billing page. `payment_method` is null until a card or other way to pay is saved."
6006+ },
49876007 "list_pinned_projects": {
49886008 "params": {
49896009 "workspace": "flagon-io"
51806200 }
51816201 ],
51826202 "notes": "Name every pinned project once; anything else is refused with `422 invalid`."
6203+ },
6204+ "list_secret_scanning_alerts": {
6205+ "params": {
6206+ "owner": "flagon-io",
6207+ "name": "hello"
6208+ },
6209+ "query": {
6210+ "state": "open"
6211+ },
6212+ "response": [
6213+ {
6214+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p",
6215+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6216+ "kind": "github_token",
6217+ "label": "a GitHub token",
6218+ "path": "scripts/release.sh",
6219+ "line": 12,
6220+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6221+ "preview": "ghp_X7…",
6222+ "status": "open",
6223+ "source": "push",
6224+ "found_by": "syntaqx",
6225+ "found_at": "2026-10-06T09:14:02.118Z",
6226+ "decided_by": null,
6227+ "reason": null,
6228+ "decided_at": null,
6229+ "dismissed_reason": null,
6230+ "test_value": null,
6231+ "state": "open",
6232+ "validity": "active",
6233+ "validity_checked_at": "2026-10-06T09:20:41.502Z",
6234+ "bypass": {
6235+ "reason": "will_fix_later",
6236+ "comment": "Rotating it this afternoon.",
6237+ "by": "syntaqx",
6238+ "at": "2026-10-06T09:14:02.118Z",
6239+ "approved_by": null
6240+ },
6241+ "pattern_id": null,
6242+ "pattern_name": null,
6243+ "locations": 1
6244+ }
6245+ ],
6246+ "notes": "| Filter | Values |\n| --- | --- |\n| `state` | `open` (in the history, or blocked at a push), `dismissed`, `fixed` |\n| `secret_type` | `aws_access_key`, `github_token`, `custom_pattern`, … |\n| `validity` | `active`, `inactive`, `unknown`, `unsupported` |\n| `bypassed` | `true` or `false` |\n\nThe secret itself is never returned: `preview` is enough to recognise it. `status` says where it stands: `open`, `blocked` (stopped at a push, never landed), `allowed` or `resolved`."
6247+ },
6248+ "list_secret_scanning_alerts_for_workspace": {
6249+ "params": {
6250+ "workspace": "flagon-io"
6251+ },
6252+ "query": {
6253+ "state": "open"
6254+ },
6255+ "response": [
6256+ {
6257+ "repo": "hello",
6258+ "secret": {
6259+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p",
6260+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6261+ "kind": "github_token",
6262+ "label": "a GitHub token",
6263+ "path": "scripts/release.sh",
6264+ "line": 12,
6265+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6266+ "preview": "ghp_X7…",
6267+ "status": "open",
6268+ "source": "push",
6269+ "found_by": "syntaqx",
6270+ "found_at": "2026-10-06T09:14:02.118Z",
6271+ "decided_by": null,
6272+ "reason": null,
6273+ "decided_at": null,
6274+ "dismissed_reason": null,
6275+ "test_value": null,
6276+ "state": "open",
6277+ "validity": "active",
6278+ "validity_checked_at": "2026-10-06T09:20:41.502Z",
6279+ "bypass": {
6280+ "reason": "will_fix_later",
6281+ "comment": "Rotating it this afternoon.",
6282+ "by": "syntaqx",
6283+ "at": "2026-10-06T09:14:02.118Z",
6284+ "approved_by": null
6285+ },
6286+ "pattern_id": null,
6287+ "pattern_name": null,
6288+ "locations": 1
6289+ }
6290+ }
6291+ ],
6292+ "notes": "Every repository whose findings you may see, each alert with its repository's name."
6293+ },
6294+ "get_secret_scanning_alert": {
6295+ "params": {
6296+ "owner": "flagon-io",
6297+ "name": "hello",
6298+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p"
6299+ },
6300+ "response": {
6301+ "secret": {
6302+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p",
6303+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6304+ "kind": "github_token",
6305+ "label": "a GitHub token",
6306+ "path": "scripts/release.sh",
6307+ "line": 12,
6308+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6309+ "preview": "ghp_X7…",
6310+ "status": "blocked",
6311+ "source": "push",
6312+ "found_by": "syntaqx",
6313+ "found_at": "2026-10-06T09:14:02.118Z",
6314+ "decided_by": null,
6315+ "reason": null,
6316+ "decided_at": null,
6317+ "dismissed_reason": null,
6318+ "test_value": null,
6319+ "state": "open",
6320+ "validity": null,
6321+ "validity_checked_at": null,
6322+ "bypass": null,
6323+ "pattern_id": null,
6324+ "pattern_name": null,
6325+ "locations": 1
6326+ },
6327+ "locations": [
6328+ {
6329+ "path": "scripts/release.sh",
6330+ "line": 12,
6331+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6332+ "source": "push",
6333+ "found_at": "2026-10-06T09:14:02.118Z"
6334+ }
6335+ ],
6336+ "activity": [],
6337+ "requests": [],
6338+ "checkable": true,
6339+ "can_bypass": true,
6340+ "can_request_bypass": false
6341+ }
6342+ },
6343+ "update_secret_scanning_alert": {
6344+ "params": {
6345+ "owner": "flagon-io",
6346+ "name": "hello",
6347+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p"
6348+ },
6349+ "request": {
6350+ "state": "dismissed",
6351+ "reason": "revoked",
6352+ "comment": "Rotated in the issuer's settings."
6353+ },
6354+ "response": {
6355+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p",
6356+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6357+ "kind": "github_token",
6358+ "label": "a GitHub token",
6359+ "path": "scripts/release.sh",
6360+ "line": 12,
6361+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6362+ "preview": "ghp_X7…",
6363+ "status": "resolved",
6364+ "source": "push",
6365+ "found_by": "syntaqx",
6366+ "found_at": "2026-10-06T09:14:02.118Z",
6367+ "decided_by": "syntaqx",
6368+ "reason": "Rotated in the issuer's settings.",
6369+ "decided_at": "2026-10-06T09:20:41.502Z",
6370+ "dismissed_reason": "revoked",
6371+ "test_value": null,
6372+ "state": "fixed",
6373+ "validity": "active",
6374+ "validity_checked_at": "2026-10-06T09:20:41.502Z",
6375+ "bypass": {
6376+ "reason": "will_fix_later",
6377+ "comment": "Rotating it this afternoon.",
6378+ "by": "syntaqx",
6379+ "at": "2026-10-06T09:14:02.118Z",
6380+ "approved_by": null
6381+ },
6382+ "pattern_id": null,
6383+ "pattern_name": null,
6384+ "locations": 1
6385+ },
6386+ "notes": "Takes the Admin role: a dismissed secret is let through push protection, unless it was `revoked`, which marks it fixed. `state` `open` reopens it."
6387+ },
6388+ "list_secret_scanning_locations": {
6389+ "params": {
6390+ "owner": "flagon-io",
6391+ "name": "hello",
6392+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p"
6393+ },
6394+ "response": [
6395+ {
6396+ "path": "scripts/release.sh",
6397+ "line": 12,
6398+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6399+ "source": "push",
6400+ "found_at": "2026-10-06T09:14:02.118Z"
6401+ }
6402+ ]
6403+ },
6404+ "bypass_push_protection": {
6405+ "params": {
6406+ "owner": "flagon-io",
6407+ "name": "hello",
6408+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p"
6409+ },
6410+ "request": {
6411+ "reason": "will_fix_later",
6412+ "comment": "Rotating it this afternoon."
6413+ },
6414+ "response": {
6415+ "secret": {
6416+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p",
6417+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6418+ "kind": "github_token",
6419+ "label": "a GitHub token",
6420+ "path": "scripts/release.sh",
6421+ "line": 12,
6422+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6423+ "preview": "ghp_X7…",
6424+ "status": "open",
6425+ "source": "push",
6426+ "found_by": "syntaqx",
6427+ "found_at": "2026-10-06T09:14:02.118Z",
6428+ "decided_by": null,
6429+ "reason": null,
6430+ "decided_at": null,
6431+ "dismissed_reason": null,
6432+ "test_value": null,
6433+ "state": "open",
6434+ "validity": null,
6435+ "validity_checked_at": null,
6436+ "bypass": {
6437+ "reason": "will_fix_later",
6438+ "comment": "Rotating it this afternoon.",
6439+ "by": "syntaqx",
6440+ "at": "2026-10-06T09:14:02.118Z",
6441+ "approved_by": null
6442+ },
6443+ "pattern_id": null,
6444+ "pattern_name": null,
6445+ "locations": 1
6446+ },
6447+ "request": null
6448+ },
6449+ "notes": "| Reason | The alert |\n| --- | --- |\n| `false_positive` | Closed as a false positive |\n| `used_in_tests` | Closed as used in tests |\n| `will_fix_later` | Stays open, to be rotated |\n\nWith delegated bypass on, a call from someone who does not review bypasses makes a request instead: `request` is set and the secret is still blocked until an owner or admin approves it. Push again once it is bypassed."
6450+ },
6451+ "check_secret_validity": {
6452+ "params": {
6453+ "owner": "flagon-io",
6454+ "name": "hello",
6455+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p"
6456+ },
6457+ "response": {
6458+ "id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p",
6459+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6460+ "kind": "github_token",
6461+ "label": "a GitHub token",
6462+ "path": "scripts/release.sh",
6463+ "line": 12,
6464+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6465+ "preview": "ghp_X7…",
6466+ "status": "open",
6467+ "source": "push",
6468+ "found_by": "syntaqx",
6469+ "found_at": "2026-10-06T09:14:02.118Z",
6470+ "decided_by": null,
6471+ "reason": null,
6472+ "decided_at": null,
6473+ "dismissed_reason": null,
6474+ "test_value": null,
6475+ "state": "open",
6476+ "validity": "active",
6477+ "validity_checked_at": "2026-10-06T09:20:41.502Z",
6478+ "bypass": {
6479+ "reason": "will_fix_later",
6480+ "comment": "Rotating it this afternoon.",
6481+ "by": "syntaqx",
6482+ "at": "2026-10-06T09:14:02.118Z",
6483+ "approved_by": null
6484+ },
6485+ "pattern_id": null,
6486+ "pattern_name": null,
6487+ "locations": 1
6488+ },
6489+ "notes": "Made for GitHub, GitLab, Stripe, Slack, npm, OpenAI, Anthropic and SendGrid tokens, with the issuer's own read-only call. Other formats answer `unsupported`; a secret that never landed answers `unknown`."
6490+ },
6491+ "list_bypass_requests": {
6492+ "params": {
6493+ "workspace": "flagon-io"
6494+ },
6495+ "query": {
6496+ "state": "pending"
6497+ },
6498+ "response": [
6499+ {
6500+ "id": "byp_01kq2n8e5f6g7h8j9k0m1n2p3q",
6501+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6502+ "workspace": "flagon-io",
6503+ "repo": "hello",
6504+ "secret_id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p",
6505+ "label": "a GitHub token",
6506+ "path": "scripts/release.sh",
6507+ "line": 12,
6508+ "preview": "ghp_X7…",
6509+ "requester": "ana",
6510+ "reason": "used_in_tests",
6511+ "comment": "A token from the test fixtures, never issued.",
6512+ "state": "pending",
6513+ "reviewer": null,
6514+ "review_comment": null,
6515+ "created_at": "2026-10-06T09:14:02.118Z",
6516+ "reviewed_at": null
6517+ }
6518+ ]
6519+ },
6520+ "review_bypass_request": {
6521+ "params": {
6522+ "workspace": "flagon-io",
6523+ "id": "byp_01kq2n8e5f6g7h8j9k0m1n2p3q"
6524+ },
6525+ "request": {
6526+ "decision": "approve",
6527+ "comment": "A fixture."
6528+ },
6529+ "response": {
6530+ "id": "byp_01kq2n8e5f6g7h8j9k0m1n2p3q",
6531+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6532+ "workspace": "flagon-io",
6533+ "repo": "hello",
6534+ "secret_id": "sec_01kq2m7d4e5f6g7h8j9k0m1n2p",
6535+ "label": "a GitHub token",
6536+ "path": "scripts/release.sh",
6537+ "line": 12,
6538+ "preview": "ghp_X7…",
6539+ "requester": "ana",
6540+ "reason": "used_in_tests",
6541+ "comment": "A token from the test fixtures, never issued.",
6542+ "state": "approved",
6543+ "reviewer": "syntaqx",
6544+ "review_comment": "A fixture.",
6545+ "created_at": "2026-10-06T09:14:02.118Z",
6546+ "reviewed_at": "2026-10-06T09:20:41.502Z"
6547+ }
6548+ },
6549+ "list_custom_patterns": {
6550+ "params": {
6551+ "owner": "flagon-io",
6552+ "name": "hello"
6553+ },
6554+ "response": {
6555+ "patterns": [
6556+ {
6557+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r",
6558+ "scope": "repository",
6559+ "workspace": "flagon-io",
6560+ "repo": "hello",
6561+ "name": "Acme API key",
6562+ "pattern": "acme_[a-z0-9]{32}",
6563+ "before": null,
6564+ "after": null,
6565+ "test_strings": [
6566+ "ACME_KEY=acme_0123456789abcdef0123456789abcdef"
6567+ ],
6568+ "state": "published",
6569+ "created_by": "syntaqx",
6570+ "created_at": "2026-10-06T09:14:02.118Z",
6571+ "updated_by": "syntaqx",
6572+ "updated_at": "2026-10-06T09:14:02.118Z",
6573+ "open_alerts": 0
6574+ }
6575+ ],
6576+ "entitled": true
6577+ },
6578+ "notes": "A repository's list includes its workspace's patterns, with `scope` `workspace`. `entitled` says whether they run here: always on a public repository, and on a private one with the Security and quality activation."
6579+ },
6580+ "list_custom_patterns_for_workspace": {
6581+ "params": {
6582+ "workspace": "flagon-io"
6583+ },
6584+ "response": {
6585+ "patterns": [
6586+ {
6587+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r",
6588+ "scope": "workspace",
6589+ "workspace": "flagon-io",
6590+ "repo": null,
6591+ "name": "Acme API key",
6592+ "pattern": "acme_[a-z0-9]{32}",
6593+ "before": null,
6594+ "after": null,
6595+ "test_strings": [
6596+ "ACME_KEY=acme_0123456789abcdef0123456789abcdef"
6597+ ],
6598+ "state": "published",
6599+ "created_by": "syntaqx",
6600+ "created_at": "2026-10-06T09:14:02.118Z",
6601+ "updated_by": "syntaqx",
6602+ "updated_at": "2026-10-06T09:14:02.118Z",
6603+ "open_alerts": 0
6604+ }
6605+ ],
6606+ "entitled": true
6607+ }
6608+ },
6609+ "create_custom_pattern": {
6610+ "params": {
6611+ "owner": "flagon-io",
6612+ "name": "hello"
6613+ },
6614+ "request": {
6615+ "pattern_name": "Acme API key",
6616+ "pattern": "acme_[a-z0-9]{32}",
6617+ "test_strings": [
6618+ "ACME_KEY=acme_0123456789abcdef0123456789abcdef"
6619+ ],
6620+ "publish": true
6621+ },
6622+ "response": {
6623+ "pattern": {
6624+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r",
6625+ "scope": "repository",
6626+ "workspace": "flagon-io",
6627+ "repo": "hello",
6628+ "name": "Acme API key",
6629+ "pattern": "acme_[a-z0-9]{32}",
6630+ "before": null,
6631+ "after": null,
6632+ "test_strings": [
6633+ "ACME_KEY=acme_0123456789abcdef0123456789abcdef"
6634+ ],
6635+ "state": "published",
6636+ "created_by": "syntaqx",
6637+ "created_at": "2026-10-06T09:14:02.118Z",
6638+ "updated_by": "syntaqx",
6639+ "updated_at": "2026-10-06T09:14:02.118Z",
6640+ "open_alerts": 0
6641+ },
6642+ "tests": [
6643+ [
6644+ 9,
6645+ 46
6646+ ]
6647+ ]
6648+ },
6649+ "notes": "`tests` gives, for each test string, where the pattern matched (start and end, in characters), or `null`. A pattern that does not compile, matches an empty string, or is too complex is refused with `422` and says why."
6650+ },
6651+ "create_custom_pattern_for_workspace": {
6652+ "params": {
6653+ "workspace": "flagon-io"
6654+ },
6655+ "request": {
6656+ "pattern_name": "Acme API key",
6657+ "pattern": "acme_[a-z0-9]{32}",
6658+ "publish": false
6659+ },
6660+ "response": {
6661+ "pattern": {
6662+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r",
6663+ "scope": "workspace",
6664+ "workspace": "flagon-io",
6665+ "repo": null,
6666+ "name": "Acme API key",
6667+ "pattern": "acme_[a-z0-9]{32}",
6668+ "before": null,
6669+ "after": null,
6670+ "test_strings": [],
6671+ "state": "draft",
6672+ "created_by": "syntaqx",
6673+ "created_at": "2026-10-06T09:14:02.118Z",
6674+ "updated_by": "syntaqx",
6675+ "updated_at": "2026-10-06T09:14:02.118Z",
6676+ "open_alerts": 0
6677+ },
6678+ "tests": []
6679+ }
6680+ },
6681+ "update_custom_pattern": {
6682+ "params": {
6683+ "owner": "flagon-io",
6684+ "name": "hello",
6685+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r"
6686+ },
6687+ "request": {
6688+ "pattern_name": "Acme API key",
6689+ "pattern": "acme_[a-z0-9]{32,40}",
6690+ "publish": true
6691+ },
6692+ "response": {
6693+ "pattern": {
6694+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r",
6695+ "scope": "repository",
6696+ "workspace": "flagon-io",
6697+ "repo": "hello",
6698+ "name": "Acme API key",
6699+ "pattern": "acme_[a-z0-9]{32,40}",
6700+ "before": null,
6701+ "after": null,
6702+ "test_strings": [
6703+ "ACME_KEY=acme_0123456789abcdef0123456789abcdef"
6704+ ],
6705+ "state": "published",
6706+ "created_by": "syntaqx",
6707+ "created_at": "2026-10-06T09:14:02.118Z",
6708+ "updated_by": "syntaqx",
6709+ "updated_at": "2026-10-06T09:14:02.118Z",
6710+ "open_alerts": 0
6711+ },
6712+ "tests": [
6713+ [
6714+ 9,
6715+ 46
6716+ ]
6717+ ]
6718+ }
6719+ },
6720+ "update_custom_pattern_for_workspace": {
6721+ "params": {
6722+ "workspace": "flagon-io",
6723+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r"
6724+ },
6725+ "request": {
6726+ "pattern_name": "Acme API key",
6727+ "pattern": "acme_[a-z0-9]{32}",
6728+ "publish": true
6729+ },
6730+ "response": {
6731+ "pattern": {
6732+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r",
6733+ "scope": "workspace",
6734+ "workspace": "flagon-io",
6735+ "repo": null,
6736+ "name": "Acme API key",
6737+ "pattern": "acme_[a-z0-9]{32}",
6738+ "before": null,
6739+ "after": null,
6740+ "test_strings": [
6741+ "ACME_KEY=acme_0123456789abcdef0123456789abcdef"
6742+ ],
6743+ "state": "published",
6744+ "created_by": "syntaqx",
6745+ "created_at": "2026-10-06T09:14:02.118Z",
6746+ "updated_by": "syntaqx",
6747+ "updated_at": "2026-10-06T09:14:02.118Z",
6748+ "open_alerts": 0
6749+ },
6750+ "tests": [
6751+ [
6752+ 9,
6753+ 46
6754+ ]
6755+ ]
6756+ }
6757+ },
6758+ "delete_custom_pattern": {
6759+ "params": {
6760+ "owner": "flagon-io",
6761+ "name": "hello",
6762+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r"
6763+ },
6764+ "response": {
6765+ "deleted": true
6766+ }
6767+ },
6768+ "delete_custom_pattern_for_workspace": {
6769+ "params": {
6770+ "workspace": "flagon-io",
6771+ "id": "pat_01kq2p9f6g7h8j9k0m1n2p3q4r"
6772+ },
6773+ "response": {
6774+ "deleted": true
6775+ }
6776+ },
6777+ "dry_run_custom_pattern": {
6778+ "params": {
6779+ "owner": "flagon-io",
6780+ "name": "hello"
6781+ },
6782+ "request": {
6783+ "pattern": "acme_[a-z0-9]{32}"
6784+ },
6785+ "response": {
6786+ "repos": [
6787+ {
6788+ "name": "hello",
6789+ "files_scanned": 214,
6790+ "matches": [
6791+ {
6792+ "path": "config/dev.env",
6793+ "line": 3,
6794+ "preview": "ACME_KEY=acme_01••••••••••••••••••••••••"
6795+ }
6796+ ],
6797+ "truncated": false,
6798+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291"
6799+ }
6800+ ]
6801+ },
6802+ "notes": "Reads up to 2,000 files and 20 MB of the default branch, skipping what secret scanning skips. Nothing is recorded."
6803+ },
6804+ "dry_run_custom_pattern_for_workspace": {
6805+ "params": {
6806+ "workspace": "flagon-io"
6807+ },
6808+ "request": {
6809+ "pattern": "acme_[a-z0-9]{32}",
6810+ "repos": [
6811+ "hello"
6812+ ]
6813+ },
6814+ "response": {
6815+ "repos": [
6816+ {
6817+ "name": "hello",
6818+ "files_scanned": 214,
6819+ "matches": [],
6820+ "truncated": false,
6821+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291"
6822+ }
6823+ ]
6824+ }
6825+ },
6826+ "list_code_scanning_alerts": {
6827+ "params": {
6828+ "owner": "flagon-io",
6829+ "name": "hello"
6830+ },
6831+ "query": {
6832+ "state": "open",
6833+ "severity": "high"
6834+ },
6835+ "response": [
6836+ {
6837+ "id": "cod_01kq2qag7h8j9k0m1n2p3q4r5s",
6838+ "number": 4,
6839+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6840+ "tool": "Semgrep OSS",
6841+ "category": "Semgrep OSS",
6842+ "rule_id": "javascript.lang.security.detect-child-process.detect-child-process",
6843+ "rule_name": "javascript.lang.security.detect-child-process.detect-child-process",
6844+ "rule_description": "Detected calls to child_process from a function argument.",
6845+ "help": null,
6846+ "help_uri": "https://semgrep.dev/r/javascript.lang.security.detect-child-process.detect-child-process",
6847+ "tags": [
6848+ "security",
6849+ "CWE-78"
6850+ ],
6851+ "level": "error",
6852+ "security_severity": null,
6853+ "severity": "high",
6854+ "message": "Detected calls to child_process from a function argument `req`. This could lead to a command injection.",
6855+ "path": "src/server.js",
6856+ "start_line": 6,
6857+ "end_line": 6,
6858+ "start_column": 3,
6859+ "end_column": 60,
6860+ "state": "open",
6861+ "fingerprint": "3f1c9a0e7b2d4c5e6f708192a3b4c5d6",
6862+ "first_commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6863+ "last_commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6864+ "created_at": "2026-10-06T09:14:02.118Z",
6865+ "updated_at": "2026-10-06T09:14:02.118Z",
6866+ "fixed_at": null,
6867+ "dismissed_by": null,
6868+ "dismissed_reason": null,
6869+ "dismissed_comment": null,
6870+ "dismissed_at": null,
6871+ "issue": null
6872+ }
6873+ ],
6874+ "notes": "`severity` is the rule's security severity when it has one (from its `security-severity` score), else from the result's level: `error` high, `warning` medium, `note` low."
6875+ },
6876+ "list_code_scanning_alerts_for_workspace": {
6877+ "params": {
6878+ "workspace": "flagon-io"
6879+ },
6880+ "query": {
6881+ "state": "open"
6882+ },
6883+ "response": [
6884+ {
6885+ "repo": "hello",
6886+ "code": {
6887+ "id": "cod_01kq2qag7h8j9k0m1n2p3q4r5s",
6888+ "number": 4,
6889+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6890+ "tool": "Semgrep OSS",
6891+ "category": "Semgrep OSS",
6892+ "rule_id": "javascript.lang.security.detect-child-process.detect-child-process",
6893+ "rule_name": "javascript.lang.security.detect-child-process.detect-child-process",
6894+ "rule_description": "Detected calls to child_process from a function argument.",
6895+ "help": null,
6896+ "help_uri": "https://semgrep.dev/r/javascript.lang.security.detect-child-process.detect-child-process",
6897+ "tags": [
6898+ "security",
6899+ "CWE-78"
6900+ ],
6901+ "level": "error",
6902+ "security_severity": null,
6903+ "severity": "high",
6904+ "message": "Detected calls to child_process from a function argument `req`. This could lead to a command injection.",
6905+ "path": "src/server.js",
6906+ "start_line": 6,
6907+ "end_line": 6,
6908+ "start_column": 3,
6909+ "end_column": 60,
6910+ "state": "open",
6911+ "fingerprint": "3f1c9a0e7b2d4c5e6f708192a3b4c5d6",
6912+ "first_commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6913+ "last_commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6914+ "created_at": "2026-10-06T09:14:02.118Z",
6915+ "updated_at": "2026-10-06T09:14:02.118Z",
6916+ "fixed_at": null,
6917+ "dismissed_by": null,
6918+ "dismissed_reason": null,
6919+ "dismissed_comment": null,
6920+ "dismissed_at": null,
6921+ "issue": null
6922+ }
6923+ }
6924+ ]
6925+ },
6926+ "get_code_scanning_alert": {
6927+ "params": {
6928+ "owner": "flagon-io",
6929+ "name": "hello",
6930+ "number": 4
6931+ },
6932+ "response": {
6933+ "alert": {
6934+ "id": "cod_01kq2qag7h8j9k0m1n2p3q4r5s",
6935+ "number": 4,
6936+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6937+ "tool": "Semgrep OSS",
6938+ "category": "Semgrep OSS",
6939+ "rule_id": "javascript.lang.security.detect-child-process.detect-child-process",
6940+ "rule_name": "javascript.lang.security.detect-child-process.detect-child-process",
6941+ "rule_description": "Detected calls to child_process from a function argument.",
6942+ "help": null,
6943+ "help_uri": "https://semgrep.dev/r/javascript.lang.security.detect-child-process.detect-child-process",
6944+ "tags": [
6945+ "security",
6946+ "CWE-78"
6947+ ],
6948+ "level": "error",
6949+ "security_severity": null,
6950+ "severity": "high",
6951+ "message": "Detected calls to child_process from a function argument `req`. This could lead to a command injection.",
6952+ "path": "src/server.js",
6953+ "start_line": 6,
6954+ "end_line": 6,
6955+ "start_column": 3,
6956+ "end_column": 60,
6957+ "state": "open",
6958+ "fingerprint": "3f1c9a0e7b2d4c5e6f708192a3b4c5d6",
6959+ "first_commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6960+ "last_commit": "4807077b296e6edbf410d55e72749d3e1170c291",
6961+ "created_at": "2026-10-06T09:14:02.118Z",
6962+ "updated_at": "2026-10-06T09:14:02.118Z",
6963+ "fixed_at": null,
6964+ "dismissed_by": null,
6965+ "dismissed_reason": null,
6966+ "dismissed_comment": null,
6967+ "dismissed_at": null,
6968+ "issue": null
6969+ },
6970+ "activity": [],
6971+ "analyses": [
6972+ {
6973+ "id": "ana_01kq2rbh8j9k0m1n2p3q4r5s6t",
6974+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
6975+ "sarif_id": "sar_01kq2scj9k0m1n2p3q4r5s6t7v",
6976+ "tool": "Semgrep OSS",
6977+ "tool_version": "1.140.0",
6978+ "category": "Semgrep OSS",
6979+ "commit_sha": "4807077b296e6edbf410d55e72749d3e1170c291",
6980+ "git_ref": "refs/heads/main",
6981+ "pull": null,
6982+ "results": 7,
6983+ "new_alerts": 2,
6984+ "fixed_alerts": 1,
6985+ "dropped": 0,
6986+ "created_at": "2026-10-06T09:14:02.118Z"
6987+ }
6988+ ]
6989+ }
6990+ },
6991+ "update_code_scanning_alert": {
6992+ "params": {
6993+ "owner": "flagon-io",
6994+ "name": "hello",
6995+ "number": 4
6996+ },
6997+ "request": {
6998+ "state": "dismissed",
6999+ "dismissed_reason": "false_positive",
7000+ "dismissed_comment": "The argument is a constant."
7001+ },
7002+ "response": {
7003+ "id": "cod_01kq2qag7h8j9k0m1n2p3q4r5s",
7004+ "number": 4,
7005+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
7006+ "tool": "Semgrep OSS",
7007+ "category": "Semgrep OSS",
7008+ "rule_id": "javascript.lang.security.detect-child-process.detect-child-process",
7009+ "rule_name": "javascript.lang.security.detect-child-process.detect-child-process",
7010+ "rule_description": "Detected calls to child_process from a function argument.",
7011+ "help": null,
7012+ "help_uri": "https://semgrep.dev/r/javascript.lang.security.detect-child-process.detect-child-process",
7013+ "tags": [
7014+ "security",
7015+ "CWE-78"
7016+ ],
7017+ "level": "error",
7018+ "security_severity": null,
7019+ "severity": "high",
7020+ "message": "Detected calls to child_process from a function argument `req`. This could lead to a command injection.",
7021+ "path": "src/server.js",
7022+ "start_line": 6,
7023+ "end_line": 6,
7024+ "start_column": 3,
7025+ "end_column": 60,
7026+ "state": "dismissed",
7027+ "fingerprint": "3f1c9a0e7b2d4c5e6f708192a3b4c5d6",
7028+ "first_commit": "4807077b296e6edbf410d55e72749d3e1170c291",
7029+ "last_commit": "4807077b296e6edbf410d55e72749d3e1170c291",
7030+ "created_at": "2026-10-06T09:14:02.118Z",
7031+ "updated_at": "2026-10-06T09:14:02.118Z",
7032+ "fixed_at": null,
7033+ "dismissed_by": "syntaqx",
7034+ "dismissed_reason": "false_positive",
7035+ "dismissed_comment": "The argument is a constant.",
7036+ "dismissed_at": "2026-10-06T09:20:41.502Z",
7037+ "issue": null
7038+ }
7039+ },
7040+ "list_code_scanning_analyses": {
7041+ "params": {
7042+ "owner": "flagon-io",
7043+ "name": "hello"
7044+ },
7045+ "response": [
7046+ {
7047+ "id": "ana_01kq2rbh8j9k0m1n2p3q4r5s6t",
7048+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
7049+ "sarif_id": "sar_01kq2scj9k0m1n2p3q4r5s6t7v",
7050+ "tool": "Semgrep OSS",
7051+ "tool_version": "1.140.0",
7052+ "category": "Semgrep OSS",
7053+ "commit_sha": "4807077b296e6edbf410d55e72749d3e1170c291",
7054+ "git_ref": "refs/heads/main",
7055+ "pull": null,
7056+ "results": 7,
7057+ "new_alerts": 2,
7058+ "fixed_alerts": 1,
7059+ "dropped": 0,
7060+ "created_at": "2026-10-06T09:14:02.118Z"
7061+ }
7062+ ]
7063+ },
7064+ "upload_sarif": {
7065+ "params": {
7066+ "owner": "flagon-io",
7067+ "name": "hello"
7068+ },
7069+ "request": {
7070+ "commit_sha": "4807077b296e6edbf410d55e72749d3e1170c291",
7071+ "ref": "refs/heads/main",
7072+ "sarif": "H4sIAAAAAAAA…",
7073+ "checkout_uri": "file:///home/runner/work/repo"
7074+ },
7075+ "response": {
7076+ "id": "sar_01kq2scj9k0m1n2p3q4r5s6t7v",
7077+ "processing_status": "complete",
7078+ "analyses": [
7079+ "ana_01kq2rbh8j9k0m1n2p3q4r5s6t"
7080+ ],
7081+ "errors": [],
7082+ "commit_sha": "4807077b296e6edbf410d55e72749d3e1170c291",
7083+ "git_ref": "refs/heads/main",
7084+ "created_at": "2026-10-06T09:14:02.118Z"
7085+ },
7086+ "notes": "`sarif` is the SARIF 2.1.0 file gzipped, then base64-encoded: `gzip -c results.sarif | base64 -w0`. The upload is read at once: `processing_status` is `complete` or `failed`, with `errors` saying why. For `refs/pull/<number>/head`, the results become the pull request's `Code scanning` check instead of alerts."
7087+ },
7088+ "get_sarif_upload": {
7089+ "params": {
7090+ "owner": "flagon-io",
7091+ "name": "hello",
7092+ "id": "sar_01kq2scj9k0m1n2p3q4r5s6t7v"
7093+ },
7094+ "response": {
7095+ "id": "sar_01kq2scj9k0m1n2p3q4r5s6t7v",
7096+ "processing_status": "complete",
7097+ "analyses": [
7098+ "ana_01kq2rbh8j9k0m1n2p3q4r5s6t"
7099+ ],
7100+ "errors": [],
7101+ "commit_sha": "4807077b296e6edbf410d55e72749d3e1170c291",
7102+ "git_ref": "refs/heads/main",
7103+ "created_at": "2026-10-06T09:14:02.118Z"
7104+ }
7105+ },
7106+ "list_vulnerability_alerts": {
7107+ "params": {
7108+ "owner": "flagon-io",
7109+ "name": "hello"
7110+ },
7111+ "query": {
7112+ "state": "open"
7113+ },
7114+ "response": [
7115+ {
7116+ "id": "vul_01kp4b8c3d4e5f6g7h8j9k0m1n",
7117+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
7118+ "ecosystem": "npm",
7119+ "package": "lodash",
7120+ "version": "4.17.20",
7121+ "manifest": "package-lock.json",
7122+ "advisory": "GHSA-35jh-r3h4-6jhm",
7123+ "osv_id": "GHSA-35jh-r3h4-6jhm",
7124+ "summary": "Command Injection in lodash",
7125+ "severity": "high",
7126+ "fixed_version": "4.17.21",
7127+ "status": "open",
7128+ "issue": null,
7129+ "found_at": "2026-10-06T09:14:02.118Z",
7130+ "fixed_at": null,
7131+ "state": "open",
7132+ "dismissed_by": null,
7133+ "dismissed_reason": null,
7134+ "dismissed_comment": null,
7135+ "dismissed_at": null,
7136+ "update": null
7137+ }
7138+ ]
7139+ },
7140+ "list_vulnerability_alerts_for_workspace": {
7141+ "params": {
7142+ "workspace": "flagon-io"
7143+ },
7144+ "query": {
7145+ "severity": "critical"
7146+ },
7147+ "response": [
7148+ {
7149+ "repo": "hello",
7150+ "vulnerability": {
7151+ "id": "vul_01kp4b8c3d4e5f6g7h8j9k0m1n",
7152+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
7153+ "ecosystem": "npm",
7154+ "package": "lodash",
7155+ "version": "4.17.20",
7156+ "manifest": "package-lock.json",
7157+ "advisory": "GHSA-35jh-r3h4-6jhm",
7158+ "osv_id": "GHSA-35jh-r3h4-6jhm",
7159+ "summary": "Command Injection in lodash",
7160+ "severity": "high",
7161+ "fixed_version": "4.17.21",
7162+ "status": "open",
7163+ "issue": null,
7164+ "found_at": "2026-10-06T09:14:02.118Z",
7165+ "fixed_at": null,
7166+ "state": "open",
7167+ "dismissed_by": null,
7168+ "dismissed_reason": null,
7169+ "dismissed_comment": null,
7170+ "dismissed_at": null,
7171+ "update": null
7172+ }
7173+ }
7174+ ]
7175+ },
7176+ "get_vulnerability_alert": {
7177+ "params": {
7178+ "owner": "flagon-io",
7179+ "name": "hello",
7180+ "id": "vul_01kp4b8c3d4e5f6g7h8j9k0m1n"
7181+ },
7182+ "response": {
7183+ "id": "vul_01kp4b8c3d4e5f6g7h8j9k0m1n",
7184+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
7185+ "ecosystem": "npm",
7186+ "package": "lodash",
7187+ "version": "4.17.20",
7188+ "manifest": "package-lock.json",
7189+ "advisory": "GHSA-35jh-r3h4-6jhm",
7190+ "osv_id": "GHSA-35jh-r3h4-6jhm",
7191+ "summary": "Command Injection in lodash",
7192+ "severity": "high",
7193+ "fixed_version": "4.17.21",
7194+ "status": "open",
7195+ "issue": null,
7196+ "found_at": "2026-10-06T09:14:02.118Z",
7197+ "fixed_at": null,
7198+ "state": "open",
7199+ "dismissed_by": null,
7200+ "dismissed_reason": null,
7201+ "dismissed_comment": null,
7202+ "dismissed_at": null,
7203+ "update": null
7204+ }
7205+ },
7206+ "update_vulnerability_alert": {
7207+ "params": {
7208+ "owner": "flagon-io",
7209+ "name": "hello",
7210+ "id": "vul_01kp4b8c3d4e5f6g7h8j9k0m1n"
7211+ },
7212+ "request": {
7213+ "state": "dismissed",
7214+ "reason": "tolerable_risk",
7215+ "comment": "Only the build uses it."
7216+ },
7217+ "response": {
7218+ "id": "vul_01kp4b8c3d4e5f6g7h8j9k0m1n",
7219+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
7220+ "ecosystem": "npm",
7221+ "package": "lodash",
7222+ "version": "4.17.20",
7223+ "manifest": "package-lock.json",
7224+ "advisory": "GHSA-35jh-r3h4-6jhm",
7225+ "osv_id": "GHSA-35jh-r3h4-6jhm",
7226+ "summary": "Command Injection in lodash",
7227+ "severity": "high",
7228+ "fixed_version": "4.17.21",
7229+ "status": "dismissed",
7230+ "issue": null,
7231+ "found_at": "2026-10-06T09:14:02.118Z",
7232+ "fixed_at": null,
7233+ "state": "dismissed",
7234+ "dismissed_by": "syntaqx",
7235+ "dismissed_reason": "tolerable_risk",
7236+ "dismissed_comment": "Only the build uses it.",
7237+ "dismissed_at": "2026-10-06T09:20:41.502Z",
7238+ "update": null
7239+ }
7240+ },
7241+ "fix_security_alert": {
7242+ "params": {
7243+ "owner": "flagon-io",
7244+ "name": "hello",
7245+ "id": "cod_01kq2qag7h8j9k0m1n2p3q4r5s"
7246+ },
7247+ "response": {
7248+ "issue": 57,
7249+ "started": true,
7250+ "message": null
7251+ },
7252+ "notes": "Opens an issue with what is wrong and what done means, and puts g1t on it as you. Its run is charged as agent usage, and its pull request lands through the repository's required checks. Asking again while the issue is open returns it."
7253+ },
7254+ "get_dependency_graph": {
7255+ "params": {
7256+ "owner": "flagon-io",
7257+ "name": "hello"
7258+ },
7259+ "response": {
7260+ "commit": "4807077b296e6edbf410d55e72749d3e1170c291",
7261+ "manifests": [
7262+ {
7263+ "path": "package-lock.json",
7264+ "ecosystem": "npm",
7265+ "dependencies": 2,
7266+ "direct": 1
7267+ }
7268+ ],
7269+ "dependencies": [
7270+ {
7271+ "ecosystem": "npm",
7272+ "name": "lodash",
7273+ "version": "4.17.20",
7274+ "manifest": "package-lock.json",
7275+ "relationship": "direct",
7276+ "development": false,
7277+ "license": "MIT",
7278+ "purl": "pkg:npm/lodash@4.17.20",
7279+ "vulnerabilities": 1
7280+ },
7281+ {
7282+ "ecosystem": "npm",
7283+ "name": "ms",
7284+ "version": "2.1.3",
7285+ "manifest": "package-lock.json",
7286+ "relationship": "transitive",
7287+ "development": false,
7288+ "license": "MIT",
7289+ "purl": "pkg:npm/ms@2.1.3",
7290+ "vulnerabilities": 0
7291+ }
7292+ ]
7293+ }
7294+ },
7295+ "get_sbom": {
7296+ "params": {
7297+ "owner": "flagon-io",
7298+ "name": "hello"
7299+ },
7300+ "response": {
7301+ "sbom": {
7302+ "spdxVersion": "SPDX-2.3",
7303+ "dataLicense": "CC0-1.0",
7304+ "SPDXID": "SPDXRef-DOCUMENT",
7305+ "name": "flagon-io/hello dependency graph",
7306+ "documentNamespace": "https://g1t.sh/flagon-io/hello/sbom/sbom_01kq2tdk0m1n2p3q4r5s6t7v8w",
7307+ "creationInfo": {
7308+ "created": "2026-10-06T09:14:02Z",
7309+ "creators": [
7310+ "Tool: g1t",
7311+ "Organization: g1t"
7312+ ],
7313+ "comment": "Read from the repository's lockfiles on its default branch."
7314+ },
7315+ "documentDescribes": [
7316+ "SPDXRef-Repository-flagon-io-hello"
7317+ ],
7318+ "packages": [
7319+ {
7320+ "SPDXID": "SPDXRef-Repository-flagon-io-hello",
7321+ "name": "flagon-io/hello",
7322+ "versionInfo": "4807077b296e6edbf410d55e72749d3e1170c291",
7323+ "downloadLocation": "git+https://g1t.sh/flagon-io/hello.git",
7324+ "filesAnalyzed": false,
7325+ "licenseConcluded": "NOASSERTION",
7326+ "licenseDeclared": "NOASSERTION",
7327+ "copyrightText": "NOASSERTION",
7328+ "primaryPackagePurpose": "SOURCE",
7329+ "externalRefs": []
7330+ },
7331+ {
7332+ "SPDXID": "SPDXRef-Package-npm-lodash-4.17.20",
7333+ "name": "lodash",
7334+ "versionInfo": "4.17.20",
7335+ "downloadLocation": "NOASSERTION",
7336+ "filesAnalyzed": false,
7337+ "licenseConcluded": "NOASSERTION",
7338+ "licenseDeclared": "MIT",
7339+ "copyrightText": "NOASSERTION",
7340+ "primaryPackagePurpose": "LIBRARY",
7341+ "comment": "Resolved by package-lock.json (direct dependency).",
7342+ "externalRefs": [
7343+ {
7344+ "referenceCategory": "PACKAGE-MANAGER",
7345+ "referenceType": "purl",
7346+ "referenceLocator": "pkg:npm/lodash@4.17.20"
7347+ }
7348+ ]
7349+ }
7350+ ],
7351+ "relationships": [
7352+ {
7353+ "spdxElementId": "SPDXRef-DOCUMENT",
7354+ "relationshipType": "DESCRIBES",
7355+ "relatedSpdxElement": "SPDXRef-Repository-flagon-io-hello"
7356+ },
7357+ {
7358+ "spdxElementId": "SPDXRef-Repository-flagon-io-hello",
7359+ "relationshipType": "DEPENDS_ON",
7360+ "relatedSpdxElement": "SPDXRef-Package-npm-lodash-4.17.20"
7361+ }
7362+ ]
7363+ }
7364+ },
7365+ "notes": "`sbom` is an SPDX 2.3 JSON document, sent as SPDX spells it. Save it with `jq .sbom`."
7366+ },
7367+ "compare_dependencies": {
7368+ "params": {
7369+ "owner": "flagon-io",
7370+ "name": "hello",
7371+ "basehead": "main...upgrade-deps"
7372+ },
7373+ "response": {
7374+ "base": "main",
7375+ "head": "upgrade-deps",
7376+ "changes": [
7377+ {
7378+ "change_type": "added",
7379+ "manifest": "package-lock.json",
7380+ "ecosystem": "npm",
7381+ "name": "lodash",
7382+ "version": "4.17.20",
7383+ "relationship": "direct",
7384+ "development": false,
7385+ "license": "MIT",
7386+ "purl": "pkg:npm/lodash@4.17.20",
7387+ "vulnerabilities": [
7388+ {
7389+ "advisory": "GHSA-35jh-r3h4-6jhm",
7390+ "osv_id": "GHSA-35jh-r3h4-6jhm",
7391+ "summary": "Command Injection in lodash",
7392+ "severity": "high",
7393+ "fixed_version": "4.17.21",
7394+ "url": "https://osv.dev/vulnerability/GHSA-35jh-r3h4-6jhm"
7395+ }
7396+ ],
7397+ "denied_license": false,
7398+ "failing": true
7399+ },
7400+ {
7401+ "change_type": "removed",
7402+ "manifest": "package-lock.json",
7403+ "ecosystem": "npm",
7404+ "name": "lodash",
7405+ "version": "4.17.21",
7406+ "relationship": "direct",
7407+ "development": false,
7408+ "license": "MIT",
7409+ "purl": "pkg:npm/lodash@4.17.21",
7410+ "vulnerabilities": [],
7411+ "denied_license": false,
7412+ "failing": false
7413+ }
7414+ ],
7415+ "passed": false,
7416+ "headline": "Adds 1 vulnerable package",
7417+ "fail_on": "high",
7418+ "deny_licenses": []
7419+ }
7420+ },
7421+ "get_security_settings": {
7422+ "params": {
7423+ "owner": "flagon-io",
7424+ "name": "hello"
7425+ },
7426+ "response": {
7427+ "settings": {
7428+ "code_scanning_gate": "high",
7429+ "dependency_review": true,
7430+ "review_fail_on": "high",
7431+ "review_deny_licenses": [
7432+ "AGPL-3.0-only"
7433+ ],
7434+ "review_comment": true
7435+ },
7436+ "workspace": {
7437+ "delegated_bypass": true,
7438+ "validity_checks": true
7439+ },
7440+ "private": true,
7441+ "entitled": true,
7442+ "upkeep": true
7443+ }
7444+ },
7445+ "update_security_settings": {
7446+ "params": {
7447+ "owner": "flagon-io",
7448+ "name": "hello"
7449+ },
7450+ "request": {
7451+ "code_scanning_gate": "high",
7452+ "review_deny_licenses": [
7453+ "AGPL-3.0-only"
7454+ ]
7455+ },
7456+ "response": {
7457+ "settings": {
7458+ "code_scanning_gate": "high",
7459+ "dependency_review": true,
7460+ "review_fail_on": "high",
7461+ "review_deny_licenses": [
7462+ "AGPL-3.0-only"
7463+ ],
7464+ "review_comment": true
7465+ },
7466+ "workspace": {
7467+ "delegated_bypass": true,
7468+ "validity_checks": true
7469+ },
7470+ "private": true,
7471+ "entitled": true,
7472+ "upkeep": true
7473+ },
7474+ "notes": "Only what you send changes. The `Code scanning` and `Dependency review` checks gate merges once you require them in branch protection."
7475+ },
7476+ "get_workspace_security_settings": {
7477+ "params": {
7478+ "workspace": "flagon-io"
7479+ },
7480+ "response": {
7481+ "settings": {
7482+ "delegated_bypass": true,
7483+ "validity_checks": true
7484+ },
7485+ "activated": true
7486+ }
7487+ },
7488+ "update_workspace_security_settings": {
7489+ "params": {
7490+ "workspace": "flagon-io"
7491+ },
7492+ "request": {
7493+ "delegated_bypass": true
7494+ },
7495+ "response": {
7496+ "settings": {
7497+ "delegated_bypass": true,
7498+ "validity_checks": true
7499+ },
7500+ "activated": true
7501+ }
7502+ },
7503+ "get_security_overview": {
7504+ "params": {
7505+ "workspace": "flagon-io"
7506+ },
7507+ "query": {
7508+ "days": "30"
7509+ },
7510+ "response": {
7511+ "activated": true,
7512+ "private_hidden": 0,
7513+ "totals": [
7514+ {
7515+ "alert_type": "secret_scanning",
7516+ "open": {
7517+ "critical": 1,
7518+ "high": 0,
7519+ "medium": 0,
7520+ "low": 0,
7521+ "unknown": 0
7522+ },
7523+ "opened": 2,
7524+ "closed": 1
7525+ },
7526+ {
7527+ "alert_type": "code_scanning",
7528+ "open": {
7529+ "critical": 0,
7530+ "high": 3,
7531+ "medium": 4,
7532+ "low": 0,
7533+ "unknown": 0
7534+ },
7535+ "opened": 7,
7536+ "closed": 2
7537+ },
7538+ {
7539+ "alert_type": "vulnerability",
7540+ "open": {
7541+ "critical": 0,
7542+ "high": 1,
7543+ "medium": 2,
7544+ "low": 1,
7545+ "unknown": 0
7546+ },
7547+ "opened": 3,
7548+ "closed": 5
7549+ }
7550+ ],
7551+ "trend": [
7552+ {
7553+ "day": "2026-10-05",
7554+ "secret_scanning": 1,
7555+ "code_scanning": 8,
7556+ "vulnerability": 6
7557+ },
7558+ {
7559+ "day": "2026-10-06",
7560+ "secret_scanning": 1,
7561+ "code_scanning": 7,
7562+ "vulnerability": 4
7563+ }
7564+ ],
7565+ "repos": [
7566+ {
7567+ "repo_id": "rep_01kp0a1b2c3d4e5f6g7h8j9k0m",
7568+ "name": "hello",
7569+ "private": true,
7570+ "custom_patterns": 1,
7571+ "validity_checks": true,
7572+ "code_scanning_at": "2026-10-06T09:14:02.118Z",
7573+ "dependency_review": true,
7574+ "security_updates": true,
7575+ "lockfiles": 1,
7576+ "secrets": {
7577+ "critical": 1,
7578+ "high": 0,
7579+ "medium": 0,
7580+ "low": 0,
7581+ "unknown": 0
7582+ },
7583+ "code": {
7584+ "critical": 0,
7585+ "high": 3,
7586+ "medium": 4,
7587+ "low": 0,
7588+ "unknown": 0
7589+ },
7590+ "vulnerabilities": {
7591+ "critical": 0,
7592+ "high": 1,
7593+ "medium": 2,
7594+ "low": 1,
7595+ "unknown": 0
7596+ }
7597+ }
7598+ ]
7599+ }
7600+ },
7601+ "create_label": {
7602+ "request": {
7603+ "label": "area: cli",
7604+ "color": "1d76db",
7605+ "description": "The command-line tool"
7606+ },
7607+ "response": {
7608+ "name": "area: cli",
7609+ "color": "1d76db",
7610+ "description": "The command-line tool",
7611+ "issues": 0,
7612+ "pulls": 0
7613+ }
7614+ },
7615+ "update_label": {
7616+ "params": {
7617+ "label": "area: cli"
7618+ },
7619+ "request": {
7620+ "new_name": "cli",
7621+ "color": "0052cc"
7622+ },
7623+ "response": {
7624+ "name": "cli",
7625+ "color": "0052cc",
7626+ "description": "The command-line tool",
7627+ "issues": 3,
7628+ "pulls": 1
7629+ },
7630+ "notes": "Renaming a label renames it on every issue and pull request that carries it."
7631+ },
7632+ "delete_label": {
7633+ "params": {
7634+ "label": "wontfix"
7635+ },
7636+ "response": true
7637+ },
7638+ "add_default_labels": {
7639+ "response": [
7640+ {
7641+ "name": "bug",
7642+ "color": "d73a4a",
7643+ "description": "Something isn't working",
7644+ "issues": 4,
7645+ "pulls": 1
7646+ },
7647+ {
7648+ "name": "documentation",
7649+ "color": "0075ca",
7650+ "description": "Improvements or additions to documentation",
7651+ "issues": 0,
7652+ "pulls": 0
7653+ }
7654+ ],
7655+ "notes": "Every label the repository has afterwards, shortened here. Labels it already had are left as they were."
7656+ },
7657+ "list_issue_labels": {
7658+ "response": [
7659+ {
7660+ "name": "bug",
7661+ "color": "d73a4a",
7662+ "description": "Something isn't working",
7663+ "issues": 4,
7664+ "pulls": 1
7665+ },
7666+ {
7667+ "name": "help wanted",
7668+ "color": "008672",
7669+ "description": "Extra attention is needed",
7670+ "issues": 1,
7671+ "pulls": 0
7672+ }
7673+ ]
7674+ },
7675+ "add_issue_labels": {
7676+ "request": {
7677+ "labels": [
7678+ "help wanted"
7679+ ]
7680+ },
7681+ "response": [
7682+ "bug",
7683+ "help wanted"
7684+ ],
7685+ "notes": "Returns its labels now, by name."
7686+ },
7687+ "set_issue_labels": {
7688+ "request": {
7689+ "labels": [
7690+ "bug"
7691+ ]
7692+ },
7693+ "response": [
7694+ "bug"
7695+ ]
7696+ },
7697+ "remove_issue_labels": {
7698+ "response": []
7699+ },
7700+ "remove_issue_label": {
7701+ "params": {
7702+ "label": "help wanted"
7703+ },
7704+ "response": [
7705+ "bug"
7706+ ]
7707+ },
7708+ "list_milestones": {
7709+ "response": [
7710+ {
7711+ "number": 3,
7712+ "title": "Launch",
7713+ "description": "Everything that has to land before the launch on October 14.",
7714+ "due_on": "2026-10-14",
7715+ "state": "open",
7716+ "open_items": 5,
7717+ "closed_items": 12,
7718+ "created_at": "2026-09-20T09:00:00.000Z",
7719+ "updated_at": "2026-10-06T16:12:40.118Z",
7720+ "closed_at": null
7721+ }
7722+ ],
7723+ "notes": "Open milestones soonest due first, then closed ones. Progress is closed_items out of open_items plus closed_items."
7724+ },
7725+ "create_milestone": {
7726+ "request": {
7727+ "title": "Launch",
7728+ "description": "Everything that has to land before the launch on October 14.",
7729+ "due_on": "2026-10-14"
7730+ },
7731+ "response": {
7732+ "number": 3,
7733+ "title": "Launch",
7734+ "description": "Everything that has to land before the launch on October 14.",
7735+ "due_on": "2026-10-14",
7736+ "state": "open",
7737+ "open_items": 0,
7738+ "closed_items": 0,
7739+ "created_at": "2026-09-20T09:00:00.000Z",
7740+ "updated_at": "2026-09-20T09:00:00.000Z",
7741+ "closed_at": null
7742+ }
7743+ },
7744+ "update_milestone": {
7745+ "params": {
7746+ "milestone": 3
7747+ },
7748+ "request": {
7749+ "state": "closed"
7750+ },
7751+ "response": {
7752+ "number": 3,
7753+ "title": "Launch",
7754+ "description": "Everything that has to land before the launch on October 14.",
7755+ "due_on": "2026-10-14",
7756+ "state": "closed",
7757+ "open_items": 0,
7758+ "closed_items": 17,
7759+ "created_at": "2026-09-20T09:00:00.000Z",
7760+ "updated_at": "2026-10-14T18:00:00.000Z",
7761+ "closed_at": "2026-10-14T18:00:00.000Z"
7762+ }
7763+ },
7764+ "delete_milestone": {
7765+ "params": {
7766+ "milestone": 3
7767+ },
7768+ "response": true
7769+ },
7770+ "get_milestone": {
7771+ "params": {
7772+ "milestone": 3
7773+ },
7774+ "response": {
7775+ "milestone": {
7776+ "number": 3,
7777+ "title": "Launch",
7778+ "description": "Everything that has to land before the launch on October 14.",
7779+ "due_on": "2026-10-14",
7780+ "state": "open",
7781+ "open_items": 5,
7782+ "closed_items": 12,
7783+ "created_at": "2026-09-20T09:00:00.000Z",
7784+ "updated_at": "2026-10-06T16:12:40.118Z",
7785+ "closed_at": null
7786+ },
7787+ "issues": [
7788+ {
7789+ "id": "iss_01m43shrzpfe49x74ga7sj1c6v",
7790+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
7791+ "number": 12,
7792+ "title": "Greeting should name the caller",
7793+ "body": "Take a name from the first argument; fall back to world.",
7794+ "labels": [
7795+ "feature",
7796+ "good first issue"
7797+ ],
7798+ "state": "open",
7799+ "reason": null,
7800+ "resolved_by": null,
7801+ "author": {
7802+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
7803+ "username": "syntaqx",
7804+ "kind": "user",
7805+ "verified": false,
7806+ "workspaces": []
7807+ },
7808+ "requested_by": null,
7809+ "created_at": "2026-10-01T18:04:11.482Z",
7810+ "updated_at": "2026-10-01T18:09:47.305Z",
7811+ "closed_at": null,
7812+ "pull_count": 0,
7813+ "comment_count": 0,
7814+ "assignees": [
7815+ "syntaqx"
7816+ ],
7817+ "blocked_by": [],
7818+ "queued": false,
7819+ "agent": null,
7820+ "milestone": {
7821+ "number": 3,
7822+ "title": "Launch"
7823+ }
7824+ }
7825+ ],
7826+ "pulls": [
7827+ {
7828+ "id": "pr_01m43smh3vexsr5pmp60qwv0vs",
7829+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
7830+ "number": 14,
7831+ "issue": 12,
7832+ "title": "Greeting should name the caller",
7833+ "body": "Reads a name from the first argument. `hello ana` prints \"Hello, ana!\".",
7834+ "agent": "claude-code",
7835+ "runtime": "external",
7836+ "status": "open",
7837+ "fork": {
7838+ "namespace": "pulls",
7839+ "name": "pr_01m43smh3vexsr5pmp60qwv0vs"
7840+ },
7841+ "fork_repo_id": "rep_01m43smh5xd7aw2kq9tv0b4c8e",
7842+ "branch": null,
7843+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
7844+ "merge_base": null,
7845+ "merged_by": null,
7846+ "merged_at": null,
7847+ "superseded_by": null,
7848+ "check_status": "passed",
7849+ "files": [
7850+ {
7851+ "path": "src/main.rs",
7852+ "additions": 6,
7853+ "deletions": 2
7854+ }
7855+ ],
7856+ "assignees": [],
7857+ "reviewers": [
7858+ "ana"
7859+ ],
7860+ "labels": [],
7861+ "milestone": {
7862+ "number": 3,
7863+ "title": "Launch"
7864+ },
7865+ "base": "main",
7866+ "author": {
7867+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
7868+ "username": "syntaqx",
7869+ "kind": "user",
7870+ "verified": false,
7871+ "workspaces": []
7872+ },
7873+ "requested_by": null,
7874+ "created_at": "2026-10-01T18:20:02.117Z",
7875+ "updated_at": "2026-10-01T18:35:44.902Z",
7876+ "confidence": null
7877+ }
7878+ ]
7879+ }
7880+ },
7881+ "update_pull_request": {
7882+ "request": {
7883+ "base": "release/1.x",
7884+ "labels": [
7885+ "bug"
7886+ ]
7887+ },
7888+ "response": {
7889+ "id": "pr_01m43smh3vexsr5pmp60qwv0vs",
7890+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
7891+ "number": 14,
7892+ "issue": 12,
7893+ "title": "Greeting should name the caller",
7894+ "body": "Reads a name from the first argument. `hello ana` prints \"Hello, ana!\".",
7895+ "agent": "claude-code",
7896+ "runtime": "external",
7897+ "status": "open",
7898+ "fork": {
7899+ "namespace": "pulls",
7900+ "name": "pr_01m43smh3vexsr5pmp60qwv0vs"
7901+ },
7902+ "fork_repo_id": "rep_01m43smh5xd7aw2kq9tv0b4c8e",
7903+ "branch": null,
7904+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
7905+ "merge_base": null,
7906+ "merged_by": null,
7907+ "merged_at": null,
7908+ "superseded_by": null,
7909+ "check_status": "passed",
7910+ "files": [
7911+ {
7912+ "path": "src/main.rs",
7913+ "additions": 6,
7914+ "deletions": 2
7915+ }
7916+ ],
7917+ "assignees": [],
7918+ "reviewers": [
7919+ "ana"
7920+ ],
7921+ "labels": [
7922+ "bug"
7923+ ],
7924+ "milestone": null,
7925+ "base": "release/1.x",
7926+ "author": {
7927+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
7928+ "username": "syntaqx",
7929+ "kind": "user",
7930+ "verified": false,
7931+ "workspaces": []
7932+ },
7933+ "requested_by": null,
7934+ "created_at": "2026-10-01T18:20:02.117Z",
7935+ "updated_at": "2026-10-01T18:35:44.902Z",
7936+ "confidence": null
7937+ },
7938+ "notes": "A new base takes it out of the merge queue, and whether it is behind, merges cleanly and has the checks it needs is worked out against the new base."
51837939 }
51847940 }
+31−3
77 //! encoded again, so that every field the type has is sent, not only the
88 //! ones an example shows.
99
10−use g1t_contracts::{access, actions, integrations, repos, search, webhooks, work};
10+use g1t_contracts::{access, actions, codeowners, integrations, repos, search, teams, webhooks, work};
1111 use g1t_kit::wire::{self, USER_KEYED};
1212 use serde::Serialize;
1313 use serde::de::DeserializeOwned;
7979 return through::<access::RepoInvitation>(op, as_is);
8080 }
8181 Op::ListOutsideCollaborators => return through::<Vec<access::OutsideCollaborator>>(op, as_is),
82+ // Teams and code owners, also `snake_case`.
83+ Op::ListTeams | Op::ListChildTeams | Op::ListUserTeams => return through::<Vec<teams::Team>>(op, as_is),
84+ Op::GetTeam | Op::CreateTeam | Op::UpdateTeam | Op::SetTeamReviewAssignment => {
85+ return through::<teams::Team>(op, as_is);
86+ }
87+ Op::ListTeamMembers => return through::<Vec<teams::TeamMember>>(op, as_is),
88+ Op::SetTeamMember => return through::<teams::TeamMember>(op, as_is),
89+ Op::ListTeamRepos => return through::<Vec<teams::TeamRepo>>(op, as_is),
90+ Op::SetTeamRepo => return through::<teams::TeamRepo>(op, as_is),
91+ Op::DeleteTeam | Op::RemoveTeamMember | Op::RemoveTeamRepo => return through::<bool>(op, as_is),
92+ Op::GetCodeownersErrors => return through::<codeowners::CodeOwnersReport>(op, as_is),
8293 // Built by the API itself, in `snake_case`.
8394 Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is),
8495 Op::DismissSecurityAlert | Op::ReopenSecurityAlert => {
8697 }
8798 _ => {}
8899 }
89− let sent = as_services_send(example);
100+ let mut sent = as_services_send(example);
101+ // A pull request's code owners are `snake_case` inside it.
102+ if op == Op::GetPullRequest
103+ && let Some(code_owners) = example.get("code_owners")
104+ {
105+ sent["codeOwners"] = code_owners.clone();
106+ }
90107 match op {
91108 Op::CreateWorkspace | Op::UpdateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent),
92109 Op::ListRepos => through::<Vec<repos::Repo>>(op, sent),
113130 Op::GetIssue => through::<work::IssueDetail>(op, sent),
114131 Op::Delegate => through::<work::Delegated>(op, sent),
115132 Op::ListPullRequests => through::<Vec<work::Pull>>(op, sent),
133+ Op::UpdatePullRequest => through::<work::Pull>(op, sent),
134+ Op::ListLabels | Op::AddDefaultLabels | Op::ListIssueLabels => through::<Vec<work::Label>>(op, sent),
135+ Op::CreateLabel | Op::UpdateLabel => through::<work::Label>(op, sent),
136+ Op::ListMilestones => through::<Vec<work::Milestone>>(op, sent),
137+ Op::CreateMilestone | Op::UpdateMilestone => through::<work::Milestone>(op, sent),
138+ Op::GetMilestone => through::<work::MilestoneDetail>(op, sent),
116139 Op::GetPullRequest => through::<work::PullDetail>(op, sent),
117− Op::MarkPullRequestReady | Op::ClosePullRequest | Op::MergePullRequest | Op::AssignIssue => {
140+ Op::MarkPullRequestReady
141+ | Op::ClosePullRequest
142+ | Op::MergePullRequest
143+ | Op::AssignIssue
144+ | Op::RequestReviewers
145+ | Op::RemoveRequestedReviewers => {
118146 through::<work::Pull>(op, sent)
119147 }
120148 Op::ListWorkflows => through::<Vec<actions::Workflow>>(op, sent),
+223−5
33 use serde_json::{Map, Value};
44
55 use crate::operations::Op;
6+use crate::security::SecurityOp;
67
78 pub struct Route {
89 pub method: &'static str,
9495 Op::ListOutsideCollaborators,
9596 &[],
9697 ),
98+ // Teams: a workspace's groups of members, with roles on repositories.
99+ route("GET", "/workspaces/:workspace/teams", Op::ListTeams, &[("q", "query")]),
100+ route("POST", "/workspaces/:workspace/teams", Op::CreateTeam, &[]),
101+ route("GET", "/workspaces/:workspace/teams/:team", Op::GetTeam, &[]),
102+ route("PATCH", "/workspaces/:workspace/teams/:team", Op::UpdateTeam, &[]),
103+ route("DELETE", "/workspaces/:workspace/teams/:team", Op::DeleteTeam, &[]),
104+ route(
105+ "GET",
106+ "/workspaces/:workspace/teams/:team/members",
107+ Op::ListTeamMembers,
108+ &[("include_child_teams", "include_child_teams")],
109+ ),
110+ route("PUT", "/workspaces/:workspace/teams/:team/members/:username", Op::SetTeamMember, &[]),
111+ route("DELETE", "/workspaces/:workspace/teams/:team/members/:username", Op::RemoveTeamMember, &[]),
112+ route("GET", "/workspaces/:workspace/teams/:team/teams", Op::ListChildTeams, &[]),
113+ route("GET", "/workspaces/:workspace/teams/:team/repos", Op::ListTeamRepos, &[]),
114+ route("PUT", "/workspaces/:workspace/teams/:team/repos/:repo", Op::SetTeamRepo, &[]),
115+ route("DELETE", "/workspaces/:workspace/teams/:team/repos/:repo", Op::RemoveTeamRepo, &[]),
116+ route(
117+ "PUT",
118+ "/workspaces/:workspace/teams/:team/review_assignment",
119+ Op::SetTeamReviewAssignment,
120+ &[],
121+ ),
122+ route("GET", "/workspaces/:workspace/members/:username/teams", Op::ListUserTeams, &[]),
123+ // A workspace's billing: usage, budget, AI credit and invoices.
124+ route(
125+ "GET",
126+ "/workspaces/:workspace/usage",
127+ Op::GetUsage,
128+ &[("from", "from"), ("until", "until"), ("products", "products"), ("projects", "projects"), ("group_by", "group_by")],
129+ ),
130+ route("GET", "/workspaces/:workspace/budget", Op::GetBudget, &[]),
131+ route("PUT", "/workspaces/:workspace/budget", Op::SetBudget, &[]),
132+ route("GET", "/workspaces/:workspace/ai_credit", Op::GetAiCredit, &[]),
133+ route("POST", "/workspaces/:workspace/ai_credit/checkout", Op::BuyAiCredit, &[]),
134+ route("GET", "/workspaces/:workspace/invoices", Op::ListInvoices, &[]),
135+ route("GET", "/workspaces/:workspace/billing_details", Op::GetBillingDetails, &[]),
136+ // Code owners: the CODEOWNERS file, checked.
137+ route("GET", "/repos/:owner/:name/codeowners/errors", Op::GetCodeownersErrors, &[("ref", "ref")]),
97138 // Security alerts: secrets and vulnerable dependencies.
98139 route(
99140 "GET",
103144 ),
104145 route("POST", "/repos/:owner/:name/security/alerts/:id/dismiss", Op::DismissSecurityAlert, &[]),
105146 route("POST", "/repos/:owner/:name/security/alerts/:id/reopen", Op::ReopenSecurityAlert, &[]),
147+ // The security suite: secret scanning, code scanning, vulnerability
148+ // alerts and the supply chain, at the common addresses.
149+ route("GET", "/repos/:owner/:name/secret-scanning/alerts", Op::Security(SecurityOp::ListSecretAlerts), &[("state", "state"), ("secret_type", "secret_type"), ("validity", "validity"), ("bypassed", "bypassed")]),
150+ route("GET", "/workspaces/:workspace/secret-scanning/alerts", Op::Security(SecurityOp::ListSecretAlerts), &[("state", "state"), ("secret_type", "secret_type"), ("validity", "validity"), ("bypassed", "bypassed")]),
151+ route("GET", "/repos/:owner/:name/secret-scanning/alerts/:id", Op::Security(SecurityOp::GetSecretAlert), &[]),
152+ route("PATCH", "/repos/:owner/:name/secret-scanning/alerts/:id", Op::Security(SecurityOp::UpdateSecretAlert), &[]),
153+ route("GET", "/repos/:owner/:name/secret-scanning/alerts/:id/locations", Op::Security(SecurityOp::ListSecretLocations), &[]),
154+ route("POST", "/repos/:owner/:name/secret-scanning/alerts/:id/bypass", Op::Security(SecurityOp::BypassPushProtection), &[]),
155+ route("POST", "/repos/:owner/:name/secret-scanning/alerts/:id/validity", Op::Security(SecurityOp::CheckSecretValidity), &[]),
156+ route("GET", "/workspaces/:workspace/secret-scanning/bypass-requests", Op::Security(SecurityOp::ListBypassRequests), &[("state", "state"), ("repo", "repo")]),
157+ route("PATCH", "/workspaces/:workspace/secret-scanning/bypass-requests/:id", Op::Security(SecurityOp::ReviewBypassRequest), &[]),
158+ route("POST", "/repos/:owner/:name/secret-scanning/custom-patterns/dry-run", Op::Security(SecurityOp::DryRunCustomPattern), &[]),
159+ route("POST", "/workspaces/:workspace/secret-scanning/custom-patterns/dry-run", Op::Security(SecurityOp::DryRunCustomPattern), &[]),
160+ route("GET", "/repos/:owner/:name/secret-scanning/custom-patterns", Op::Security(SecurityOp::ListCustomPatterns), &[]),
161+ route("GET", "/workspaces/:workspace/secret-scanning/custom-patterns", Op::Security(SecurityOp::ListCustomPatterns), &[]),
162+ route("POST", "/repos/:owner/:name/secret-scanning/custom-patterns", Op::Security(SecurityOp::CreateCustomPattern), &[]),
163+ route("POST", "/workspaces/:workspace/secret-scanning/custom-patterns", Op::Security(SecurityOp::CreateCustomPattern), &[]),
164+ route("PATCH", "/repos/:owner/:name/secret-scanning/custom-patterns/:id", Op::Security(SecurityOp::UpdateCustomPattern), &[]),
165+ route("PATCH", "/workspaces/:workspace/secret-scanning/custom-patterns/:id", Op::Security(SecurityOp::UpdateCustomPattern), &[]),
166+ route("DELETE", "/repos/:owner/:name/secret-scanning/custom-patterns/:id", Op::Security(SecurityOp::DeleteCustomPattern), &[]),
167+ route("DELETE", "/workspaces/:workspace/secret-scanning/custom-patterns/:id", Op::Security(SecurityOp::DeleteCustomPattern), &[]),
168+ route("GET", "/repos/:owner/:name/code-scanning/alerts", Op::Security(SecurityOp::ListCodeAlerts), &[("state", "state"), ("severity", "severity"), ("tool", "tool"), ("rule_id", "rule_id")]),
169+ route("GET", "/workspaces/:workspace/code-scanning/alerts", Op::Security(SecurityOp::ListCodeAlerts), &[("state", "state"), ("severity", "severity"), ("tool", "tool"), ("rule_id", "rule_id")]),
170+ route("GET", "/repos/:owner/:name/code-scanning/alerts/:number", Op::Security(SecurityOp::GetCodeAlert), &[]),
171+ route("PATCH", "/repos/:owner/:name/code-scanning/alerts/:number", Op::Security(SecurityOp::UpdateCodeAlert), &[]),
172+ route("GET", "/repos/:owner/:name/code-scanning/analyses", Op::Security(SecurityOp::ListAnalyses), &[]),
173+ route("POST", "/repos/:owner/:name/code-scanning/sarifs", Op::Security(SecurityOp::UploadSarif), &[]),
174+ route("GET", "/repos/:owner/:name/code-scanning/sarifs/:id", Op::Security(SecurityOp::GetSarifUpload), &[]),
175+ route("GET", "/repos/:owner/:name/vulnerability-alerts", Op::Security(SecurityOp::ListVulnerabilityAlerts), &[("state", "state"), ("severity", "severity"), ("ecosystem", "ecosystem"), ("package", "package")]),
176+ route("GET", "/workspaces/:workspace/vulnerability-alerts", Op::Security(SecurityOp::ListVulnerabilityAlerts), &[("state", "state"), ("severity", "severity"), ("ecosystem", "ecosystem"), ("package", "package")]),
177+ route("GET", "/repos/:owner/:name/vulnerability-alerts/:id", Op::Security(SecurityOp::GetVulnerabilityAlert), &[]),
178+ route("PATCH", "/repos/:owner/:name/vulnerability-alerts/:id", Op::Security(SecurityOp::UpdateVulnerabilityAlert), &[]),
179+ route("POST", "/repos/:owner/:name/security/alerts/:id/fix", Op::Security(SecurityOp::FixAlert), &[]),
180+ route("GET", "/repos/:owner/:name/dependency-graph", Op::Security(SecurityOp::GetDependencyGraph), &[]),
181+ route("GET", "/repos/:owner/:name/dependency-graph/sbom", Op::Security(SecurityOp::GetSbom), &[]),
182+ route("GET", "/repos/:owner/:name/dependency-graph/compare/:basehead", Op::Security(SecurityOp::CompareDependencies), &[]),
183+ route("GET", "/repos/:owner/:name/security/settings", Op::Security(SecurityOp::GetSettings), &[]),
184+ route("PATCH", "/repos/:owner/:name/security/settings", Op::Security(SecurityOp::UpdateSettings), &[]),
185+ route("GET", "/workspaces/:workspace/security/settings", Op::Security(SecurityOp::GetWorkspaceSettings), &[]),
186+ route("PATCH", "/workspaces/:workspace/security/settings", Op::Security(SecurityOp::UpdateWorkspaceSettings), &[]),
187+ route("GET", "/workspaces/:workspace/security/overview", Op::Security(SecurityOp::GetOverview), &[("days", "days")]),
106188 route("GET", "/repos", Op::ListRepos, &[("q", "query")]),
107189 route(
108190 "GET",
184266 &[("before", "before")],
185267 ),
186268 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
269+ route("POST", "/repos/:owner/:name/labels", Op::CreateLabel, &[]),
270+ route("POST", "/repos/:owner/:name/labels/defaults", Op::AddDefaultLabels, &[]),
271+ route("PATCH", "/repos/:owner/:name/labels/:label", Op::UpdateLabel, &[]),
272+ route("DELETE", "/repos/:owner/:name/labels/:label", Op::DeleteLabel, &[]),
273+ route("GET", "/repos/:owner/:name/issues/:number/labels", Op::ListIssueLabels, &[]),
274+ route("POST", "/repos/:owner/:name/issues/:number/labels", Op::AddIssueLabels, &[]),
275+ route("PUT", "/repos/:owner/:name/issues/:number/labels", Op::SetIssueLabels, &[]),
276+ route("DELETE", "/repos/:owner/:name/issues/:number/labels", Op::RemoveIssueLabels, &[]),
277+ route("DELETE", "/repos/:owner/:name/issues/:number/labels/:label", Op::RemoveIssueLabels, &[]),
278+ route("GET", "/repos/:owner/:name/milestones", Op::ListMilestones, &[("state", "state")]),
279+ route("POST", "/repos/:owner/:name/milestones", Op::CreateMilestone, &[]),
280+ route("GET", "/repos/:owner/:name/milestones/:milestone", Op::GetMilestone, &[]),
281+ route("PATCH", "/repos/:owner/:name/milestones/:milestone", Op::UpdateMilestone, &[]),
282+ route("DELETE", "/repos/:owner/:name/milestones/:milestone", Op::DeleteMilestone, &[]),
187283 route(
188284 "GET",
189285 "/repos/:owner/:name/issues",
190286 Op::ListIssues,
191− &[("state", "state"), ("label", "label")],
287+ &[("state", "state"), ("label", "label"), ("milestone", "milestone")],
192288 ),
193289 route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]),
194290 route(
560656 "GET",
561657 "/repos/:owner/:name/pulls",
562658 Op::ListPullRequests,
563− &[("state", "state")],
659+ &[("state", "state"), ("label", "label"), ("milestone", "milestone"), ("base", "base")],
564660 ),
565661 route(
566662 "POST",
575671 &[],
576672 ),
577673 route(
674+ "PATCH",
675+ "/repos/:owner/:name/pulls/:number",
676+ Op::UpdatePullRequest,
677+ &[],
678+ ),
679+ route(
578680 "GET",
579681 "/repos/:owner/:name/pulls/:number/changes",
580682 Op::GetPullRequestChanges,
587689 &[],
588690 ),
589691 route(
692+ "POST",
693+ "/repos/:owner/:name/pulls/:number/requested_reviewers",
694+ Op::RequestReviewers,
695+ &[],
696+ ),
697+ route(
698+ "DELETE",
699+ "/repos/:owner/:name/pulls/:number/requested_reviewers",
700+ Op::RemoveRequestedReviewers,
701+ &[],
702+ ),
703+ route(
590704 "GET",
591705 "/repos/:owner/:name/pulls/:number/session",
592706 Op::ReadSession,
673787 ///
674788 /// The input is the JSON body, overlaid with the query parameters the route
675789 /// reads and then with what the path names: `owner` and `name` become
676−/// `repo`, and `number` becomes an integer.
790+/// `repo`, as does a team's `repo` with its `workspace`, and `number`
791+/// becomes an integer.
677792 pub fn resolve(
678793 method: &str,
679794 path: &str,
703818 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
704819 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
705820 }
706− for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username"] {
821+ for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username", "team", "basehead"] {
707822 if let Some(value) = param(key) {
708823 input.insert(key.to_owned(), Value::String(value.to_owned()));
709824 }
710825 }
711− // A branch name may hold slashes, sent URL-encoded as one segment.
826+ // A repository of a team's workspace, named by itself.
827+ if let (Some(workspace), Some(name)) = (param("workspace"), param("repo")) {
828+ input.insert("repo".to_owned(), Value::String(format!("{workspace}/{name}")));
829+ }
830+ // A branch name may hold slashes, sent URL-encoded as one segment, and
831+ // a label's name spaces.
712832 if let Some(branch) = param("branch") {
713833 input.insert("branch".to_owned(), Value::String(percent_decoded(branch)));
714834 }
835+ if let Some(label) = param("label") {
836+ input.insert("label".to_owned(), Value::String(percent_decoded(label)));
837+ }
838+ if let Some(milestone) = param("milestone") {
839+ // Not a number: zero, which no milestone has.
840+ input.insert("milestone".to_owned(), milestone.parse::<u32>().unwrap_or(0).into());
841+ }
715842 // GitHub says some things with the path alone.
716843 if route.path.ends_with("/enable") || route.path.ends_with("/disable") {
717844 input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable")));
794921 }
795922
796923 #[test]
924+ fn teams_are_addressed_by_workspace_and_slug() {
925+ let query = [("q".to_owned(), "back".to_owned())];
926+ let (route, input) = resolve("GET", "/workspaces/acme/teams", &query, Value::Null).unwrap();
927+ assert_eq!(route.op, Op::ListTeams);
928+ assert_eq!(input, json!({ "query": "back", "workspace": "acme" }));
929+ let (route, input) = resolve("PATCH", "/workspaces/acme/teams/backend", &[], json!({ "name": "Back end" })).unwrap();
930+ assert_eq!(route.op, Op::UpdateTeam);
931+ assert_eq!(input, json!({ "name": "Back end", "workspace": "acme", "team": "backend" }));
932+ let (route, input) =
933+ resolve("PUT", "/workspaces/acme/teams/backend/members/ana", &[], json!({ "role": "maintainer" })).unwrap();
934+ assert_eq!(route.op, Op::SetTeamMember);
935+ assert_eq!(input, json!({ "role": "maintainer", "workspace": "acme", "team": "backend", "username": "ana" }));
936+ let query = [("include_child_teams".to_owned(), "true".to_owned())];
937+ let (route, input) = resolve("GET", "/workspaces/acme/teams/backend/members", &query, Value::Null).unwrap();
938+ assert_eq!(route.op, Op::ListTeamMembers);
939+ assert_eq!(input, json!({ "include_child_teams": "true", "workspace": "acme", "team": "backend" }));
940+ // A repository is named by itself, in the team's workspace.
941+ let (route, input) =
942+ resolve("PUT", "/workspaces/acme/teams/backend/repos/rocket", &[], json!({ "role": "write" })).unwrap();
943+ assert_eq!(route.op, Op::SetTeamRepo);
944+ assert_eq!(input, json!({ "role": "write", "workspace": "acme", "team": "backend", "repo": "acme/rocket" }));
945+ let op = |method: &str, path: &str| resolve(method, path, &[], Value::Null).unwrap().0.op;
946+ assert_eq!(op("DELETE", "/workspaces/acme/teams/backend/repos/rocket"), Op::RemoveTeamRepo);
947+ assert_eq!(op("GET", "/workspaces/acme/teams/backend/teams"), Op::ListChildTeams);
948+ assert_eq!(op("GET", "/workspaces/acme/teams/backend/repos"), Op::ListTeamRepos);
949+ assert_eq!(op("PUT", "/workspaces/acme/teams/backend/review_assignment"), Op::SetTeamReviewAssignment);
950+ assert_eq!(op("DELETE", "/workspaces/acme/teams/backend"), Op::DeleteTeam);
951+ assert_eq!(op("POST", "/workspaces/acme/teams"), Op::CreateTeam);
952+ assert_eq!(op("GET", "/workspaces/acme/teams/backend"), Op::GetTeam);
953+ assert_eq!(op("DELETE", "/workspaces/acme/teams/backend/members/ana"), Op::RemoveTeamMember);
954+ let (route, input) = resolve("GET", "/workspaces/acme/members/ana/teams", &[], Value::Null).unwrap();
955+ assert_eq!(route.op, Op::ListUserTeams);
956+ assert_eq!(input, json!({ "workspace": "acme", "username": "ana" }));
957+ }
958+
959+ #[test]
960+ fn reviewers_are_requested_and_code_owners_checked_on_a_repository() {
961+ let body = json!({ "reviewers": ["ana"], "team_reviewers": ["backend"] });
962+ let (route, input) = resolve("POST", "/repos/acme/rocket/pulls/7/requested_reviewers", &[], body.clone()).unwrap();
963+ assert_eq!(route.op, Op::RequestReviewers);
964+ assert_eq!(
965+ input,
966+ json!({ "reviewers": ["ana"], "team_reviewers": ["backend"], "repo": "acme/rocket", "number": 7 })
967+ );
968+ let (route, _) = resolve("DELETE", "/repos/acme/rocket/pulls/7/requested_reviewers", &[], body).unwrap();
969+ assert_eq!(route.op, Op::RemoveRequestedReviewers);
970+ let query = [("ref".to_owned(), "main".to_owned())];
971+ let (route, input) = resolve("GET", "/repos/acme/rocket/codeowners/errors", &query, Value::Null).unwrap();
972+ assert_eq!(route.op, Op::GetCodeownersErrors);
973+ assert_eq!(input, json!({ "ref": "main", "repo": "acme/rocket" }));
974+ }
975+
976+ #[test]
797977 fn notifications_are_addressed_as_threads_and_by_issue() {
798978 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap();
799979 assert_eq!(route.op, Op::MarkThreadDone);
815995 }
816996
817997 #[test]
998+ fn labels_and_milestones_are_named_in_the_path() {
999+ let (route, input) = resolve("PATCH", "/repos/acme/web/labels/good%20first%20issue", &[], json!({ "color": "7057ff" })).unwrap();
1000+ assert_eq!(route.op, Op::UpdateLabel);
1001+ assert_eq!(input, json!({ "color": "7057ff", "label": "good first issue", "repo": "acme/web" }));
1002+ let (route, input) = resolve("DELETE", "/repos/acme/web/issues/7/labels/bug", &[], Value::Null).unwrap();
1003+ assert_eq!(route.op, Op::RemoveIssueLabels);
1004+ assert_eq!(input, json!({ "label": "bug", "number": 7, "repo": "acme/web" }));
1005+ let (route, input) = resolve("DELETE", "/repos/acme/web/issues/7/labels", &[], Value::Null).unwrap();
1006+ assert_eq!(route.op, Op::RemoveIssueLabels);
1007+ assert_eq!(input, json!({ "number": 7, "repo": "acme/web" }));
1008+ let (route, _) = resolve("POST", "/repos/acme/web/labels/defaults", &[], Value::Null).unwrap();
1009+ assert_eq!(route.op, Op::AddDefaultLabels);
1010+ let (route, input) = resolve("PATCH", "/repos/acme/web/milestones/3", &[], json!({ "state": "closed" })).unwrap();
1011+ assert_eq!(route.op, Op::UpdateMilestone);
1012+ assert_eq!(input, json!({ "state": "closed", "milestone": 3, "repo": "acme/web" }));
1013+ let (route, input) = resolve("PATCH", "/repos/acme/web/pulls/9", &[], json!({ "base": "release" })).unwrap();
1014+ assert_eq!(route.op, Op::UpdatePullRequest);
1015+ assert_eq!(input, json!({ "base": "release", "number": 9, "repo": "acme/web" }));
1016+ }
1017+
1018+ #[test]
1019+ fn billing_is_addressed_by_workspace() {
1020+ let query = [("from".to_owned(), "2026-10-01".to_owned()), ("products".to_owned(), "agent,sandboxes".to_owned())];
1021+ let (route, input) = resolve("GET", "/workspaces/acme/usage", &query, Value::Null).unwrap();
1022+ assert_eq!(route.op, Op::GetUsage);
1023+ assert_eq!(input, json!({ "from": "2026-10-01", "products": "agent,sandboxes", "workspace": "acme" }));
1024+ let (route, input) = resolve("PUT", "/workspaces/acme/budget", &[], json!({ "alerts": [50] })).unwrap();
1025+ assert_eq!(route.op, Op::SetBudget);
1026+ assert_eq!(input, json!({ "alerts": [50], "workspace": "acme" }));
1027+ let op = |method: &str, path: &str| resolve(method, path, &[], Value::Null).unwrap().0.op;
1028+ assert_eq!(op("GET", "/workspaces/acme/budget"), Op::GetBudget);
1029+ assert_eq!(op("GET", "/workspaces/acme/ai_credit"), Op::GetAiCredit);
1030+ assert_eq!(op("POST", "/workspaces/acme/ai_credit/checkout"), Op::BuyAiCredit);
1031+ assert_eq!(op("GET", "/workspaces/acme/invoices"), Op::ListInvoices);
1032+ assert_eq!(op("GET", "/workspaces/acme/billing_details"), Op::GetBillingDetails);
1033+ }
1034+
1035+ #[test]
8181036 fn query_parameters_are_renamed() {
8191037 let query = [
8201038 ("q".to_owned(), "parser".to_owned()),
+1067−0
1+//! The security suite over REST and MCP: secret scanning (alerts, where
2+//! each secret is, push protection bypasses and their review, validity
3+//! checks, custom patterns), code scanning (alerts, analyses, SARIF
4+//! uploads), vulnerability alerts, the dependency graph with its SBOM and
5+//! dependency review, "Fix with g1t", settings, and the workspace's
6+//! overview.
7+//!
8+//! The addresses follow the common ones (`/repos/{owner}/{name}/secret-
9+//! scanning/alerts`, `/code-scanning/sarifs`, `/dependency-graph/sbom`),
10+//! in g1t's spelling: no version prefix, `snake_case` throughout. The
11+//! security service decides who may see and change what, and which parts
12+//! need the Security and quality activation (a 402 says so); this module
13+//! reads the input and gives each answer its public shape.
14+
15+use g1t_contracts::repos::RepoPath;
16+use g1t_contracts::security::{AlertChange, AlertState, DismissArgs, DismissReason, ReopenArgs, SecretFinding, SecurityOverview, Vulnerability};
17+use g1t_contracts::security_suite::*;
18+use g1t_contracts::{FailureCode, Outcome, Viewer};
19+use serde::Serialize;
20+use serde::de::DeserializeOwned;
21+use serde_json::{Value, json};
22+use worker::Result;
23+
24+use crate::operations::Services;
25+
26+/// One operation of the suite.
27+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28+pub enum SecurityOp {
29+ ListSecretAlerts,
30+ GetSecretAlert,
31+ UpdateSecretAlert,
32+ ListSecretLocations,
33+ BypassPushProtection,
34+ CheckSecretValidity,
35+ ListBypassRequests,
36+ ReviewBypassRequest,
37+ ListCustomPatterns,
38+ CreateCustomPattern,
39+ UpdateCustomPattern,
40+ DeleteCustomPattern,
41+ DryRunCustomPattern,
42+ ListCodeAlerts,
43+ GetCodeAlert,
44+ UpdateCodeAlert,
45+ ListAnalyses,
46+ UploadSarif,
47+ GetSarifUpload,
48+ ListVulnerabilityAlerts,
49+ GetVulnerabilityAlert,
50+ UpdateVulnerabilityAlert,
51+ FixAlert,
52+ GetDependencyGraph,
53+ GetSbom,
54+ CompareDependencies,
55+ GetSettings,
56+ UpdateSettings,
57+ GetWorkspaceSettings,
58+ UpdateWorkspaceSettings,
59+ GetOverview,
60+}
61+
62+impl SecurityOp {
63+ /// Every one: `Op::ALL` lists each as `Op::Security(…)`, which a test
64+ /// checks against this.
65+ #[cfg(test)]
66+ pub const ALL: [SecurityOp; 31] = [
67+ SecurityOp::ListSecretAlerts,
68+ SecurityOp::GetSecretAlert,
69+ SecurityOp::UpdateSecretAlert,
70+ SecurityOp::ListSecretLocations,
71+ SecurityOp::BypassPushProtection,
72+ SecurityOp::CheckSecretValidity,
73+ SecurityOp::ListBypassRequests,
74+ SecurityOp::ReviewBypassRequest,
75+ SecurityOp::ListCustomPatterns,
76+ SecurityOp::CreateCustomPattern,
77+ SecurityOp::UpdateCustomPattern,
78+ SecurityOp::DeleteCustomPattern,
79+ SecurityOp::DryRunCustomPattern,
80+ SecurityOp::ListCodeAlerts,
81+ SecurityOp::GetCodeAlert,
82+ SecurityOp::UpdateCodeAlert,
83+ SecurityOp::ListAnalyses,
84+ SecurityOp::UploadSarif,
85+ SecurityOp::GetSarifUpload,
86+ SecurityOp::ListVulnerabilityAlerts,
87+ SecurityOp::GetVulnerabilityAlert,
88+ SecurityOp::UpdateVulnerabilityAlert,
89+ SecurityOp::FixAlert,
90+ SecurityOp::GetDependencyGraph,
91+ SecurityOp::GetSbom,
92+ SecurityOp::CompareDependencies,
93+ SecurityOp::GetSettings,
94+ SecurityOp::UpdateSettings,
95+ SecurityOp::GetWorkspaceSettings,
96+ SecurityOp::UpdateWorkspaceSettings,
97+ SecurityOp::GetOverview,
98+ ];
99+
100+ pub fn name(self) -> &'static str {
101+ match self {
102+ SecurityOp::ListSecretAlerts => "list_secret_scanning_alerts",
103+ SecurityOp::GetSecretAlert => "get_secret_scanning_alert",
104+ SecurityOp::UpdateSecretAlert => "update_secret_scanning_alert",
105+ SecurityOp::ListSecretLocations => "list_secret_scanning_locations",
106+ SecurityOp::BypassPushProtection => "bypass_push_protection",
107+ SecurityOp::CheckSecretValidity => "check_secret_validity",
108+ SecurityOp::ListBypassRequests => "list_bypass_requests",
109+ SecurityOp::ReviewBypassRequest => "review_bypass_request",
110+ SecurityOp::ListCustomPatterns => "list_custom_patterns",
111+ SecurityOp::CreateCustomPattern => "create_custom_pattern",
112+ SecurityOp::UpdateCustomPattern => "update_custom_pattern",
113+ SecurityOp::DeleteCustomPattern => "delete_custom_pattern",
114+ SecurityOp::DryRunCustomPattern => "dry_run_custom_pattern",
115+ SecurityOp::ListCodeAlerts => "list_code_scanning_alerts",
116+ SecurityOp::GetCodeAlert => "get_code_scanning_alert",
117+ SecurityOp::UpdateCodeAlert => "update_code_scanning_alert",
118+ SecurityOp::ListAnalyses => "list_code_scanning_analyses",
119+ SecurityOp::UploadSarif => "upload_sarif",
120+ SecurityOp::GetSarifUpload => "get_sarif_upload",
121+ SecurityOp::ListVulnerabilityAlerts => "list_vulnerability_alerts",
122+ SecurityOp::GetVulnerabilityAlert => "get_vulnerability_alert",
123+ SecurityOp::UpdateVulnerabilityAlert => "update_vulnerability_alert",
124+ SecurityOp::FixAlert => "fix_security_alert",
125+ SecurityOp::GetDependencyGraph => "get_dependency_graph",
126+ SecurityOp::GetSbom => "get_sbom",
127+ SecurityOp::CompareDependencies => "compare_dependencies",
128+ SecurityOp::GetSettings => "get_security_settings",
129+ SecurityOp::UpdateSettings => "update_security_settings",
130+ SecurityOp::GetWorkspaceSettings => "get_workspace_security_settings",
131+ SecurityOp::UpdateWorkspaceSettings => "update_workspace_security_settings",
132+ SecurityOp::GetOverview => "get_security_overview",
133+ }
134+ }
135+
136+ /// For the API reference: "List secret scanning alerts".
137+ pub fn title(self) -> &'static str {
138+ match self {
139+ SecurityOp::ListSecretAlerts => "List secret scanning alerts",
140+ SecurityOp::GetSecretAlert => "Get a secret scanning alert",
141+ SecurityOp::UpdateSecretAlert => "Dismiss or reopen a secret scanning alert",
142+ SecurityOp::ListSecretLocations => "List where a secret was found",
143+ SecurityOp::BypassPushProtection => "Bypass push protection",
144+ SecurityOp::CheckSecretValidity => "Check whether a secret still works",
145+ SecurityOp::ListBypassRequests => "List push protection bypass requests",
146+ SecurityOp::ReviewBypassRequest => "Review a bypass request",
147+ SecurityOp::ListCustomPatterns => "List custom patterns",
148+ SecurityOp::CreateCustomPattern => "Create a custom pattern",
149+ SecurityOp::UpdateCustomPattern => "Update a custom pattern",
150+ SecurityOp::DeleteCustomPattern => "Delete a custom pattern",
151+ SecurityOp::DryRunCustomPattern => "Dry-run a custom pattern",
152+ SecurityOp::ListCodeAlerts => "List code scanning alerts",
153+ SecurityOp::GetCodeAlert => "Get a code scanning alert",
154+ SecurityOp::UpdateCodeAlert => "Dismiss or reopen a code scanning alert",
155+ SecurityOp::ListAnalyses => "List code scanning analyses",
156+ SecurityOp::UploadSarif => "Upload a SARIF file",
157+ SecurityOp::GetSarifUpload => "Get a SARIF upload",
158+ SecurityOp::ListVulnerabilityAlerts => "List vulnerability alerts",
159+ SecurityOp::GetVulnerabilityAlert => "Get a vulnerability alert",
160+ SecurityOp::UpdateVulnerabilityAlert => "Dismiss or reopen a vulnerability alert",
161+ SecurityOp::FixAlert => "Fix an alert with g1t",
162+ SecurityOp::GetDependencyGraph => "Get the dependency graph",
163+ SecurityOp::GetSbom => "Export an SBOM",
164+ SecurityOp::CompareDependencies => "Compare dependencies",
165+ SecurityOp::GetSettings => "Get a repository's security settings",
166+ SecurityOp::UpdateSettings => "Update a repository's security settings",
167+ SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings",
168+ SecurityOp::UpdateWorkspaceSettings => "Update a workspace's security settings",
169+ SecurityOp::GetOverview => "Get the security overview",
170+ }
171+ }
172+
173+ pub fn description(self) -> &'static str {
174+ match self {
175+ SecurityOp::ListSecretAlerts => "List secret scanning alerts: secrets found in pushes (blocked) and in history (open), newest first, in a repository or (with workspace) across a workspace. Filter by state (open, dismissed, fixed), secret_type, validity (active, inactive, unknown, unsupported) and bypassed. The secret itself is never returned: a preview and a fingerprint-based id only.",
176+ SecurityOp::GetSecretAlert => "Get one secret scanning alert by id (sec_…), with every place it was found, its activity, its bypass requests, and whether you may bypass it or only ask to.",
177+ SecurityOp::UpdateSecretAlert => "Dismiss a secret scanning alert (state dismissed, with a reason: false_positive, used_in_tests, revoked or wont_fix, and an optional comment) or reopen it (state open). Revoked marks it fixed; the others let pushes carrying it through. Takes the Admin role.",
178+ SecurityOp::ListSecretLocations => "List every place a secret was found: file, line, commit and whether a push or the history scan found it.",
179+ SecurityOp::BypassPushProtection => "Push past push protection for a blocked secret, with a reason: false_positive or used_in_tests (the alert is closed with that reason) or will_fix_later (it stays open, to be rotated). Recorded on the alert and in the audit log. With delegated bypass on, someone who does not review bypasses makes a request instead, which owners and the repository's admins approve or deny; the answer says which happened. Push again once it is bypassed or approved.",
180+ SecurityOp::CheckSecretValidity => "Ask a landed secret's issuer whether it still works, and mark the alert active or inactive. The check is the issuer's own read-only identity call over HTTPS; the secret goes nowhere else. Needs validity checks on for the workspace (and the Security and quality activation on a private repository). Formats with no safe check answer unsupported.",
181+ SecurityOp::ListBypassRequests => "List a workspace's push protection bypass requests, pending first. Owners and repository admins see every request; anyone else their own. Filter by state (pending, approved, denied, cancelled) or repo.",
182+ SecurityOp::ReviewBypassRequest => "Approve or deny a bypass request (owners and the repository's admins, never your own), or cancel your own. An approved request bypasses push protection for that secret, as its requester asked.",
183+ SecurityOp::ListCustomPatterns => "List custom secret patterns: a repository's own and the ones it inherits from its workspace (with repo), or a workspace's (with workspace).",
184+ SecurityOp::CreateCustomPattern => "Create a custom secret pattern: a name, a regular expression for the secret, optional regular expressions for what comes right before and after it, and test strings. Patterns run in linear time (no look-around or back-references) and within size limits. With publish true, push protection and scans use it at once and the history is scanned again for it; otherwise it is a draft. A repository's takes Admin; a workspace's, an owner. On a private repository it needs the Security and quality activation.",
185+ SecurityOp::UpdateCustomPattern => "Change a custom pattern, publish it, or turn it back into a draft (publish false). Returns where it matched each test string.",
186+ SecurityOp::DeleteCustomPattern => "Delete a custom pattern. Alerts it found stay.",
187+ SecurityOp::DryRunCustomPattern => "Run a pattern over the default branch without saving it: of the repository, or (with workspace) of up to ten of its repositories, or those named in repos. Returns the files read and up to fifty matches each, masked.",
188+ SecurityOp::ListCodeAlerts => "List code scanning alerts: problems a tool reported on the default branch, one per tool, category and fingerprint, open first and worst first. In a repository, or (with workspace) across a workspace. Filter by state, severity, tool and rule_id.",
189+ SecurityOp::GetCodeAlert => "Get one code scanning alert by number, with its rule, location, activity and the analyses that reported it.",
190+ SecurityOp::UpdateCodeAlert => "Dismiss a code scanning alert (state dismissed, dismissed_reason false_positive, wont_fix or used_in_tests, optional dismissed_comment) or reopen it (state open). A fixed alert reopens by itself when an analysis reports it again.",
191+ SecurityOp::ListAnalyses => "List code scanning analyses, newest first: each upload's run of one tool on one commit, with how many results it had and the alerts it opened and fixed.",
192+ SecurityOp::UploadSarif => "Upload a SARIF 2.1.0 file: sarif is the file gzipped and base64-encoded; commit_sha the full commit; ref refs/heads/<branch> or refs/pull/<number>/head. For the default branch, new results open alerts and results no longer reported fix theirs. For a pull request, its results new to it on lines it changes become review comments and the Code scanning check, which fails at the repository's threshold. Read at once; the answer says complete or failed and why. Needs the Security and quality activation on a private repository.",
193+ SecurityOp::GetSarifUpload => "Get a SARIF upload by id (sar_…): whether it was read, the analyses it made, and what was wrong.",
194+ SecurityOp::ListVulnerabilityAlerts => "List vulnerability alerts: a package a lockfile resolves with a known advisory, open first and worst first, with the security update g1t opened for it. In a repository, or (with workspace) across a workspace. Filter by state, severity, ecosystem and package.",
195+ SecurityOp::GetVulnerabilityAlert => "Get one vulnerability alert by id (vul_…).",
196+ SecurityOp::UpdateVulnerabilityAlert => "Dismiss a vulnerability alert (state dismissed, with a reason: fix_started, no_bandwidth, tolerable_risk, inaccurate or not_used, and an optional comment) or reopen it (state open).",
197+ SecurityOp::FixAlert => "Put g1t on an issue to fix an alert: a code scanning alert (cod_…), a vulnerable dependency (vul_…) or a secret in the code (sec_…; rotating it stays with you). Its pull request lands through the repository's required checks. The agent's run is charged as agent usage. Returns the issue, and whether the agent started.",
198+ SecurityOp::GetDependencyGraph => "Get the dependency graph: every package the lockfiles on the default branch resolve, per lockfile, with whether it is direct or transitive (where the lockfile says), for development, its license when recorded, its package URL and its open vulnerability alerts.",
199+ SecurityOp::GetSbom => "Export the dependency graph as an SPDX 2.3 JSON document, in sbom. Every package is named by its package URL.",
200+ SecurityOp::CompareDependencies => "Compare the dependencies at two commits, branches or tags (basehead, as base...head): what was added and removed per lockfile, with the known vulnerabilities of what was added and whether it passes the repository's dependency review policy. Needs the Security and quality activation on a private repository.",
201+ SecurityOp::GetSettings => "Get a repository's security settings: when the Code scanning check fails, dependency review and its policy, its workspace's settings, and whether the paid features are on for it.",
202+ SecurityOp::UpdateSettings => "Change a repository's security settings: code_scanning_gate (none, errors, critical, high, medium or any), dependency_review, review_fail_on (critical, high, medium, low or none), review_deny_licenses (SPDX ids) and review_comment. Takes the Maintain role. Require the Code scanning and Dependency review checks in branch protection to gate merges on them.",
203+ SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings (delegated bypass, validity checks) and whether it has the Security and quality activation.",
204+ SecurityOp::UpdateWorkspaceSettings => "Turn delegated bypass and validity checks on or off for a workspace. Owners only.",
205+ SecurityOp::GetOverview => "Get a workspace's security overview: open alerts by type and severity, how many opened and closed in the last days (7 to 90, 30 by default), a daily trend, and for each repository which features are on and what is open, most in need first. Private repositories count with the Security and quality activation only.",
206+ }
207+ }
208+
209+ /// Whether the operation is about one repository named by `repo`
210+ /// (rather than a workspace, or either).
211+ pub fn needs_repo(self) -> bool {
212+ !matches!(
213+ self,
214+ SecurityOp::ListSecretAlerts
215+ | SecurityOp::ListCodeAlerts
216+ | SecurityOp::ListVulnerabilityAlerts
217+ | SecurityOp::ListBypassRequests
218+ | SecurityOp::ReviewBypassRequest
219+ | SecurityOp::ListCustomPatterns
220+ | SecurityOp::CreateCustomPattern
221+ | SecurityOp::UpdateCustomPattern
222+ | SecurityOp::DeleteCustomPattern
223+ | SecurityOp::DryRunCustomPattern
224+ | SecurityOp::GetWorkspaceSettings
225+ | SecurityOp::UpdateWorkspaceSettings
226+ | SecurityOp::GetOverview
227+ )
228+ }
229+
230+ pub fn input(self) -> Value {
231+ let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
232+ let either = |mut properties: Value| {
233+ properties["repo"] = json!({ "type": "string", "description": "Repository as \"owner/name\". Or give workspace." });
234+ properties["workspace"] = json!({ "type": "string", "description": "Instead of repo: the workspace's slug, for all of it (or its own, for patterns)." });
235+ properties
236+ };
237+ let secret_id = || json!({ "type": "string", "description": "The alert's id: sec_…" });
238+ let number = || json!({ "type": "integer", "description": "The code scanning alert's number." });
239+ let state = || json!({ "type": "string", "enum": ["open", "dismissed", "fixed"], "description": "Only alerts in this state." });
240+ let severity = || json!({ "type": "string", "enum": ["critical", "high", "medium", "low", "unknown"], "description": "Only alerts of this severity." });
241+ let set_state = || json!({ "type": "string", "enum": ["open", "dismissed"], "description": "dismissed, with a reason, or open to reopen." });
242+ let comment = || json!({ "type": "string", "description": "Why, in a sentence; kept with the alert. At most 500 characters." });
243+ let pattern_fields = |mut properties: Value| {
244+ properties["pattern_name"] = json!({ "type": "string", "description": "What people call it: \"Acme API key\"." });
245+ properties["pattern"] = json!({ "type": "string", "description": "The secret's format, as a regular expression (the regex crate's syntax: no look-around or back-references). At most 1,000 characters; it may not match an empty string." });
246+ properties["before"] = json!({ "type": "string", "description": "What must come right before the secret, as a regular expression. Default: the start of the line or a character that is not a letter or digit." });
247+ properties["after"] = json!({ "type": "string", "description": "What must come right after it. Default: the end of the line or a character that is not a letter or digit." });
248+ properties
249+ };
250+ let workspace = || json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." });
251+ let (properties, required): (Value, &[&str]) = match self {
252+ SecurityOp::ListSecretAlerts => (
253+ either(json!({
254+ "state": state(),
255+ "secret_type": { "type": "string", "description": "Only this kind of secret: aws_access_key, github_token, custom_pattern, …" },
256+ "validity": { "type": "string", "enum": ["active", "inactive", "unknown", "unsupported"], "description": "Only alerts whose issuer said this when last asked." },
257+ "bypassed": { "type": "boolean", "description": "Only alerts someone bypassed push protection for (true), or not (false)." },
258+ })),
259+ &[],
260+ ),
261+ SecurityOp::GetSecretAlert | SecurityOp::ListSecretLocations | SecurityOp::CheckSecretValidity => {
262+ (json!({ "repo": repo(), "id": secret_id() }), &["repo", "id"])
263+ }
264+ SecurityOp::UpdateSecretAlert => (
265+ json!({
266+ "repo": repo(),
267+ "id": secret_id(),
268+ "state": set_state(),
269+ "reason": { "type": "string", "enum": ["false_positive", "used_in_tests", "revoked", "wont_fix"], "description": "Why it is dismissed. revoked marks it fixed." },
270+ "comment": comment(),
271+ }),
272+ &["repo", "id", "state"],
273+ ),
274+ SecurityOp::BypassPushProtection => (
275+ json!({
276+ "repo": repo(),
277+ "id": secret_id(),
278+ "reason": { "type": "string", "enum": BypassReason::ALL.map(BypassReason::as_str), "description": "false_positive: not a secret. used_in_tests: a value for tests. will_fix_later: real, to be rotated (the alert stays open)." },
279+ "comment": comment(),
280+ }),
281+ &["repo", "id", "reason"],
282+ ),
283+ SecurityOp::ListBypassRequests => (
284+ json!({
285+ "workspace": workspace(),
286+ "repo": { "type": "string", "description": "Only this repository's, as \"owner/name\"." },
287+ "state": { "type": "string", "enum": ["pending", "approved", "denied", "cancelled"], "description": "Only requests in this state." },
288+ }),
289+ &["workspace"],
290+ ),
291+ SecurityOp::ReviewBypassRequest => (
292+ json!({
293+ "workspace": workspace(),
294+ "id": { "type": "string", "description": "The request's id: byp_…" },
295+ "decision": { "type": "string", "enum": ["approve", "deny", "cancel"], "description": "approve or deny (reviewers), or cancel (your own)." },
296+ "comment": comment(),
297+ }),
298+ &["workspace", "id", "decision"],
299+ ),
300+ SecurityOp::ListCustomPatterns => (either(json!({})), &[]),
301+ SecurityOp::CreateCustomPattern => (
302+ either(pattern_fields(json!({
303+ "test_strings": { "type": "array", "items": { "type": "string" }, "description": "Up to 20 strings to show the pattern working on." },
304+ "publish": { "type": "boolean", "description": "Use it in push protection and scans now (true), or keep a draft (false, the default)." },
305+ }))),
306+ &["pattern_name", "pattern"],
307+ ),
308+ SecurityOp::UpdateCustomPattern => (
309+ either(pattern_fields(json!({
310+ "id": { "type": "string", "description": "The pattern's id: pat_…" },
311+ "test_strings": { "type": "array", "items": { "type": "string" }, "description": "Up to 20 strings to show the pattern working on." },
312+ "publish": { "type": "boolean", "description": "Published (true) or a draft (false)." },
313+ }))),
314+ &["id", "pattern_name", "pattern"],
315+ ),
316+ SecurityOp::DeleteCustomPattern => (either(json!({ "id": { "type": "string", "description": "The pattern's id: pat_…" } })), &["id"]),
317+ SecurityOp::DryRunCustomPattern => (
318+ either(pattern_fields(json!({
319+ "repos": { "type": "array", "items": { "type": "string" }, "description": "With workspace: repository names to run it on; the first ten when empty." },
320+ }))),
321+ &["pattern"],
322+ ),
323+ SecurityOp::ListCodeAlerts => (
324+ either(json!({
325+ "state": state(),
326+ "severity": severity(),
327+ "tool": { "type": "string", "description": "Only this tool's: \"ESLint\"." },
328+ "rule_id": { "type": "string", "description": "Only this rule's." },
329+ })),
330+ &[],
331+ ),
332+ SecurityOp::GetCodeAlert => (json!({ "repo": repo(), "number": number() }), &["repo", "number"]),
333+ SecurityOp::UpdateCodeAlert => (
334+ json!({
335+ "repo": repo(),
336+ "number": number(),
337+ "state": set_state(),
338+ "dismissed_reason": { "type": "string", "enum": ["false_positive", "wont_fix", "used_in_tests"], "description": "Why it is dismissed." },
339+ "dismissed_comment": comment(),
340+ }),
341+ &["repo", "number", "state"],
342+ ),
343+ SecurityOp::ListAnalyses => (json!({ "repo": repo() }), &["repo"]),
344+ SecurityOp::UploadSarif => (
345+ json!({
346+ "repo": repo(),
347+ "commit_sha": { "type": "string", "description": "The full hash of the commit analysed." },
348+ "ref": { "type": "string", "description": "refs/heads/<branch>, or refs/pull/<number>/head (or /merge) for a pull request." },
349+ "sarif": { "type": "string", "description": "The SARIF 2.1.0 file, gzipped, then base64-encoded. At most 10 MB encoded and 40 MB unzipped." },
350+ "tool_name": { "type": "string", "description": "The tool's name, when the file has one run and you want another name for it." },
351+ "category": { "type": "string", "description": "Which analysis this is, when a repository runs several of one tool. Default: the run's automationDetails.id, or the tool's name." },
352+ "checkout_uri": { "type": "string", "description": "Where the files were checked out (file:///home/runner/work/repo), so absolute paths become repository paths." },
353+ }),
354+ &["repo", "commit_sha", "ref", "sarif"],
355+ ),
356+ SecurityOp::GetSarifUpload => (json!({ "repo": repo(), "id": { "type": "string", "description": "The upload's id: sar_…" } }), &["repo", "id"]),
357+ SecurityOp::ListVulnerabilityAlerts => (
358+ either(json!({
359+ "state": state(),
360+ "severity": severity(),
361+ "ecosystem": { "type": "string", "description": "Only this ecosystem's: npm, crates.io, Go or PyPI." },
362+ "package": { "type": "string", "description": "Only this package's." },
363+ })),
364+ &[],
365+ ),
366+ SecurityOp::GetVulnerabilityAlert => (json!({ "repo": repo(), "id": { "type": "string", "description": "The alert's id: vul_…" } }), &["repo", "id"]),
367+ SecurityOp::UpdateVulnerabilityAlert => (
368+ json!({
369+ "repo": repo(),
370+ "id": { "type": "string", "description": "The alert's id: vul_…" },
371+ "state": set_state(),
372+ "reason": { "type": "string", "enum": ["fix_started", "no_bandwidth", "tolerable_risk", "inaccurate", "not_used"], "description": "Why it is dismissed." },
373+ "comment": comment(),
374+ }),
375+ &["repo", "id", "state"],
376+ ),
377+ SecurityOp::FixAlert => (
378+ json!({ "repo": repo(), "id": { "type": "string", "description": "The alert's id: cod_…, vul_… or sec_…" } }),
379+ &["repo", "id"],
380+ ),
381+ SecurityOp::GetDependencyGraph | SecurityOp::GetSbom | SecurityOp::GetSettings => (json!({ "repo": repo() }), &["repo"]),
382+ SecurityOp::CompareDependencies => (
383+ json!({
384+ "repo": repo(),
385+ "basehead": { "type": "string", "description": "base...head: two commits, branches or tags, e.g. main...my-branch." },
386+ }),
387+ &["repo", "basehead"],
388+ ),
389+ SecurityOp::UpdateSettings => (
390+ json!({
391+ "repo": repo(),
392+ "code_scanning_gate": { "type": "string", "enum": ["none", "errors", "critical", "high", "medium", "any"], "description": "When a pull request's Code scanning check fails: never, on errors, or on new results of this security severity or worse (and errors)." },
393+ "dependency_review": { "type": "boolean", "description": "Whether pull requests get the Dependency review check." },
394+ "review_fail_on": { "type": "string", "enum": ["critical", "high", "medium", "low", "none"], "description": "The lowest severity of a known vulnerability in an added package that fails the review." },
395+ "review_deny_licenses": { "type": "array", "items": { "type": "string" }, "description": "SPDX license ids an added package may not have." },
396+ "review_comment": { "type": "boolean", "description": "Whether the review comments its summary on the pull request." },
397+ }),
398+ &["repo"],
399+ ),
400+ SecurityOp::GetWorkspaceSettings => (json!({ "workspace": workspace() }), &["workspace"]),
401+ SecurityOp::UpdateWorkspaceSettings => (
402+ json!({
403+ "workspace": workspace(),
404+ "delegated_bypass": { "type": "boolean", "description": "Bypasses need an owner's or the repository's admins' approval." },
405+ "validity_checks": { "type": "boolean", "description": "Ask issuers whether secrets still work, where that can be done safely." },
406+ }),
407+ &["workspace"],
408+ ),
409+ SecurityOp::GetOverview => (
410+ json!({ "workspace": workspace(), "days": { "type": "integer", "description": "Days of trend, 7 to 90. Default 30." } }),
411+ &["workspace"],
412+ ),
413+ };
414+ let mut schema = json!({ "type": "object", "properties": properties });
415+ if !required.is_empty() {
416+ schema["required"] = json!(required);
417+ }
418+ schema
419+ }
420+}
421+
422+fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
423+ Ok(Outcome::fail(code, message))
424+}
425+
426+fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> {
427+ Ok(Outcome::Ok(serde_json::to_value(value)?))
428+}
429+
430+fn text(input: &Value, key: &str) -> Option<String> {
431+ input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned)
432+}
433+
434+fn flag(input: &Value, key: &str) -> Option<bool> {
435+ match &input[key] {
436+ Value::Bool(value) => Some(*value),
437+ Value::String(text) => match text.trim() {
438+ "true" | "1" => Some(true),
439+ "false" | "0" => Some(false),
440+ _ => None,
441+ },
442+ _ => None,
443+ }
444+}
445+
446+fn whole(input: &Value, key: &str) -> Option<u32> {
447+ match &input[key] {
448+ Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
449+ Value::String(digits) => digits.trim().parse().ok(),
450+ _ => None,
451+ }
452+}
453+
454+fn strings(input: &Value, key: &str) -> Vec<String> {
455+ input[key].as_array().map(|items| items.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect()).unwrap_or_default()
456+}
457+
458+/// One of `allowed`, or why not.
459+fn one_of(input: &Value, key: &str, allowed: &[&str]) -> std::result::Result<Option<String>, String> {
460+ match text(input, key) {
461+ None => Ok(None),
462+ Some(given) => {
463+ let lower = given.to_lowercase();
464+ if allowed.contains(&lower.as_str()) {
465+ Ok(Some(lower))
466+ } else {
467+ Err(format!("{key} is {}, not {given}.", allowed.join(", ")))
468+ }
469+ }
470+ }
471+}
472+
473+/// Filters for secret scanning alerts.
474+#[derive(Debug, Default, PartialEq)]
475+pub(crate) struct SecretFilters {
476+ pub state: Option<AlertState>,
477+ pub secret_type: Option<String>,
478+ pub validity: Option<String>,
479+ pub bypassed: Option<bool>,
480+}
481+
482+pub(crate) fn secret_filters(input: &Value) -> std::result::Result<SecretFilters, String> {
483+ Ok(SecretFilters {
484+ state: one_of(input, "state", &["open", "dismissed", "fixed"])?.and_then(|state| AlertState::parse(&state)),
485+ secret_type: text(input, "secret_type"),
486+ validity: one_of(input, "validity", &["active", "inactive", "unknown", "unsupported"])?,
487+ bypassed: match &input["bypassed"] {
488+ Value::Null => None,
489+ _ => Some(flag(input, "bypassed").ok_or("bypassed is true or false.")?),
490+ },
491+ })
492+}
493+
494+impl SecretFilters {
495+ pub(crate) fn keeps(&self, secret: &SecretFinding) -> bool {
496+ self.state.is_none_or(|state| secret.state == state)
497+ && self.secret_type.as_deref().is_none_or(|kind| secret.kind == kind)
498+ && self.validity.as_deref().is_none_or(|validity| secret.validity.as_deref().unwrap_or("unknown") == validity)
499+ && self.bypassed.is_none_or(|bypassed| secret.bypass.is_some() == bypassed)
500+ }
501+}
502+
503+/// Filters for code scanning alerts.
504+#[derive(Debug, Default, PartialEq)]
505+pub(crate) struct CodeFilters {
506+ pub state: Option<AlertState>,
507+ pub severity: Option<String>,
508+ pub tool: Option<String>,
509+ pub rule_id: Option<String>,
510+}
511+
512+pub(crate) fn code_filters(input: &Value) -> std::result::Result<CodeFilters, String> {
513+ Ok(CodeFilters {
514+ state: one_of(input, "state", &["open", "dismissed", "fixed"])?.and_then(|state| AlertState::parse(&state)),
515+ severity: one_of(input, "severity", &["critical", "high", "medium", "low", "unknown"])?,
516+ tool: text(input, "tool"),
517+ rule_id: text(input, "rule_id"),
518+ })
519+}
520+
521+impl CodeFilters {
522+ pub(crate) fn keeps(&self, alert: &CodeAlert) -> bool {
523+ self.state.is_none_or(|state| alert.state == state)
524+ && self.severity.as_deref().is_none_or(|severity| alert.severity == severity)
525+ && self.tool.as_deref().is_none_or(|tool| alert.tool.eq_ignore_ascii_case(tool))
526+ && self.rule_id.as_deref().is_none_or(|rule| alert.rule_id == rule)
527+ }
528+}
529+
530+/// Filters for vulnerability alerts.
531+#[derive(Debug, Default, PartialEq)]
532+pub(crate) struct VulnerabilityFilters {
533+ pub state: Option<AlertState>,
534+ pub severity: Option<String>,
535+ pub ecosystem: Option<String>,
536+ pub package: Option<String>,
537+}
538+
539+pub(crate) fn vulnerability_filters(input: &Value) -> std::result::Result<VulnerabilityFilters, String> {
540+ Ok(VulnerabilityFilters {
541+ state: one_of(input, "state", &["open", "dismissed", "fixed"])?.and_then(|state| AlertState::parse(&state)),
542+ severity: one_of(input, "severity", &["critical", "high", "medium", "low", "unknown"])?,
543+ ecosystem: text(input, "ecosystem"),
544+ package: text(input, "package"),
545+ })
546+}
547+
548+impl VulnerabilityFilters {
549+ pub(crate) fn keeps(&self, vuln: &Vulnerability) -> bool {
550+ self.state.is_none_or(|state| vuln.state == state)
551+ && self.severity.as_deref().is_none_or(|severity| vuln.severity == severity)
552+ && self.ecosystem.as_deref().is_none_or(|ecosystem| vuln.ecosystem.eq_ignore_ascii_case(ecosystem))
553+ && self.package.as_deref().is_none_or(|package| vuln.package == package)
554+ }
555+}
556+
557+/// `base...head` (or `base..head`), as compare_dependencies reads it.
558+pub(crate) fn base_head(text: &str) -> Option<(String, String)> {
559+ let (base, head) = text.split_once("...").or_else(|| text.split_once(".."))?;
560+ let (base, head) = (base.trim(), head.trim());
561+ (!base.is_empty() && !head.is_empty()).then(|| (base.to_owned(), head.to_owned()))
562+}
563+
564+/// What dismissing or reopening an alert of `kind` asks: the reason, if
565+/// dismissing, checked against the reasons that kind takes.
566+pub(crate) fn state_change(input: &Value, reason_key: &str, reasons: &[DismissReason]) -> std::result::Result<Option<DismissReason>, String> {
567+ match text(input, "state").as_deref() {
568+ Some("open") => Ok(None),
569+ Some("dismissed") => {
570+ let names: Vec<&str> = reasons.iter().map(|reason| reason.as_str()).collect();
571+ let given = text(input, reason_key).ok_or_else(|| format!("Give {reason_key}: one of {}.", names.join(", ")))?;
572+ DismissReason::parse(&given)
573+ .filter(|reason| reasons.contains(reason))
574+ .map(Some)
575+ .ok_or_else(|| format!("{reason_key} is {}, not {given}.", names.join(", ")))
576+ }
577+ _ => Err("state is open or dismissed.".to_owned()),
578+ }
579+}
580+
581+const SECRET_REASONS: [DismissReason; 4] = [DismissReason::FalsePositive, DismissReason::UsedInTests, DismissReason::Revoked, DismissReason::WontFix];
582+const CODE_REASONS: [DismissReason; 3] = [DismissReason::FalsePositive, DismissReason::WontFix, DismissReason::UsedInTests];
583+const DEPENDENCY_REASONS: [DismissReason; 5] = [
584+ DismissReason::FixStarted,
585+ DismissReason::NoBandwidth,
586+ DismissReason::TolerableRisk,
587+ DismissReason::Inaccurate,
588+ DismissReason::NotUsed,
589+];
590+
591+async fn call<A: Serialize, T: DeserializeOwned>(services: &Services, method: &str, args: &A) -> Result<Outcome<T>> {
592+ g1t_kit::call(&services.security, method, args).await
593+}
594+
595+/// Passes a service's outcome through as it is.
596+async fn pass<A: Serialize>(services: &Services, method: &str, args: &A) -> Result<Outcome<Value>> {
597+ call(services, method, args).await
598+}
599+
600+fn path_of(input: &Value) -> Option<RepoPath> {
601+ crate::operations::repo_path(input)
602+}
603+
604+pub async fn run(op: SecurityOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
605+ let actor = || viewer.clone().unwrap_or_default();
606+ let repo = path_of(input);
607+ let workspace = text(input, "workspace").map(|slug| slug.to_lowercase());
608+ let need_repo = || "Give the repository as \"owner/name\".".to_owned();
609+ // Operations on a repository or a workspace: which.
610+ let scope_repo = repo.clone();
611+ let scope_workspace = || workspace.clone().or_else(|| repo.as_ref().map(|repo| repo.namespace.to_lowercase()));
612+ match op {
613+ SecurityOp::ListSecretAlerts => {
614+ let filters = match secret_filters(input) {
615+ Ok(filters) => filters,
616+ Err(message) => return failed(FailureCode::Invalid, &message),
617+ };
618+ match (scope_repo, workspace) {
619+ (Some(repo), _) => {
620+ let overview: Outcome<SecurityOverview> =
621+ call(services, "overview", &g1t_contracts::security::OverviewArgs { repo, viewer: viewer.clone() }).await?;
622+ match overview {
623+ Outcome::Ok(overview) => {
624+ let alerts: Vec<SecretFinding> = overview.secrets.into_iter().filter(|secret| filters.keeps(secret)).collect();
625+ ok(&alerts)
626+ }
627+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
628+ }
629+ }
630+ (None, Some(workspace)) => {
631+ let found: Outcome<Vec<WorkspaceAlert>> = call(
632+ services,
633+ "workspace_alerts",
634+ &WorkspaceAlertsArgs { viewer: viewer.clone(), workspace, alert_type: AlertType::SecretScanning },
635+ )
636+ .await?;
637+ match found {
638+ Outcome::Ok(found) => {
639+ let alerts: Vec<WorkspaceAlert> =
640+ found.into_iter().filter(|alert| alert.secret.as_ref().is_some_and(|secret| filters.keeps(secret))).collect();
641+ ok(&alerts)
642+ }
643+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
644+ }
645+ }
646+ (None, None) => failed(FailureCode::Invalid, "Give repo, or workspace for all of one."),
647+ }
648+ }
649+ SecurityOp::GetSecretAlert | SecurityOp::ListSecretLocations => {
650+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
651+ let id = text(input, "id").unwrap_or_default();
652+ let detail: Outcome<SecretAlertDetail> = call(services, "secret_alert", &SecretAlertArgs { viewer: viewer.clone(), repo, id }).await?;
653+ match (detail, op) {
654+ (Outcome::Ok(detail), SecurityOp::ListSecretLocations) => ok(&detail.locations),
655+ (Outcome::Ok(detail), _) => ok(&detail),
656+ (Outcome::Fail(failure), _) => Ok(Outcome::Fail(failure)),
657+ }
658+ }
659+ SecurityOp::UpdateSecretAlert | SecurityOp::UpdateVulnerabilityAlert => {
660+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
661+ let id = text(input, "id").unwrap_or_default();
662+ let (reasons, wants): (&[DismissReason], &str) = match op {
663+ SecurityOp::UpdateSecretAlert => (&SECRET_REASONS, "sec_"),
664+ _ => (&DEPENDENCY_REASONS, "vul_"),
665+ };
666+ if !id.starts_with(wants) {
667+ return failed(FailureCode::NotFound, "No such alert.");
668+ }
669+ let reason = match state_change(input, "reason", reasons) {
670+ Ok(reason) => reason,
671+ Err(message) => return failed(FailureCode::Invalid, &message),
672+ };
673+ let changed: Outcome<AlertChange> = match reason {
674+ Some(reason) => {
675+ let comment = text(input, "comment").unwrap_or_default();
676+ call(services, "dismiss", &DismissArgs { actor: actor(), repo, id, reason, comment }).await?
677+ }
678+ None => call(services, "reopen", &ReopenArgs { actor: actor(), repo, id }).await?,
679+ };
680+ match changed {
681+ Outcome::Ok(AlertChange { secret: Some(secret), .. }) => ok(&secret),
682+ Outcome::Ok(AlertChange { vulnerability: Some(vuln), .. }) => ok(&vuln),
683+ Outcome::Ok(_) => failed(FailureCode::NotFound, "No such alert."),
684+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
685+ }
686+ }
687+ SecurityOp::BypassPushProtection => {
688+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
689+ let Some(reason) = text(input, "reason").as_deref().and_then(BypassReason::parse) else {
690+ return failed(FailureCode::Invalid, "reason is false_positive, used_in_tests or will_fix_later.");
691+ };
692+ let args = BypassArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default(), reason, comment: text(input, "comment").unwrap_or_default() };
693+ pass(services, "bypass", &args).await
694+ }
695+ SecurityOp::CheckSecretValidity => {
696+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
697+ pass(services, "check_validity", &CheckValidityArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default() }).await
698+ }
699+ SecurityOp::ListBypassRequests => {
700+ let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
701+ let state = match one_of(input, "state", &["pending", "approved", "denied", "cancelled"]) {
702+ Ok(state) => state,
703+ Err(message) => return failed(FailureCode::Invalid, &message),
704+ };
705+ pass(services, "bypass_requests", &BypassRequestsArgs { viewer: viewer.clone(), workspace, repo, state }).await
706+ }
707+ SecurityOp::ReviewBypassRequest => {
708+ let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
709+ let decision = match one_of(input, "decision", &["approve", "deny", "cancel"]) {
710+ Ok(Some(decision)) => decision,
711+ Ok(None) => return failed(FailureCode::Invalid, "decision is approve, deny or cancel."),
712+ Err(message) => return failed(FailureCode::Invalid, &message),
713+ };
714+ let args = ReviewBypassArgs {
715+ actor: actor(),
716+ workspace,
717+ id: text(input, "id").unwrap_or_default(),
718+ decision,
719+ comment: text(input, "comment").unwrap_or_default(),
720+ };
721+ pass(services, "review_bypass", &args).await
722+ }
723+ SecurityOp::ListCustomPatterns => {
724+ let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") };
725+ pass(services, "custom_patterns", &CustomPatternsArgs { viewer: viewer.clone(), workspace, repo: scope_repo }).await
726+ }
727+ SecurityOp::CreateCustomPattern | SecurityOp::UpdateCustomPattern => {
728+ let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") };
729+ let args = SaveCustomPatternArgs {
730+ actor: actor(),
731+ workspace,
732+ repo: scope_repo,
733+ id: if op == SecurityOp::UpdateCustomPattern { text(input, "id") } else { None },
734+ name: text(input, "pattern_name").unwrap_or_default(),
735+ pattern: input["pattern"].as_str().unwrap_or_default().to_owned(),
736+ before: text(input, "before"),
737+ after: text(input, "after"),
738+ test_strings: strings(input, "test_strings"),
739+ publish: flag(input, "publish").unwrap_or(false),
740+ };
741+ pass(services, "save_custom_pattern", &args).await
742+ }
743+ SecurityOp::DeleteCustomPattern => {
744+ let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") };
745+ let args = DeleteCustomPatternArgs { actor: actor(), workspace, repo: scope_repo, id: text(input, "id").unwrap_or_default() };
746+ match call::<_, bool>(services, "delete_custom_pattern", &args).await? {
747+ Outcome::Ok(_) => ok(&json!({ "deleted": true })),
748+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
749+ }
750+ }
751+ SecurityOp::DryRunCustomPattern => {
752+ let Some(workspace) = scope_workspace() else { return failed(FailureCode::Invalid, "Give repo or workspace.") };
753+ let args = DryRunPatternArgs {
754+ actor: actor(),
755+ workspace,
756+ repo: scope_repo,
757+ repos: strings(input, "repos"),
758+ pattern: input["pattern"].as_str().unwrap_or_default().to_owned(),
759+ before: text(input, "before"),
760+ after: text(input, "after"),
761+ };
762+ pass(services, "dry_run_pattern", &args).await
763+ }
764+ SecurityOp::ListCodeAlerts => {
765+ let filters = match code_filters(input) {
766+ Ok(filters) => filters,
767+ Err(message) => return failed(FailureCode::Invalid, &message),
768+ };
769+ match (scope_repo, workspace) {
770+ (Some(repo), _) => match call::<_, CodeScanning>(services, "code_scanning", &CodeScanningArgs { viewer: viewer.clone(), repo }).await? {
771+ Outcome::Ok(scanning) => {
772+ let alerts: Vec<CodeAlert> = scanning.alerts.into_iter().filter(|alert| filters.keeps(alert)).collect();
773+ ok(&alerts)
774+ }
775+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
776+ },
777+ (None, Some(workspace)) => {
778+ let found: Outcome<Vec<WorkspaceAlert>> = call(
779+ services,
780+ "workspace_alerts",
781+ &WorkspaceAlertsArgs { viewer: viewer.clone(), workspace, alert_type: AlertType::CodeScanning },
782+ )
783+ .await?;
784+ match found {
785+ Outcome::Ok(found) => {
786+ let alerts: Vec<WorkspaceAlert> =
787+ found.into_iter().filter(|alert| alert.code.as_ref().is_some_and(|code| filters.keeps(code))).collect();
788+ ok(&alerts)
789+ }
790+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
791+ }
792+ }
793+ (None, None) => failed(FailureCode::Invalid, "Give repo, or workspace for all of one."),
794+ }
795+ }
796+ SecurityOp::GetCodeAlert => {
797+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
798+ pass(services, "code_alert", &CodeAlertArgs { viewer: viewer.clone(), repo, number: whole(input, "number").unwrap_or(0) }).await
799+ }
800+ SecurityOp::UpdateCodeAlert => {
801+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
802+ let reason = match state_change(input, "dismissed_reason", &CODE_REASONS) {
803+ Ok(reason) => reason,
804+ Err(message) => return failed(FailureCode::Invalid, &message),
805+ };
806+ let args = SetCodeAlertStateArgs {
807+ actor: actor(),
808+ repo,
809+ number: whole(input, "number").unwrap_or(0),
810+ state: if reason.is_some() { AlertState::Dismissed } else { AlertState::Open },
811+ reason,
812+ comment: text(input, "dismissed_comment").unwrap_or_default(),
813+ };
814+ pass(services, "set_code_alert_state", &args).await
815+ }
816+ SecurityOp::ListAnalyses => {
817+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
818+ match call::<_, CodeScanning>(services, "code_scanning", &CodeScanningArgs { viewer: viewer.clone(), repo }).await? {
819+ Outcome::Ok(scanning) => ok(&scanning.analyses),
820+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
821+ }
822+ }
823+ SecurityOp::UploadSarif => {
824+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
825+ let (Some(commit_sha), Some(git_ref), Some(sarif)) = (text(input, "commit_sha"), text(input, "ref"), text(input, "sarif")) else {
826+ return failed(FailureCode::Invalid, "Give commit_sha, ref and sarif (the file gzipped and base64-encoded).");
827+ };
828+ if sarif.len() > g1t_scan_limits::MAX_UPLOAD_BYTES {
829+ return failed(FailureCode::Invalid, "The upload is larger than 10 MB.");
830+ }
831+ let args = UploadSarifArgs {
832+ actor: actor(),
833+ repo,
834+ commit_sha,
835+ git_ref,
836+ sarif,
837+ tool_name: text(input, "tool_name"),
838+ category: text(input, "category"),
839+ checkout_uri: text(input, "checkout_uri"),
840+ };
841+ pass(services, "upload_sarif", &args).await
842+ }
843+ SecurityOp::GetSarifUpload => {
844+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
845+ pass(services, "sarif_status", &SarifStatusArgs { viewer: viewer.clone(), repo, id: text(input, "id").unwrap_or_default() }).await
846+ }
847+ SecurityOp::ListVulnerabilityAlerts => {
848+ let filters = match vulnerability_filters(input) {
849+ Ok(filters) => filters,
850+ Err(message) => return failed(FailureCode::Invalid, &message),
851+ };
852+ match (scope_repo, workspace) {
853+ (Some(repo), _) => {
854+ let overview: Outcome<SecurityOverview> =
855+ call(services, "overview", &g1t_contracts::security::OverviewArgs { repo, viewer: viewer.clone() }).await?;
856+ match overview {
857+ Outcome::Ok(overview) => {
858+ let alerts: Vec<Vulnerability> = overview.vulnerabilities.into_iter().filter(|vuln| filters.keeps(vuln)).collect();
859+ ok(&alerts)
860+ }
861+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
862+ }
863+ }
864+ (None, Some(workspace)) => {
865+ let found: Outcome<Vec<WorkspaceAlert>> = call(
866+ services,
867+ "workspace_alerts",
868+ &WorkspaceAlertsArgs { viewer: viewer.clone(), workspace, alert_type: AlertType::Vulnerability },
869+ )
870+ .await?;
871+ match found {
872+ Outcome::Ok(found) => {
873+ let alerts: Vec<WorkspaceAlert> =
874+ found.into_iter().filter(|alert| alert.vulnerability.as_ref().is_some_and(|vuln| filters.keeps(vuln))).collect();
875+ ok(&alerts)
876+ }
877+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
878+ }
879+ }
880+ (None, None) => failed(FailureCode::Invalid, "Give repo, or workspace for all of one."),
881+ }
882+ }
883+ SecurityOp::GetVulnerabilityAlert => {
884+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
885+ let id = text(input, "id").unwrap_or_default();
886+ let overview: Outcome<SecurityOverview> =
887+ call(services, "overview", &g1t_contracts::security::OverviewArgs { repo, viewer: viewer.clone() }).await?;
888+ match overview {
889+ Outcome::Ok(overview) => match overview.vulnerabilities.into_iter().find(|vuln| vuln.id == id) {
890+ Some(vuln) => ok(&vuln),
891+ None => failed(FailureCode::NotFound, "No such alert."),
892+ },
893+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
894+ }
895+ }
896+ SecurityOp::FixAlert => {
897+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
898+ pass(services, "fix_alert", &FixAlertArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default() }).await
899+ }
900+ SecurityOp::GetDependencyGraph => {
901+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
902+ pass(services, "dependency_graph", &DependencyGraphArgs { viewer: viewer.clone(), repo }).await
903+ }
904+ SecurityOp::GetSbom => {
905+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
906+ // The document goes out as SPDX spells it: `sbom` is passed
907+ // through untouched (g1t_kit::wire::USER_KEYED).
908+ match call::<_, Value>(services, "sbom", &SbomArgs { viewer: viewer.clone(), repo }).await? {
909+ Outcome::Ok(document) => ok(&json!({ "sbom": document })),
910+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
911+ }
912+ }
913+ SecurityOp::CompareDependencies => {
914+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
915+ let Some((base, head)) = text(input, "basehead").as_deref().and_then(base_head) else {
916+ return failed(FailureCode::Invalid, "basehead is base...head, e.g. main...my-branch.");
917+ };
918+ pass(services, "dependency_review", &DependencyReviewArgs { viewer: viewer.clone(), repo, base, head }).await
919+ }
920+ SecurityOp::GetSettings => {
921+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
922+ pass(services, "security_settings", &SecuritySettingsArgs { viewer: viewer.clone(), repo }).await
923+ }
924+ SecurityOp::UpdateSettings => {
925+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
926+ // What is not given stays as it is.
927+ let current: Outcome<SecuritySettingsView> =
928+ call(services, "security_settings", &SecuritySettingsArgs { viewer: viewer.clone(), repo: repo.clone() }).await?;
929+ let mut settings = match current {
930+ Outcome::Ok(view) => view.settings,
931+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
932+ };
933+ if let Some(gate) = text(input, "code_scanning_gate") {
934+ settings.code_scanning_gate = gate.to_lowercase();
935+ }
936+ if let Some(on) = flag(input, "dependency_review") {
937+ settings.dependency_review = on;
938+ }
939+ if let Some(fail_on) = text(input, "review_fail_on") {
940+ settings.review_fail_on = fail_on.to_lowercase();
941+ }
942+ if input["review_deny_licenses"].is_array() {
943+ settings.review_deny_licenses = strings(input, "review_deny_licenses");
944+ }
945+ if let Some(on) = flag(input, "review_comment") {
946+ settings.review_comment = on;
947+ }
948+ pass(services, "set_security_settings", &SetSecuritySettingsArgs { actor: actor(), repo, settings }).await
949+ }
950+ SecurityOp::GetWorkspaceSettings => {
951+ let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
952+ pass(services, "workspace_security_settings", &WorkspaceSecuritySettingsArgs { viewer: viewer.clone(), workspace }).await
953+ }
954+ SecurityOp::UpdateWorkspaceSettings => {
955+ let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
956+ let current: Outcome<WorkspaceSecurityView> =
957+ call(services, "workspace_security_settings", &WorkspaceSecuritySettingsArgs { viewer: viewer.clone(), workspace: workspace.clone() }).await?;
958+ let mut settings = match current {
959+ Outcome::Ok(view) => view.settings,
960+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
961+ };
962+ if let Some(on) = flag(input, "delegated_bypass") {
963+ settings.delegated_bypass = on;
964+ }
965+ if let Some(on) = flag(input, "validity_checks") {
966+ settings.validity_checks = on;
967+ }
968+ pass(services, "set_workspace_security_settings", &SetWorkspaceSecuritySettingsArgs { actor: actor(), workspace, settings }).await
969+ }
970+ SecurityOp::GetOverview => {
971+ let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
972+ pass(services, "security_overview", &WorkspaceOverviewArgs { viewer: viewer.clone(), workspace, days: whole(input, "days") }).await
973+ }
974+ }
975+}
976+
977+/// Limits the API checks before passing an upload on.
978+mod g1t_scan_limits {
979+ /// As the security service's: 10 MB gzipped and base64-encoded.
980+ pub const MAX_UPLOAD_BYTES: usize = 10 * 1024 * 1024;
981+}
982+
983+#[cfg(test)]
984+mod tests {
985+ use super::*;
986+
987+ #[test]
988+ fn every_one_is_an_operation() {
989+ for op in SecurityOp::ALL {
990+ assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Security(op)), "{}", op.name());
991+ }
992+ }
993+
994+ #[test]
995+ fn names_are_unique_and_found_again() {
996+ let mut names: Vec<&str> = SecurityOp::ALL.iter().map(|op| op.name()).collect();
997+ names.sort();
998+ names.dedup();
999+ assert_eq!(names.len(), SecurityOp::ALL.len());
1000+ }
1001+
1002+ #[test]
1003+ fn secret_filters_are_read_as_words() {
1004+ let filters = secret_filters(&json!({ "state": "OPEN", "validity": "active", "bypassed": "true", "secret_type": "github_token" })).unwrap();
1005+ assert_eq!(filters.state, Some(AlertState::Open));
1006+ assert_eq!(filters.validity.as_deref(), Some("active"));
1007+ assert_eq!(filters.bypassed, Some(true));
1008+ assert!(secret_filters(&json!({ "validity": "maybe" })).unwrap_err().contains("validity is active, inactive"));
1009+ assert!(secret_filters(&json!({ "bypassed": "perhaps" })).is_err());
1010+ assert_eq!(secret_filters(&json!({})).unwrap(), SecretFilters::default());
1011+ }
1012+
1013+ #[test]
1014+ fn a_state_change_needs_a_reason_its_kind_takes() {
1015+ assert_eq!(state_change(&json!({ "state": "open" }), "reason", &SECRET_REASONS), Ok(None));
1016+ assert_eq!(
1017+ state_change(&json!({ "state": "dismissed", "reason": "revoked" }), "reason", &SECRET_REASONS),
1018+ Ok(Some(DismissReason::Revoked))
1019+ );
1020+ assert!(state_change(&json!({ "state": "dismissed", "reason": "not_used" }), "reason", &SECRET_REASONS).unwrap_err().contains("reason is false_positive"));
1021+ assert!(state_change(&json!({ "state": "dismissed" }), "dismissed_reason", &CODE_REASONS).unwrap_err().starts_with("Give dismissed_reason"));
1022+ assert!(state_change(&json!({ "state": "fixed" }), "reason", &CODE_REASONS).is_err());
1023+ }
1024+
1025+ #[test]
1026+ fn base_and_head_are_split_at_the_dots() {
1027+ assert_eq!(base_head("main...feature/x"), Some(("main".into(), "feature/x".into())));
1028+ assert_eq!(base_head("v1.0..v1.1"), Some(("v1.0".into(), "v1.1".into())));
1029+ assert_eq!(base_head("main"), None);
1030+ assert_eq!(base_head("...head"), None);
1031+ }
1032+
1033+ #[test]
1034+ fn code_and_vulnerability_filters_check_their_words() {
1035+ assert!(code_filters(&json!({ "severity": "severe" })).unwrap_err().contains("severity is critical"));
1036+ let filters = vulnerability_filters(&json!({ "ecosystem": "npm", "state": "dismissed" })).unwrap();
1037+ assert_eq!(filters.state, Some(AlertState::Dismissed));
1038+ }
1039+
1040+ #[test]
1041+ fn a_read_only_token_sees_only_the_security_reads() {
1042+ use g1t_contracts::scopes::{Scope, scope_for};
1043+ for op in SecurityOp::ALL {
1044+ let scope = scope_for(op.name()).unwrap_or_else(|| panic!("{} has no scope", op.name()));
1045+ assert!(matches!(scope, Scope::SecurityRead | Scope::SecurityWrite), "{}", op.name());
1046+ }
1047+ assert_eq!(scope_for("get_sbom"), Some(Scope::SecurityRead));
1048+ assert_eq!(scope_for("upload_sarif"), Some(Scope::SecurityWrite));
1049+ // Fixing an alert also opens an issue and spends agent time.
1050+ let needed = g1t_contracts::scopes::needed("fix_security_alert", &json!({}));
1051+ assert_eq!(needed, [Scope::SecurityWrite, Scope::IssuesWrite, Scope::AgentsRun]);
1052+ // No agent decides about security.
1053+ for name in ["bypass_push_protection", "review_bypass_request", "update_security_settings", "fix_security_alert"] {
1054+ assert!(g1t_contracts::credentials::NEVER.contains(&name), "{name}");
1055+ }
1056+ }
1057+
1058+ #[test]
1059+ fn workspace_wide_operations_do_not_need_a_repository() {
1060+ for op in [SecurityOp::GetOverview, SecurityOp::ListBypassRequests, SecurityOp::ListCustomPatterns] {
1061+ assert!(!op.needs_repo());
1062+ }
1063+ assert!(SecurityOp::UploadSarif.needs_repo());
1064+ let required = SecurityOp::UploadSarif.input()["required"].clone();
1065+ assert_eq!(required, json!(["repo", "commit_sha", "ref", "sarif"]));
1066+ }
1067+}
+173−8
1919 use serde_json::{Map, Value, json};
2020
2121 use crate::operations::Op;
22+use crate::security::SecurityOp;
2223
2324 pub struct Action {
2425 pub name: &'static str,
5758 Tool {
5859 name: "repository",
5960 title: "Repositories",
60− description: "Repositories: find, read and create them, change their settings, and see and dismiss their security alerts (secrets and vulnerable dependencies). Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.",
61+ description: "Repositories: find, read and create them, change their settings, check their CODEOWNERS file, manage their labels and milestones, and see and dismiss their security alerts (secrets and vulnerable dependencies). Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.",
6162 default_action: None,
6263 actions: &[
6364 a("list", Op::ListRepos, "Repositories you can see"),
6768 a("get_settings", Op::GetRepoSettings, "Branch protection: required checks, approvals, how pull requests merge"),
6869 a("update_settings", Op::UpdateRepoSettings, "Change branch protection and how pull requests merge"),
6970 a("check_names", Op::ListCheckNames, "Check names reported lately, to require on the default branch"),
70− a("list_labels", Op::ListLabels, "Labels in use"),
71+ a("codeowners", Op::GetCodeownersErrors, "Problems in its CODEOWNERS file, by line"),
72+ a("list_labels", Op::ListLabels, "Labels, with colors and how many issues and pull requests carry each"),
73+ a("create_label", Op::CreateLabel, "Create a label"),
74+ a("update_label", Op::UpdateLabel, "Rename a label or change its color or description"),
75+ a("delete_label", Op::DeleteLabel, "Delete a label, from everything that carries it"),
76+ a("add_default_labels", Op::AddDefaultLabels, "Add the default labels it is missing"),
77+ a("list_milestones", Op::ListMilestones, "Milestones, with progress and due dates"),
78+ a("get_milestone", Op::GetMilestone, "One milestone with its issues and pull requests"),
79+ a("create_milestone", Op::CreateMilestone, "Create a milestone"),
80+ a("update_milestone", Op::UpdateMilestone, "Change a milestone's title, description, due date or state"),
81+ a("delete_milestone", Op::DeleteMilestone, "Delete a milestone"),
7182 a("list_events", Op::ListEvents, "Timeline: pushes, issues, pull requests, comments"),
7283 a("rename_branch", Op::RenameBranch, "Rename a branch"),
7384 a("rename", Op::RenameRepo, "Rename it; old addresses redirect"),
93104 a("list", Op::ListIssues, "Issues on a repository, newest first"),
94105 a("get", Op::GetIssue, "One issue with comments and its pull requests"),
95106 a("create", Op::CreateIssue, "Open an issue"),
96− a("update", Op::UpdateIssue, "Change title, body, labels or assignees"),
107+ a("update", Op::UpdateIssue, "Change title, body, labels, milestone or assignees"),
108+ a("labels", Op::ListIssueLabels, "The labels an issue or pull request carries"),
109+ a("add_labels", Op::AddIssueLabels, "Add labels to an issue or pull request"),
110+ a("set_labels", Op::SetIssueLabels, "Replace the labels of an issue or pull request"),
111+ a("remove_labels", Op::RemoveIssueLabels, "Take labels off an issue or pull request"),
97112 a("close", Op::CloseIssue, "Close it without a pull request"),
98113 a("reopen", Op::ReopenIssue, "Reopen it"),
99114 a("comment", Op::AddComment, "Comment on an issue or pull request; path and line for one line of a change"),
103118 Tool {
104119 name: "pull_request",
105120 title: "Pull requests",
106− description: "Pull requests: start a change for an issue, record your session, mark it ready, review and merge. Read `overlaps` and `behind` on `get` before going far.",
121+ description: "Pull requests: start a change for an issue, record your session, mark it ready, ask people and teams to review, review and merge. Read `overlaps` and `behind` on `get` before going far, and `code_owners` for whose approval it needs.",
107122 default_action: None,
108123 actions: &[
109124 a("list", Op::ListPullRequests, "Pull requests on a repository, newest first"),
110125 a("get", Op::GetPullRequest, "Status, checks and required checks, reviews, overlaps, whether it is behind"),
111126 a("changes", Op::GetPullRequestChanges, "Files and line-by-line diff"),
112127 a("create", Op::CreatePullRequest, "Start a draft with its own fork to push to, or open one from a pushed branch"),
128+ a("update", Op::UpdatePullRequest, "Change its base branch, labels, milestone, assignees or reviewers"),
113129 a("record_session", Op::RecordSession, "Append prompt, reasoning and tool entries to its session"),
114130 a("read_session", Op::ReadSession, "Its recorded session"),
115131 a("ready", Op::MarkPullRequestReady, "Mark a draft ready, with a summary"),
132+ a("request_reviewers", Op::RequestReviewers, "Ask people or teams to review it"),
133+ a("remove_requested_reviewers", Op::RemoveRequestedReviewers, "Stop asking people or teams to review it"),
116134 a("review", Op::ReviewPullRequest, "Approve or request changes"),
117135 a("close", Op::ClosePullRequest, "Close without merging"),
118136 a("merge", Op::MergePullRequest, "Land it, or join the merge queue"),
225243 ],
226244 },
227245 Tool {
246+ name: "team",
247+ title: "Teams",
248+ description: "Teams: groups of a workspace's members, given roles on repositories together, mentioned as @workspace/team and asked to review together. Name one by `workspace` and its slug (`team`). Any member may create a team; the workspace's owners and the team's maintainers manage it. A secret team is seen only by its people and the owners.",
249+ default_action: None,
250+ actions: &[
251+ a("list", Op::ListTeams, "A workspace's teams you can see"),
252+ a("get", Op::GetTeam, "One team"),
253+ a("create", Op::CreateTeam, "Create a team; you become its maintainer"),
254+ a("update", Op::UpdateTeam, "Change its name, slug, description, visibility, parent or notifications"),
255+ a("delete", Op::DeleteTeam, "Delete it; its child teams move up"),
256+ a("list_members", Op::ListTeamMembers, "Its people and their roles, child teams' with include_child_teams"),
257+ a("set_member", Op::SetTeamMember, "Add a member of the workspace, or change their role"),
258+ a("remove_member", Op::RemoveTeamMember, "Take someone out of it"),
259+ a("list_child_teams", Op::ListChildTeams, "The teams nested under it"),
260+ a("list_repos", Op::ListTeamRepos, "The repositories it has a role on"),
261+ a("set_repo", Op::SetTeamRepo, "Give it a role on a repository"),
262+ a("remove_repo", Op::RemoveTeamRepo, "Take its role on a repository away"),
263+ a("set_review_assignment", Op::SetTeamReviewAssignment, "Whom it picks when asked to review"),
264+ a("list_user_teams", Op::ListUserTeams, "The teams someone is in"),
265+ ],
266+ },
267+ Tool {
228268 name: "workspace",
229269 title: "Workspaces",
230270 description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, and keep your own pinned projects at the top of its sidebar.",
249289 ],
250290 },
251291 Tool {
292+ name: "billing",
293+ title: "Billing",
294+ description: "A workspace's billing: its usage by product, project and day, its budget (the monthly spend limit, alerts and whether usage pauses at it), its AI credit, and its invoices. Amounts are whole millionths of a dollar (`_micros`), or cents (`_cents`) where named. Members read it; changing the budget and buying credit are for owners, as people, and never for g1t's agents.",
295+ default_action: Some("usage"),
296+ actions: &[
297+ a("usage", Op::GetUsage, "Usage over a range of days, by product, meter, project and day, and what paid for it"),
298+ a("budget", Op::GetBudget, "The monthly spend limit, what was spent, alerts and whether usage pauses at the limit"),
299+ a("set_budget", Op::SetBudget, "Change the spend limit, alerts, pausing or the alert webhook"),
300+ a("ai_credit", Op::GetAiCredit, "AI credit left, its grants, auto-reload and how to buy more"),
301+ a("buy_ai_credit", Op::BuyAiCredit, "A payment page to buy AI credit, for a person to open"),
302+ a("invoices", Op::ListInvoices, "Every invoice, the itemised usage invoices, and the next one so far"),
303+ a("billing_details", Op::GetBillingDetails, "Who invoices are made out to and the payment method on file"),
304+ ],
305+ },
306+ Tool {
307+ name: "security",
308+ title: "Security",
309+ description: "A repository's security: secret scanning alerts and push protection bypasses, custom secret patterns, code scanning alerts and SARIF uploads, vulnerability alerts, the dependency graph and its SBOM, dependency review, settings, and a workspace's overview. Fix an alert with g1t. Findings are shown to those who can change the code only. Give `repo` (owner/name), or `workspace` for lists across one.",
310+ default_action: Some("secret_alerts"),
311+ actions: &[
312+ a("secret_alerts", Op::Security(SecurityOp::ListSecretAlerts), "Secret scanning alerts; by state, secret_type, validity, bypassed"),
313+ a("secret_alert", Op::Security(SecurityOp::GetSecretAlert), "One secret alert, with where it was found and its bypass requests"),
314+ a("update_secret_alert", Op::Security(SecurityOp::UpdateSecretAlert), "Dismiss a secret alert with a reason, or reopen it"),
315+ a("secret_locations", Op::Security(SecurityOp::ListSecretLocations), "Every file, line and commit a secret is in"),
316+ a("bypass", Op::Security(SecurityOp::BypassPushProtection), "Push past push protection with a reason, or ask to"),
317+ a("check_validity", Op::Security(SecurityOp::CheckSecretValidity), "Ask a secret's issuer whether it still works"),
318+ a("bypass_requests", Op::Security(SecurityOp::ListBypassRequests), "A workspace's push protection bypass requests"),
319+ a("review_bypass", Op::Security(SecurityOp::ReviewBypassRequest), "Approve, deny or cancel a bypass request"),
320+ a("patterns", Op::Security(SecurityOp::ListCustomPatterns), "Custom secret patterns of a repository or workspace"),
321+ a("create_pattern", Op::Security(SecurityOp::CreateCustomPattern), "Create a custom secret pattern, as a draft or published"),
322+ a("update_pattern", Op::Security(SecurityOp::UpdateCustomPattern), "Change, publish or unpublish a custom pattern"),
323+ a("delete_pattern", Op::Security(SecurityOp::DeleteCustomPattern), "Delete a custom pattern"),
324+ a("dry_run_pattern", Op::Security(SecurityOp::DryRunCustomPattern), "Run a pattern over the default branch without saving it"),
325+ a("code_alerts", Op::Security(SecurityOp::ListCodeAlerts), "Code scanning alerts; by state, severity, tool, rule_id"),
326+ a("code_alert", Op::Security(SecurityOp::GetCodeAlert), "One code scanning alert by number"),
327+ a("update_code_alert", Op::Security(SecurityOp::UpdateCodeAlert), "Dismiss a code scanning alert with a reason, or reopen it"),
328+ a("analyses", Op::Security(SecurityOp::ListAnalyses), "Code scanning analyses, newest first"),
329+ a("upload_sarif", Op::Security(SecurityOp::UploadSarif), "Upload a SARIF file, gzipped and base64-encoded"),
330+ a("sarif_upload", Op::Security(SecurityOp::GetSarifUpload), "Whether a SARIF upload was read, and its analyses"),
331+ a("vulnerability_alerts", Op::Security(SecurityOp::ListVulnerabilityAlerts), "Vulnerable dependencies; by state, severity, ecosystem, package"),
332+ a("vulnerability_alert", Op::Security(SecurityOp::GetVulnerabilityAlert), "One vulnerability alert"),
333+ a("update_vulnerability_alert", Op::Security(SecurityOp::UpdateVulnerabilityAlert), "Dismiss a vulnerability alert with a reason, or reopen it"),
334+ a("fix", Op::Security(SecurityOp::FixAlert), "Put g1t on an issue to fix an alert"),
335+ a("dependency_graph", Op::Security(SecurityOp::GetDependencyGraph), "Every package the lockfiles resolve, direct or transitive"),
336+ a("sbom", Op::Security(SecurityOp::GetSbom), "The dependency graph as an SPDX 2.3 document"),
337+ a("compare_dependencies", Op::Security(SecurityOp::CompareDependencies), "What changes in dependencies between base...head"),
338+ a("settings", Op::Security(SecurityOp::GetSettings), "A repository's security settings"),
339+ a("update_settings", Op::Security(SecurityOp::UpdateSettings), "Change when checks fail and dependency review's policy"),
340+ a("workspace_settings", Op::Security(SecurityOp::GetWorkspaceSettings), "A workspace's delegated bypass and validity checks"),
341+ a("update_workspace_settings", Op::Security(SecurityOp::UpdateWorkspaceSettings), "Turn delegated bypass or validity checks on or off"),
342+ a("overview", Op::Security(SecurityOp::GetOverview), "A workspace's alerts, trends and coverage"),
343+ ],
344+ },
345+ Tool {
252346 name: "notifications",
253347 title: "Notifications",
254348 description: "Your inbox: what needs you, and what you follow. One thread per issue, pull request, workflow or deployment, with why you were told (`reason`): an agent waiting on you, a review asked of you, an assignment, a mention, your work's checks, or what you subscribe to and watch. Mark threads read or done once handled, and choose what you hear of with subscribe, unsubscribe and watch. Your own: a personal token.",
296390 fn destructive(op: Op) -> bool {
297391 matches!(
298392 op,
299− Op::DeleteWorkspace
393+ Op::Security(SecurityOp::DeleteCustomPattern | SecurityOp::BypassPushProtection)
394+ | Op::DeleteWorkspace
300395 | Op::UpdateWorkspace
301396 | Op::DeleteRepo
302397 | Op::PurgeRepo
312407 | Op::SetActionsVariable
313408 | Op::SetModelRoutes
314409 | Op::SetBasePermission
410+ | Op::DeleteTeam
411+ | Op::RemoveTeamRepo
315412 | Op::MergePullRequest
316413 | Op::RemoveRunner
317414 | Op::DeleteRunnerGroup
564661 assert!(tool.action(default).is_some(), "{}", tool.name);
565662 }
566663 }
567− assert!(TOOLS.len() <= 16, "{} tools", TOOLS.len());
664+ assert!(TOOLS.len() <= 17, "{} tools", TOOLS.len());
568665 }
569666
570667 #[test]
629726 assert_eq!(tool["annotations"]["destructiveHint"], false);
630727 }
631728 let issue = tools.iter().find(|tool| tool["name"] == "issue").unwrap();
632− assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get"]));
729+ assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get", "labels"]));
633730 // Nothing of the agent tool is a read.
634731 assert!(!tools.iter().any(|tool| tool["name"] == "agent"));
635732 }
638735 fn a_narrow_token_sees_only_its_tools() {
639736 let access = token(Some(vec![Scope::IssuesWrite]));
640737 let names: Vec<Value> = listed(&Gate::Token(&access)).into_iter().map(|tool| tool["name"].clone()).collect();
641− assert_eq!(names, vec![json!("issue"), json!("plan"), json!("account")]);
738+ // Labels and milestones are the repository's, managed with issues:write.
739+ assert_eq!(names, vec![json!("repository"), json!("issue"), json!("plan"), json!("account")]);
642740 // Notifications are a resource of their own: reading them lists
643741 // only what reads.
644742 let reader = token(Some(vec![Scope::NotificationsRead]));
677775 assert_eq!(resolve(account, &json!({})), Ok(Op::Whoami));
678776 }
679777
778+ #[test]
779+ fn teams_are_one_tool_and_a_workspace_reader_sees_only_its_reads() {
780+ let team = Tool::by_name("team").unwrap();
781+ let names: Vec<&str> = team.actions.iter().map(|action| action.name).collect();
782+ assert_eq!(
783+ names,
784+ [
785+ "list",
786+ "get",
787+ "create",
788+ "update",
789+ "delete",
790+ "list_members",
791+ "set_member",
792+ "remove_member",
793+ "list_child_teams",
794+ "list_repos",
795+ "set_repo",
796+ "remove_repo",
797+ "set_review_assignment",
798+ "list_user_teams",
799+ ]
800+ );
801+ let reader = token(Some(vec![Scope::WorkspaceRead]));
802+ let tools = listed(&Gate::Token(&reader));
803+ let listed_team = tools.iter().find(|tool| tool["name"] == "team").unwrap();
804+ assert_eq!(
805+ listed_team["inputSchema"]["properties"]["action"]["enum"],
806+ json!(["list", "get", "list_members", "list_child_teams", "list_repos", "list_user_teams"])
807+ );
808+ assert_eq!(listed_team["annotations"]["readOnlyHint"], true);
809+ // A team's role on a repository is who has access.
810+ let admin = token(Some(vec![Scope::WorkspaceAdmin]));
811+ let tools = listed(&Gate::Token(&admin));
812+ let listed_team = tools.iter().find(|tool| tool["name"] == "team").unwrap();
813+ let actions = listed_team["inputSchema"]["properties"]["action"]["enum"].as_array().unwrap();
814+ assert!(actions.contains(&json!("set_review_assignment")) && !actions.contains(&json!("set_repo")));
815+ let access = token(Some(vec![Scope::AccessAdmin]));
816+ let tools = listed(&Gate::Token(&access));
817+ let listed_team = tools.iter().find(|tool| tool["name"] == "team").unwrap();
818+ assert_eq!(listed_team["inputSchema"]["properties"]["action"]["enum"], json!(["set_repo", "remove_repo"]));
819+ // Both kinds of role a schema names are offered.
820+ let roles = &listed(&Gate::Everything).into_iter().find(|tool| tool["name"] == "team").unwrap()["inputSchema"]
821+ ["properties"]["role"]["enum"];
822+ for role in ["member", "maintainer", "read", "admin"] {
823+ assert!(roles.as_array().unwrap().contains(&json!(role)), "{role}");
824+ }
825+ assert_eq!(
826+ resolve(team, &json!({ "action": "set_repo", "workspace": "acme", "team": "backend", "repo": "rocket" })),
827+ Err("team.set_repo needs role.".to_owned())
828+ );
829+ }
830+
831+ #[test]
832+ fn reviewers_and_code_owners_are_actions_of_their_tools() {
833+ let pull = Tool::by_name("pull_request").unwrap();
834+ assert_eq!(
835+ resolve(pull, &json!({ "action": "request_reviewers", "repo": "a/b", "number": 1, "team_reviewers": ["backend"] })),
836+ Ok(Op::RequestReviewers)
837+ );
838+ assert_eq!(pull.action("remove_requested_reviewers").map(|action| action.op), Some(Op::RemoveRequestedReviewers));
839+ let repository = Tool::by_name("repository").unwrap();
840+ assert_eq!(resolve(repository, &json!({ "action": "codeowners", "repo": "a/b" })), Ok(Op::GetCodeownersErrors));
841+ assert!(reads_only(Op::GetCodeownersErrors));
842+ assert!(!reads_only(Op::RequestReviewers));
843+ }
844+
680845 /// How much smaller `tools/list` is than one tool per operation. Run
681846 /// with `--nocapture` to see the numbers.
682847 #[test]
+16−0
8484 { label: 'RubyGems', slug: 'guides/rubygems' },
8585 { label: 'Go modules', slug: 'guides/go' },
8686 { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' },
87+ ],
88+ },
89+ {
90+ label: 'Security',
91+ items: [
8792 { label: 'Security', slug: 'guides/security' },
93+ { label: 'Dependency updates', slug: 'guides/dependency-updates' },
94+ { label: 'Secret protection', slug: 'guides/security/secret-protection' },
95+ { label: 'Code scanning', slug: 'guides/security/code-scanning' },
96+ { label: 'Supply chain', slug: 'guides/security/supply-chain' },
97+ { label: 'Security overview', slug: 'guides/security/security-overview' },
98+ { label: "What's free and what's paid", slug: 'guides/security/pricing' },
8899 ],
89100 },
90101 {
113124 label: 'Landing changes',
114125 items: [
115126 { label: 'Pull requests and checks', slug: 'guides/pull-requests' },
127+ { label: 'Pull requests into other branches', slug: 'guides/base-branches' },
128+ { label: 'Labels', slug: 'guides/labels' },
129+ { label: 'Milestones', slug: 'guides/milestones' },
116130 { label: 'The merge queue', slug: 'guides/merge-queue' },
131+ { label: 'CODEOWNERS', slug: 'guides/codeowners' },
117132 { label: 'Sessions and why-blame', slug: 'guides/why-blame' },
118133 { label: 'Forks and branches', slug: 'concepts/forks' },
119134 ],
125140 { label: 'GitHub', slug: 'guides/github' },
126141 { label: 'Workspaces and tokens', slug: 'guides/workspaces' },
127142 { label: 'Access and roles', slug: 'guides/access-and-roles' },
143+ { label: 'Teams', slug: 'guides/teams' },
128144 { label: 'Managing a repository', slug: 'guides/managing-repositories' },
129145 { label: 'Transferring a repository', slug: 'guides/transferring-repositories' },
130146 { label: 'Audit log', slug: 'guides/audit-log' },
+0−0

Binary or large file; its contents are not shown.

+12−0
206206 overflow-x: auto;
207207 }
208208 }
209+/*
210+ * There, a name, route or value in a table is never broken, not even at a
211+ * hyphen or a slash: a long one widens the table, which then scrolls.
212+ */
213+@media (max-width: 50rem) {
214+ .sl-markdown-content td code,
215+ .sl-markdown-content th code {
216+ white-space: nowrap;
217+ overflow-wrap: normal;
218+ word-break: normal;
219+ }
220+}
209221 .sl-markdown-content th {
210222 border-bottom: 1px solid var(--g1t-line-strong);
211223 background: transparent;
+25−0
134134 animation: none;
135135 }
136136 }
137+
138+/*
139+ * The credit and terms forms (components/billing.tsx) show a field only for the
140+ * choice it belongs to, with CSS alone: the custom amount and the slug for
141+ * Custom, a refund's details for Refund, the expiry for anything else, and
142+ * the date for "On a date". Where `:has()` is not supported (or without
143+ * CSS) every field shows, and the server ignores the ones that do not apply.
144+ */
145+@supports selector(:has(*)) {
146+ .credit-form .when-custom,
147+ .credit-form .when-refund,
148+ .credit-form .when-date,
149+ .terms-form .when-custom {
150+ display: none;
151+ }
152+ .credit-form:has(input[name="preset"][value="custom"]:checked) .when-custom,
153+ .terms-form:has(input[name="preset"][value="custom"]:checked) .when-custom,
154+ .credit-form:has(input[name="kind"][value="refund"]:checked) .when-refund,
155+ .credit-form:has(input[name="expires"][value="date"]:checked) .when-date {
156+ display: block;
157+ }
158+ .credit-form:has(input[name="kind"][value="refund"]:checked) .when-not-refund {
159+ display: none;
160+ }
161+}
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.