1use std::collections::BTreeMap;
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12
13use crate::JsonValue;
14use crate::schema::{Identity, IsoDateTime, NonEmptyString, Property, RunxSchema, object_schema};
15
16mod evaluate;
17
18pub use evaluate::{
19 admit_operational_policy_request, lint_operational_policy_contract,
20 project_operational_policy_readback, validate_operational_policy_contract,
21 validate_operational_policy_semantics,
22};
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
25pub enum OperationalPolicySchema {
26 #[serde(rename = "runx.operational_policy.v1")]
27 V1,
28}
29
30impl fmt::Display for OperationalPolicySchema {
31 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32 formatter.write_str("runx.operational_policy.v1")
33 }
34}
35
36#[derive(
37 Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, RunxSchema,
38)]
39#[serde(rename_all = "kebab-case")]
40pub enum OperationalPolicyAction {
41 ReplyOnly,
42 IssueIntake,
43 WorkPlan,
44 IssueToPr,
45 ManualReview,
46 PrReview,
47 PrFixUp,
48 MergeAssist,
49}
50
51impl fmt::Display for OperationalPolicyAction {
52 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53 formatter.write_str(action_name(*self))
54 }
55}
56
57pub mod operational_policy_source_provider {
62 pub const SLACK: &str = "slack";
64 pub const SENTRY: &str = "sentry";
66 pub const GITHUB: &str = "github";
68 pub const FILE: &str = "file";
70 pub const API: &str = "api";
72}
73
74pub mod operational_policy_runner_kind {
78 pub const LOCAL: &str = "local";
80 pub const GITHUB_ACTIONS: &str = "github-actions";
82 pub const ASTER: &str = "aster";
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
87#[serde(rename_all = "lowercase")]
88pub enum OperationalPolicyRunnerState {
89 Available,
90 Disabled,
91 Maintenance,
92}
93
94impl fmt::Display for OperationalPolicyRunnerState {
95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96 formatter.write_str(match self {
97 Self::Available => "available",
98 Self::Disabled => "disabled",
99 Self::Maintenance => "maintenance",
100 })
101 }
102}
103
104#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
105#[serde(rename_all = "snake_case")]
106pub enum OperationalPolicyDedupeStrategy {
107 SourceFingerprint,
108 ProviderSearch,
109 Branch,
110}
111
112#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum OperationalPolicyOutcomeCloseMode {
115 Never,
116 WhenVerified,
117 WhenTerminal,
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
121#[serde(rename_all = "lowercase")]
122pub enum OperationalPolicyPublishMode {
123 Reply,
124 Comment,
125 None,
126}
127
128impl fmt::Display for OperationalPolicyPublishMode {
129 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130 formatter.write_str(match self {
131 Self::Reply => "reply",
132 Self::Comment => "comment",
133 Self::None => "none",
134 })
135 }
136}
137
138#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
139pub enum OperationalPolicyMissingBehavior {
140 #[serde(rename = "fail_closed")]
141 FailClosed,
142}
143
144#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
145#[serde(rename_all = "lowercase")]
146pub enum OperationalPolicyDuplicateBehavior {
147 Reuse,
148 Comment,
149 Block,
150}
151
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct OperationalPolicy {
155 pub schema: OperationalPolicySchema,
156 pub schema_version: OperationalPolicySchema,
157 pub policy_id: NonEmptyString,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub created_at: Option<IsoDateTime>,
160 pub sources: Vec<OperationalPolicySourceRule>,
161 pub runners: Vec<OperationalPolicyRunnerRule>,
162 pub owner_routes: Vec<OperationalPolicyOwnerRoute>,
163 pub targets: Vec<OperationalPolicyTargetRule>,
164 pub dedupe: OperationalPolicyDedupePolicy,
165 pub outcomes: OperationalPolicyOutcomePolicy,
166 pub permissions: OperationalPolicyAutomationPermissions,
167}
168
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct OperationalPolicySourceRule {
172 pub source_id: NonEmptyString,
173 pub provider: NonEmptyString,
177 pub allowed_locators: Vec<NonEmptyString>,
178 pub allowed_actions: Vec<OperationalPolicyAction>,
179 pub source_thread: OperationalPolicySourceThreadPolicy,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub minimum_confidence: Option<f64>,
182 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
187 pub adapter_policy: BTreeMap<NonEmptyString, JsonValue>,
188}
189
190#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, RunxSchema)]
191#[serde(deny_unknown_fields)]
192pub struct OperationalPolicySourceThreadPolicy {
193 pub required: bool,
194 pub publish_mode: OperationalPolicyPublishMode,
195 pub missing_behavior: OperationalPolicyMissingBehavior,
196}
197
198#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct OperationalPolicyRunnerRule {
201 pub runner_id: NonEmptyString,
202 pub kind: NonEmptyString,
206 pub state: OperationalPolicyRunnerState,
207 pub allowed_actions: Vec<OperationalPolicyAction>,
208 pub target_repos: Vec<String>,
209 pub scafld_required: bool,
210}
211
212#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct OperationalPolicyOwnerRoute {
215 pub route_id: NonEmptyString,
216 pub owners: Vec<NonEmptyString>,
217 pub target_repos: Vec<String>,
218 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub labels: Vec<NonEmptyString>,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub project: Option<NonEmptyString>,
222}
223
224#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct OperationalPolicyTargetRule {
227 pub repo: String,
228 pub runner_ids: Vec<NonEmptyString>,
229 pub allowed_actions: Vec<OperationalPolicyAction>,
230 pub default_owner_route: NonEmptyString,
231 pub scafld_required: bool,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub base_branch: Option<NonEmptyString>,
234}
235
236#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct OperationalPolicyDedupePolicy {
239 pub strategy: OperationalPolicyDedupeStrategy,
240 pub key_fields: Vec<NonEmptyString>,
241 pub on_duplicate: OperationalPolicyDuplicateBehavior,
242}
243
244#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
245#[serde(deny_unknown_fields)]
246pub struct OperationalPolicyOutcomePolicy {
247 pub observe_provider: bool,
248 pub verification_required: bool,
249 pub close_source_issue: OperationalPolicyOutcomeCloseMode,
250 pub publish_final_source_thread_update: bool,
251}
252
253#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct OperationalPolicyAutomationPermissions {
256 pub auto_merge: bool,
257 pub mutate_target_repo: bool,
258 pub require_human_merge_gate: bool,
259}
260
261impl RunxSchema for OperationalPolicy {
262 fn json_schema() -> Value {
263 object_schema(
264 vec![
265 Property::new("schema", OperationalPolicySchema::json_schema(), true),
266 Property::new(
267 "schema_version",
268 OperationalPolicySchema::json_schema(),
269 true,
270 ),
271 Property::new("policy_id", id_schema(), true),
272 Property::new("created_at", IsoDateTime::json_schema(), false),
273 Property::new(
274 "sources",
275 non_empty_array(OperationalPolicySourceRule::json_schema()),
276 true,
277 ),
278 Property::new(
279 "runners",
280 non_empty_array(OperationalPolicyRunnerRule::json_schema()),
281 true,
282 ),
283 Property::new(
284 "owner_routes",
285 non_empty_array(OperationalPolicyOwnerRoute::json_schema()),
286 true,
287 ),
288 Property::new(
289 "targets",
290 non_empty_array(OperationalPolicyTargetRule::json_schema()),
291 true,
292 ),
293 Property::new("dedupe", OperationalPolicyDedupePolicy::json_schema(), true),
294 Property::new(
295 "outcomes",
296 OperationalPolicyOutcomePolicy::json_schema(),
297 true,
298 ),
299 Property::new(
300 "permissions",
301 OperationalPolicyAutomationPermissions::json_schema(),
302 true,
303 ),
304 ],
305 true,
306 Some(Identity::Runx {
307 logical: "runx.operational_policy.v1",
308 url: None,
309 }),
310 )
311 }
312}
313
314impl RunxSchema for OperationalPolicySourceRule {
315 fn json_schema() -> Value {
316 object_schema(
317 vec![
318 Property::new("source_id", id_schema(), true),
319 Property::new("provider", NonEmptyString::json_schema(), true),
320 Property::new(
321 "allowed_locators",
322 non_empty_array(NonEmptyString::json_schema()),
323 true,
324 ),
325 Property::new(
326 "allowed_actions",
327 non_empty_array(OperationalPolicyAction::json_schema()),
328 true,
329 ),
330 Property::new(
331 "source_thread",
332 OperationalPolicySourceThreadPolicy::json_schema(),
333 true,
334 ),
335 Property::new("minimum_confidence", confidence_schema(), false),
336 Property::new("adapter_policy", adapter_policy_schema(), false),
337 ],
338 true,
339 None,
340 )
341 }
342}
343
344impl RunxSchema for OperationalPolicyRunnerRule {
345 fn json_schema() -> Value {
346 object_schema(
347 vec![
348 Property::new("runner_id", id_schema(), true),
349 Property::new("kind", NonEmptyString::json_schema(), true),
350 Property::new("state", OperationalPolicyRunnerState::json_schema(), true),
351 Property::new(
352 "allowed_actions",
353 non_empty_array(OperationalPolicyAction::json_schema()),
354 true,
355 ),
356 Property::new("target_repos", non_empty_array(repo_slug_schema()), true),
357 Property::new("scafld_required", bool::json_schema(), true),
358 ],
359 true,
360 None,
361 )
362 }
363}
364
365impl RunxSchema for OperationalPolicyOwnerRoute {
366 fn json_schema() -> Value {
367 object_schema(
368 vec![
369 Property::new("route_id", id_schema(), true),
370 Property::new(
371 "owners",
372 non_empty_array(NonEmptyString::json_schema()),
373 true,
374 ),
375 Property::new("target_repos", non_empty_array(repo_slug_schema()), true),
376 Property::new("labels", Vec::<NonEmptyString>::json_schema(), false),
377 Property::new("project", NonEmptyString::json_schema(), false),
378 ],
379 true,
380 None,
381 )
382 }
383}
384
385impl RunxSchema for OperationalPolicyTargetRule {
386 fn json_schema() -> Value {
387 object_schema(
388 vec![
389 Property::new("repo", repo_slug_schema(), true),
390 Property::new("runner_ids", non_empty_array(id_schema()), true),
391 Property::new(
392 "allowed_actions",
393 non_empty_array(OperationalPolicyAction::json_schema()),
394 true,
395 ),
396 Property::new("default_owner_route", id_schema(), true),
397 Property::new("scafld_required", bool::json_schema(), true),
398 Property::new("base_branch", NonEmptyString::json_schema(), false),
399 ],
400 true,
401 None,
402 )
403 }
404}
405
406impl RunxSchema for OperationalPolicyDedupePolicy {
407 fn json_schema() -> Value {
408 object_schema(
409 vec![
410 Property::new(
411 "strategy",
412 OperationalPolicyDedupeStrategy::json_schema(),
413 true,
414 ),
415 Property::new(
416 "key_fields",
417 non_empty_array(NonEmptyString::json_schema()),
418 true,
419 ),
420 Property::new(
421 "on_duplicate",
422 OperationalPolicyDuplicateBehavior::json_schema(),
423 true,
424 ),
425 ],
426 true,
427 None,
428 )
429 }
430}
431
432impl RunxSchema for OperationalPolicyAutomationPermissions {
433 fn json_schema() -> Value {
434 object_schema(
435 vec![
436 Property::new("auto_merge", const_bool(false), true),
437 Property::new("mutate_target_repo", bool::json_schema(), true),
438 Property::new("require_human_merge_gate", const_bool(true), true),
439 ],
440 true,
441 None,
442 )
443 }
444}
445
446fn repo_slug_schema() -> Value {
447 json!({
448 "minLength": 3,
449 "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
450 "type": "string"
451 })
452}
453
454fn id_schema() -> Value {
455 json!({
456 "minLength": 1,
457 "pattern": "^[A-Za-z0-9_.:-]+$",
458 "type": "string"
459 })
460}
461
462fn confidence_schema() -> Value {
463 json!({ "minimum": 0, "maximum": 1, "type": "number" })
464}
465
466fn non_empty_array(item_schema: Value) -> Value {
467 json!({ "items": item_schema, "minItems": 1, "type": "array" })
468}
469
470fn const_bool(value: bool) -> Value {
471 json!({ "const": value, "type": "boolean" })
472}
473
474fn adapter_policy_schema() -> Value {
475 json!({
476 "additionalProperties": JsonValue::json_schema(),
477 "propertyNames": NonEmptyString::json_schema(),
478 "type": "object",
479 })
480}
481
482#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
483pub struct OperationalPolicyValidationFinding {
484 pub code: String,
485 pub path: String,
486 pub message: String,
487}
488
489#[derive(Clone, Debug, Eq, PartialEq)]
490pub struct OperationalPolicyAdmissionRequest {
491 pub source_id: Option<String>,
492 pub target_repo: Option<String>,
493 pub action: OperationalPolicyAction,
494 pub runner_id: Option<String>,
495 pub source_thread_locator: Option<String>,
496}
497
498#[derive(Clone, Debug, PartialEq, Serialize)]
499pub struct OperationalPolicyAdmission {
500 pub status: OperationalPolicyAdmissionStatus,
501 pub findings: Vec<OperationalPolicyValidationFinding>,
502 pub policy_id: String,
503 #[serde(skip_serializing_if = "Option::is_none")]
504 pub source_id: Option<String>,
505 #[serde(skip_serializing_if = "Option::is_none")]
506 pub target_repo: Option<String>,
507 #[serde(skip_serializing_if = "Option::is_none")]
508 pub runner_id: Option<String>,
509 #[serde(skip_serializing_if = "Option::is_none")]
510 pub owner_route_id: Option<String>,
511 #[serde(skip_serializing_if = "Option::is_none")]
512 pub owners: Option<Vec<String>>,
513 pub dedupe_strategy: OperationalPolicyDedupeStrategy,
514 pub outcome_close_mode: OperationalPolicyOutcomeCloseMode,
515 pub source_thread_required: bool,
516 pub mutate_target_repo: bool,
517 pub require_human_merge_gate: bool,
518}
519
520#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
521#[serde(rename_all = "lowercase")]
522pub enum OperationalPolicyAdmissionStatus {
523 Allow,
524 Deny,
525}
526
527#[derive(Clone, Debug, Eq, PartialEq)]
528pub enum OperationalPolicyError {
529 Contract(OperationalPolicyValidationFinding),
530 Semantic(OperationalPolicyValidationFinding),
531}
532
533impl OperationalPolicyError {
534 pub fn finding(&self) -> &OperationalPolicyValidationFinding {
535 match self {
536 Self::Contract(finding) | Self::Semantic(finding) => finding,
537 }
538 }
539}
540
541impl fmt::Display for OperationalPolicyError {
542 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
543 let finding = self.finding();
544 write!(
545 formatter,
546 "{} failed validation ({}): {}",
547 finding.path, finding.code, finding.message
548 )
549 }
550}
551
552impl std::error::Error for OperationalPolicyError {}
553
554#[derive(Clone, Debug, PartialEq, Serialize)]
555pub struct OperationalPolicyReadback {
556 pub policy_id: String,
557 pub schema_version: OperationalPolicySchema,
558 pub valid: bool,
559 pub findings: Vec<OperationalPolicyValidationFinding>,
560 pub sources: Vec<OperationalPolicySourceReadback>,
561 pub runners: Vec<OperationalPolicyRunnerReadback>,
562 pub targets: Vec<OperationalPolicyTargetReadback>,
563 pub outcomes: OperationalPolicyOutcomePolicy,
564 pub permissions: OperationalPolicyAutomationPermissions,
565}
566
567#[derive(Clone, Debug, PartialEq, Serialize)]
568pub struct OperationalPolicySourceReadback {
569 pub source_id: String,
570 pub provider: NonEmptyString,
571 pub locator_count: usize,
572 pub allowed_actions: Vec<OperationalPolicyAction>,
573 pub source_thread_required: bool,
574 pub publish_mode: OperationalPolicyPublishMode,
575}
576
577#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
578pub struct OperationalPolicyRunnerReadback {
579 pub runner_id: String,
580 pub kind: NonEmptyString,
581 pub state: OperationalPolicyRunnerState,
582 pub target_repos: Vec<String>,
583 pub allowed_actions: Vec<OperationalPolicyAction>,
584 pub scafld_required: bool,
585}
586
587#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
588pub struct OperationalPolicyTargetReadback {
589 pub repo: String,
590 pub runner_ids: Vec<String>,
591 pub default_owner_route: String,
592 pub owner_count: usize,
593 pub allowed_actions: Vec<OperationalPolicyAction>,
594 pub scafld_required: bool,
595 pub available_runner_count: usize,
596}
597
598fn action_name(action: OperationalPolicyAction) -> &'static str {
599 match action {
600 OperationalPolicyAction::ReplyOnly => "reply-only",
601 OperationalPolicyAction::IssueIntake => "issue-intake",
602 OperationalPolicyAction::WorkPlan => "work-plan",
603 OperationalPolicyAction::IssueToPr => "issue-to-pr",
604 OperationalPolicyAction::ManualReview => "manual-review",
605 OperationalPolicyAction::PrReview => "pr-review",
606 OperationalPolicyAction::PrFixUp => "pr-fix-up",
607 OperationalPolicyAction::MergeAssist => "merge-assist",
608 }
609}