Skip to content

g1t/apps/api/src/rules.rs

427 lines25,453 bytesCodeBlame
1//! Rulesets over REST and MCP: a repository's and a workspace's rulesets,
2//! the rules that hold for one branch or tag, and how the rules judged
3//! pushes and merges (the evaluations, with insights).
4//!
5//! Rulesets travel as the API shows them, `snake_case` between services
6//! too, so a ruleset read here, exported from the site or written by hand
7//! is created and updated unchanged. The work service decides who may see
8//! and change them and validates every one (`g1t_rules::validate`).
9
10use g1t_contracts::repos::RepoPath;
11use g1t_contracts::rules::*;
12use g1t_contracts::{FailureCode, Outcome, Viewer};
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15use serde_json::{Map, Value, json};
16use worker::Result;
17
18use crate::operations::Services;
19
20/// One operation on rulesets.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum RulesOp {
23 ListRepoRulesets,
24 GetRepoRuleset,
25 CreateRepoRuleset,
26 UpdateRepoRuleset,
27 DeleteRepoRuleset,
28 GetBranchRules,
29 ListRuleEvaluations,
30 ListWorkspaceRulesets,
31 GetWorkspaceRuleset,
32 CreateWorkspaceRuleset,
33 UpdateWorkspaceRuleset,
34 DeleteWorkspaceRuleset,
35 ListWorkspaceRuleEvaluations,
36}
37
38/// The keys of a ruleset in a request body.
39const SPEC_KEYS: [&str; 6] = ["name", "enforcement", "target", "conditions", "bypass_actors", "rules"];
40
41impl RulesOp {
42 /// Every one: `Op::ALL` lists each as `Op::Rules(…)`, which a test
43 /// checks against this.
44 #[cfg(test)]
45 pub const ALL: [RulesOp; 13] = [
46 RulesOp::ListRepoRulesets,
47 RulesOp::GetRepoRuleset,
48 RulesOp::CreateRepoRuleset,
49 RulesOp::UpdateRepoRuleset,
50 RulesOp::DeleteRepoRuleset,
51 RulesOp::GetBranchRules,
52 RulesOp::ListRuleEvaluations,
53 RulesOp::ListWorkspaceRulesets,
54 RulesOp::GetWorkspaceRuleset,
55 RulesOp::CreateWorkspaceRuleset,
56 RulesOp::UpdateWorkspaceRuleset,
57 RulesOp::DeleteWorkspaceRuleset,
58 RulesOp::ListWorkspaceRuleEvaluations,
59 ];
60
61 pub fn name(self) -> &'static str {
62 match self {
63 RulesOp::ListRepoRulesets => "list_repo_rulesets",
64 RulesOp::GetRepoRuleset => "get_repo_ruleset",
65 RulesOp::CreateRepoRuleset => "create_repo_ruleset",
66 RulesOp::UpdateRepoRuleset => "update_repo_ruleset",
67 RulesOp::DeleteRepoRuleset => "delete_repo_ruleset",
68 RulesOp::GetBranchRules => "get_branch_rules",
69 RulesOp::ListRuleEvaluations => "list_rule_evaluations",
70 RulesOp::ListWorkspaceRulesets => "list_workspace_rulesets",
71 RulesOp::GetWorkspaceRuleset => "get_workspace_ruleset",
72 RulesOp::CreateWorkspaceRuleset => "create_workspace_ruleset",
73 RulesOp::UpdateWorkspaceRuleset => "update_workspace_ruleset",
74 RulesOp::DeleteWorkspaceRuleset => "delete_workspace_ruleset",
75 RulesOp::ListWorkspaceRuleEvaluations => "list_workspace_rule_evaluations",
76 }
77 }
78
79 /// For the API reference: "List a repository's rulesets".
80 pub fn title(self) -> &'static str {
81 match self {
82 RulesOp::ListRepoRulesets => "List a repository's rulesets",
83 RulesOp::GetRepoRuleset => "Get a repository ruleset",
84 RulesOp::CreateRepoRuleset => "Create a repository ruleset",
85 RulesOp::UpdateRepoRuleset => "Update a repository ruleset",
86 RulesOp::DeleteRepoRuleset => "Delete a repository ruleset",
87 RulesOp::GetBranchRules => "Get the rules for a branch",
88 RulesOp::ListRuleEvaluations => "List a repository's rule evaluations",
89 RulesOp::ListWorkspaceRulesets => "List a workspace's rulesets",
90 RulesOp::GetWorkspaceRuleset => "Get a workspace ruleset",
91 RulesOp::CreateWorkspaceRuleset => "Create a workspace ruleset",
92 RulesOp::UpdateWorkspaceRuleset => "Update a workspace ruleset",
93 RulesOp::DeleteWorkspaceRuleset => "Delete a workspace ruleset",
94 RulesOp::ListWorkspaceRuleEvaluations => "List a workspace's rule evaluations",
95 }
96 }
97
98 pub fn description(self) -> &'static str {
99 match self {
100 RulesOp::ListRepoRulesets => "List a repository's rulesets: what may happen to its branches and tags, and what a pull request needs before it merges. With include_parents, also its workspace's rulesets that hold in it (level workspace). Each has its enforcement (active, evaluate: a dry run that records what it would have refused, or disabled), target (branch or tag), conditions (ref_name include and exclude patterns: fnmatch, ~DEFAULT_BRANCH, ~ALL), bypass_actors and rules. The one made from branch protection settings has source branch_protection.",
101 RulesOp::GetRepoRuleset => "Get one of a repository's rulesets by id (rs_…), or one of its workspace's that holds in it.",
102 RulesOp::CreateRepoRuleset => "Create a repository ruleset: name, enforcement (active, evaluate or disabled; active by default), target (branch or tag), conditions.ref_name (include and exclude patterns), bypass_actors (each a kind: role, team, user, token or g1t, a value, and a mode: always or pull_requests; nobody bypasses unless listed, g1t included) and rules (each a type, its parameters, and applies_to: everyone, agents or people). Rule types: creation, update, deletion, non_fast_forward, required_linear_history, required_signatures, pull_request, required_status_checks, merge_queue, required_deployments, commit_message_pattern, commit_author_email_pattern, committer_email_pattern, branch_name_pattern, tag_name_pattern, file_path_restriction, file_extension_restriction, max_file_size, max_file_path_length, max_files_changed, secret_scanning, confidence_threshold, cost_cap, path_review, merge_window and agent_auto_merge. Several rulesets stack: every rule of each holds. Takes the Maintain role. Returns the ruleset as saved, tidied.",
103 RulesOp::UpdateRepoRuleset => "Change a repository ruleset. Fields left out stay as they are; rules and bypass_actors, when given, replace the whole list. Takes the Maintain role.",
104 RulesOp::DeleteRepoRuleset => "Delete a repository ruleset. Its evaluations stay in the log. Takes the Maintain role.",
105 RulesOp::GetBranchRules => "Every rule that holds for a branch (or a tag, with target tag) of a repository, from every ruleset that targets it, the repository's and its workspace's: each with its type, parameters and applies_to, and the ruleset_id, ruleset_name, level and enforcement it comes from. Active rules come first, then those of rulesets in evaluate. rulesets lists the rulesets with who may bypass each. A branch name with slashes is URL-encoded in the path.",
106 RulesOp::ListRuleEvaluations => "List how a repository's rulesets judged pushes, merges and other changes to its branches and tags, newest first: the ruleset, the action (push, merge, create_ref, delete_ref, rename_ref or commit), the ref, the actor and whether they are a person, an agent or g1t, the verdict (pass, fail or bypass) and each rule broken with why. A fail of a ruleset in evaluate is what it would have refused. Filter by ruleset_id or verdict, or problems_only; page with before. insights counts the last 30 days by ruleset and by rule. Takes the Write role.",
107 RulesOp::ListWorkspaceRulesets => "List a workspace's own rulesets. Each holds in the repositories its conditions.repository selects: names matching include (fnmatch, or ~ALL) and not exclude, of a visibility (any, public or private), and carrying one of topics when given. Members only.",
108 RulesOp::GetWorkspaceRuleset => "Get one of a workspace's own rulesets by id (rs_…), with its conditions, bypass actors and rules. Members only.",
109 RulesOp::CreateWorkspaceRuleset => "Create a workspace ruleset, as for a repository, plus conditions.repository: which of the workspace's repositories it holds in (include and exclude name patterns, visibility, topics). Owners only.",
110 RulesOp::UpdateWorkspaceRuleset => "Change a workspace ruleset. Fields left out stay as they are; rules and bypass_actors, when given, replace the whole list. Owners only.",
111 RulesOp::DeleteWorkspaceRuleset => "Delete a workspace ruleset: it stops holding in every repository it selected. Its evaluations stay in the log. Owners only.",
112 RulesOp::ListWorkspaceRuleEvaluations => "List how a workspace's rulesets, and its repositories' own, judged changes across its repositories, newest first, with 30 days of insights. Members only.",
113 }
114 }
115
116 /// Whether the operation is about one repository named by `repo`.
117 pub fn needs_repo(self) -> bool {
118 matches!(
119 self,
120 RulesOp::ListRepoRulesets
121 | RulesOp::GetRepoRuleset
122 | RulesOp::CreateRepoRuleset
123 | RulesOp::UpdateRepoRuleset
124 | RulesOp::DeleteRepoRuleset
125 | RulesOp::GetBranchRules
126 | RulesOp::ListRuleEvaluations
127 )
128 }
129
130 pub fn input(self) -> Value {
131 let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
132 let workspace = || json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." });
133 let id = || json!({ "type": "string", "description": "The ruleset's id: rs_…" });
134 let spec = |mut properties: Value, workspace_level: bool| {
135 properties["ruleset_name"] = json!({ "type": "string", "description": "What people call it, at most 100 characters. A ruleset as exported names it `name`, which is read too." });
136 properties["enforcement"] = json!({ "type": "string", "enum": ["active", "evaluate", "disabled"], "description": "active: its rules hold. evaluate: nothing is refused, and what would have been is recorded. disabled: kept, not evaluated. Default active." });
137 properties["target"] = json!({ "type": "string", "enum": ["branch", "tag"], "description": "What its name conditions match. Default branch." });
138 let mut conditions = json!({
139 "ref_name": {
140 "type": "object",
141 "description": "Which branches or tags: include and exclude, each a list of fnmatch patterns (* within a path segment, ** across them), ~DEFAULT_BRANCH or ~ALL.",
142 "properties": {
143 "include": { "type": "array", "items": { "type": "string" } },
144 "exclude": { "type": "array", "items": { "type": "string" } },
145 },
146 },
147 });
148 if workspace_level {
149 conditions["repository"] = json!({
150 "type": "object",
151 "description": "Which of the workspace's repositories: include and exclude name patterns (or ~ALL), visibility (any, public, private) and topics (any of).",
152 "properties": {
153 "include": { "type": "array", "items": { "type": "string" } },
154 "exclude": { "type": "array", "items": { "type": "string" } },
155 "visibility": { "type": "string", "enum": ["any", "public", "private"] },
156 "topics": { "type": "array", "items": { "type": "string" } },
157 },
158 });
159 }
160 properties["conditions"] = json!({ "type": "object", "properties": conditions });
161 properties["bypass_actors"] = json!({
162 "type": "array",
163 "description": "Who it does not hold for. Nobody bypasses unless listed, g1t included. kind role takes read, triage, write, maintain, admin (that role or higher) or owner; team its slug or workspace/slug; user a username; token a token id, or workspace for any of the workspace's tokens; g1t no value. mode always (pushes and merges) or pull_requests (merges only; a person merging asks to, with bypass_rules).",
164 "items": {
165 "type": "object",
166 "properties": {
167 "kind": { "type": "string", "enum": ["role", "team", "user", "token", "g1t"] },
168 "value": { "type": "string" },
169 "mode": { "type": "string", "enum": ["always", "pull_requests"] },
170 },
171 "required": ["kind"],
172 },
173 });
174 properties["rules"] = json!({
175 "type": "array",
176 "description": "Its rules. Each: type, parameters (left-out parameters take their defaults) and applies_to (everyone, agents or people). See the Rules guide for every type's parameters.",
177 "items": {
178 "type": "object",
179 "properties": {
180 "type": { "type": "string" },
181 "parameters": { "type": "object" },
182 "applies_to": { "type": "string", "enum": ["everyone", "agents", "people"] },
183 },
184 "required": ["type"],
185 },
186 });
187 properties
188 };
189 let evaluations = |mut properties: Value| {
190 properties["ruleset_id"] = json!({ "type": "string", "description": "Only this ruleset's evaluations." });
191 properties["verdict"] = json!({ "type": "string", "enum": ["pass", "fail", "bypass"], "description": "Only evaluations that came out this way." });
192 properties["problems_only"] = json!({ "type": "boolean", "description": "Only evaluations that broke a rule: failed, would have failed, or bypassed." });
193 properties["before"] = json!({ "type": "string", "description": "An evaluation's id (rev_…): only older ones. The page's next." });
194 properties["limit"] = json!({ "type": "integer", "description": "How many, 1 to 100; 30 by default." });
195 properties
196 };
197 let (properties, required): (Value, &[&str]) = match self {
198 RulesOp::ListRepoRulesets => (
199 json!({ "repo": repo(), "include_parents": { "type": "boolean", "description": "Also list the workspace's rulesets that hold in it." } }),
200 &["repo"],
201 ),
202 RulesOp::GetRepoRuleset | RulesOp::DeleteRepoRuleset => (json!({ "repo": repo(), "id": id() }), &["repo", "id"]),
203 RulesOp::CreateRepoRuleset => (spec(json!({ "repo": repo() }), false), &["repo"]),
204 RulesOp::UpdateRepoRuleset => (spec(json!({ "repo": repo(), "id": id() }), false), &["repo", "id"]),
205 RulesOp::GetBranchRules => (
206 json!({
207 "repo": repo(),
208 "branch": { "type": "string", "description": "The branch (or tag) name, such as main or release/1.x." },
209 "target": { "type": "string", "enum": ["branch", "tag"], "description": "branch (the default) or tag." },
210 }),
211 &["repo", "branch"],
212 ),
213 RulesOp::ListRuleEvaluations => (evaluations(json!({ "repo": repo() })), &["repo"]),
214 RulesOp::ListWorkspaceRulesets => (json!({ "workspace": workspace() }), &["workspace"]),
215 RulesOp::GetWorkspaceRuleset | RulesOp::DeleteWorkspaceRuleset => {
216 (json!({ "workspace": workspace(), "id": id() }), &["workspace", "id"])
217 }
218 RulesOp::CreateWorkspaceRuleset => (spec(json!({ "workspace": workspace() }), true), &["workspace"]),
219 RulesOp::UpdateWorkspaceRuleset => (spec(json!({ "workspace": workspace(), "id": id() }), true), &["workspace", "id"]),
220 RulesOp::ListWorkspaceRuleEvaluations => (evaluations(json!({ "workspace": workspace() })), &["workspace"]),
221 };
222 let mut schema = json!({ "type": "object", "properties": properties });
223 if !required.is_empty() {
224 schema["required"] = json!(required);
225 }
226 schema
227 }
228}
229
230fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> {
231 Ok(Outcome::Ok(serde_json::to_value(value)?))
232}
233
234fn text(input: &Value, key: &str) -> Option<String> {
235 input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned)
236}
237
238fn flag(input: &Value, key: &str) -> bool {
239 match &input[key] {
240 Value::Bool(value) => *value,
241 Value::String(text) => matches!(text.trim(), "true" | "1"),
242 _ => false,
243 }
244}
245
246async fn call<A: Serialize, T: DeserializeOwned>(services: &Services, method: &str, args: &A) -> Result<Outcome<T>> {
247 g1t_kit::call(&services.work, method, args).await
248}
249
250/// The ruleset in a request body, laid over `current` for an update: the
251/// fields given replace those it had.
252pub(crate) fn spec_of(input: &Value, current: Option<&RulesetSpec>) -> std::result::Result<RulesetSpec, String> {
253 let mut merged: Map<String, Value> = match current {
254 Some(current) => match serde_json::to_value(current) {
255 Ok(Value::Object(fields)) => fields,
256 _ => Map::new(),
257 },
258 None => Map::new(),
259 };
260 for key in SPEC_KEYS {
261 if let Some(value) = input.get(key).filter(|value| !value.is_null()) {
262 merged.insert(key.to_owned(), value.clone());
263 }
264 }
265 // Under a repository's address `name` is the repository's, so the API
266 // names the ruleset `ruleset_name`; an exported ruleset's `name` is read
267 // as well.
268 if let Some(name) = input.get("ruleset_name").filter(|value| !value.is_null()) {
269 merged.insert("name".to_owned(), name.clone());
270 }
271 serde_json::from_value(Value::Object(merged)).map_err(|error| format!("The ruleset could not be read: {error}"))
272}
273
274fn owner(op: RulesOp, input: &Value, repo: Option<RepoPath>) -> std::result::Result<Owner, String> {
275 if op.needs_repo() {
276 return repo.map(Owner::repo).ok_or_else(|| "Give the repository as \"owner/name\".".to_owned());
277 }
278 text(input, "workspace").map(|slug| Owner::workspace(&slug)).ok_or_else(|| "Give the workspace's slug.".to_owned())
279}
280
281pub async fn run(op: RulesOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
282 let owner = match owner(op, input, crate::operations::repo_path(input)) {
283 Ok(owner) => owner,
284 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
285 };
286 let actor = || viewer.clone().unwrap_or_default();
287 let id = || text(input, "id").unwrap_or_default();
288 match op {
289 RulesOp::ListRepoRulesets | RulesOp::ListWorkspaceRulesets => {
290 call(
291 services,
292 "list_rulesets",
293 &ListRulesetsArgs { viewer: viewer.clone(), owner, include_parents: flag(input, "include_parents") },
294 )
295 .await
296 }
297 RulesOp::GetRepoRuleset | RulesOp::GetWorkspaceRuleset => {
298 call(services, "get_ruleset", &GetRulesetArgs { viewer: viewer.clone(), owner, id: id() }).await
299 }
300 RulesOp::CreateRepoRuleset | RulesOp::CreateWorkspaceRuleset => {
301 let ruleset = match spec_of(input, None) {
302 Ok(ruleset) => ruleset,
303 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
304 };
305 call(services, "save_ruleset", &SaveRulesetArgs { actor: actor(), owner, id: None, ruleset, from_api: true }).await
306 }
307 RulesOp::UpdateRepoRuleset | RulesOp::UpdateWorkspaceRuleset => {
308 let current: Outcome<Ruleset> =
309 call(services, "get_ruleset", &GetRulesetArgs { viewer: viewer.clone(), owner: owner.clone(), id: id() }).await?;
310 let current = match current {
311 Outcome::Ok(current) => current,
312 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
313 };
314 if current.level == Level::Workspace && op == RulesOp::UpdateRepoRuleset {
315 return Ok(Outcome::fail(
316 FailureCode::Invalid,
317 "That is the workspace's ruleset: change it with update_workspace_ruleset.",
318 ));
319 }
320 let ruleset = match spec_of(input, Some(&current.spec)) {
321 Ok(ruleset) => ruleset,
322 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
323 };
324 call(services, "save_ruleset", &SaveRulesetArgs { actor: actor(), owner, id: Some(current.id), ruleset, from_api: true })
325 .await
326 }
327 RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset => {
328 let deleted: Outcome<bool> =
329 call(services, "delete_ruleset", &DeleteRulesetArgs { actor: actor(), owner, id: id(), from_api: true }).await?;
330 match deleted {
331 Outcome::Ok(deleted) => ok(&json!({ "deleted": deleted })),
332 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
333 }
334 }
335 RulesOp::GetBranchRules => {
336 let Some(repo) = owner.repo else {
337 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
338 };
339 let target = match text(input, "target").as_deref() {
340 None | Some("branch") => Target::Branch,
341 Some("tag") => Target::Tag,
342 Some(other) => return Ok(Outcome::fail(FailureCode::Invalid, format!("{other} is not a target: use branch or tag."))),
343 };
344 let Some(name) = text(input, "branch") else {
345 return Ok(Outcome::fail(FailureCode::Invalid, "Name the branch."));
346 };
347 call(services, "effective_rules", &EffectiveRulesArgs { viewer: viewer.clone(), repo, name, target }).await
348 }
349 RulesOp::ListRuleEvaluations | RulesOp::ListWorkspaceRuleEvaluations => {
350 let verdict = match text(input, "verdict").as_deref() {
351 None => None,
352 Some("pass") => Some(Verdict::Pass),
353 Some("fail") => Some(Verdict::Fail),
354 Some("bypass") => Some(Verdict::Bypass),
355 Some(other) => return Ok(Outcome::fail(FailureCode::Invalid, format!("{other} is not a verdict: use pass, fail or bypass."))),
356 };
357 let limit = match &input["limit"] {
358 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
359 Value::String(digits) => digits.trim().parse().ok(),
360 _ => None,
361 };
362 call(
363 services,
364 "rule_evaluations",
365 &EvaluationsArgs {
366 viewer: viewer.clone(),
367 owner,
368 ruleset_id: text(input, "ruleset_id"),
369 verdict,
370 problems_only: flag(input, "problems_only"),
371 before: text(input, "before"),
372 limit,
373 },
374 )
375 .await
376 }
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn a_body_is_a_ruleset_and_an_update_keeps_what_it_leaves_out() {
386 let body = json!({
387 "repo": "acme/web",
388 "name": "Protect main",
389 "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"] } },
390 "rules": [{ "type": "deletion" }, { "type": "pull_request", "parameters": { "required_approvals": 2 } }]
391 });
392 let created = spec_of(&body, None).unwrap();
393 assert_eq!(created.name, "Protect main");
394 assert_eq!(created.enforcement, Enforcement::Active);
395 assert_eq!(created.rules.len(), 2);
396 let updated = spec_of(&json!({ "enforcement": "evaluate" }), Some(&created)).unwrap();
397 assert_eq!(updated.enforcement, Enforcement::Evaluate);
398 assert_eq!(updated.rules, created.rules, "rules left out stay");
399 let replaced = spec_of(&json!({ "rules": [] }), Some(&created)).unwrap();
400 assert!(replaced.rules.is_empty(), "a list given replaces the list");
401 let renamed = spec_of(&json!({ "ruleset_name": "Protect releases" }), Some(&created)).unwrap();
402 assert_eq!(renamed.name, "Protect releases");
403 assert!(spec_of(&json!({ "rules": [{ "type": "no_such_rule" }] }), None).is_err());
404 }
405
406 #[test]
407 fn whose_rulesets_comes_from_repo_or_workspace() {
408 let input = json!({ "workspace": "Acme" });
409 assert_eq!(owner(RulesOp::ListWorkspaceRulesets, &input, None).unwrap(), Owner::workspace("acme"));
410 assert!(owner(RulesOp::ListRepoRulesets, &input, None).is_err());
411 let path = RepoPath { namespace: "acme".into(), name: "web".into() };
412 assert_eq!(owner(RulesOp::GetBranchRules, &json!({}), Some(path.clone())).unwrap(), Owner::repo(path));
413 }
414
415 #[test]
416 fn each_operation_is_described_with_a_schema() {
417 for op in RulesOp::ALL {
418 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
419 assert_eq!(op.input()["type"], "object");
420 if op.needs_repo() {
421 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
422 } else {
423 assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name());
424 }
425 }
426 }
427}