Skip to main content

cloud/envoy/
ci.rs

1//! `ci.*` verb signatures — external CI pipeline catalog (R409-T12).
2//!
3//! Three verbs for talking to external CI systems. Note: **qed** (W126) is
4//! yah's own scheduler; these verbs are for when yah does not own the CI
5//! substrate and needs to talk to GitHub Actions, GitLab CI, CircleCI, etc.
6//!
7//! - `ci.pipeline.run`    — trigger a pipeline run
8//! - `ci.pipeline.status` — query a run's lifecycle phase
9//! - `ci.artifact.fetch`  — get a download URL for a build artifact
10//!
11//! Adapters are responsible for mapping `pipeline` (a provider-specific
12//! identifier — repo path, workflow filename, numeric ID) and `git_ref`
13//! (branch, tag, or SHA) to the provider's API shape.
14
15use serde::{Deserialize, Serialize};
16
17use super::{InternalVerb, VerbCategory};
18
19// ── ci.pipeline.run ───────────────────────────────────────────────────────
20
21/// Marker type for `ci.pipeline.run`.
22pub struct CiPipelineRun;
23
24/// Request body for `ci.pipeline.run`.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
27pub struct CiPipelineRunInput {
28    /// Provider-specific pipeline identifier: a workflow filename on GitHub
29    /// Actions (`"release.yml"`), a numeric pipeline ID on GitLab, a pipeline
30    /// slug on CircleCI, etc.
31    pub pipeline: String,
32    /// Git ref to run the pipeline against: branch name, tag, or full SHA.
33    #[serde(rename = "ref")]
34    pub git_ref: String,
35    /// Key-value variables to pass to the pipeline run. Provider-specific
36    /// interpretation; `None` uses the pipeline's own defaults.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub variables: Option<std::collections::BTreeMap<String, String>>,
39}
40
41/// Response body for `ci.pipeline.run`.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
44pub struct CiPipelineRunOutput {
45    /// Provider-issued run identifier. Opaque; used in `ci.pipeline.status`
46    /// and `ci.artifact.fetch`.
47    pub run_id: String,
48    /// URL to the run in the provider's UI. `None` when not available at
49    /// trigger time.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub url: Option<String>,
52}
53
54impl InternalVerb for CiPipelineRun {
55    type Input = CiPipelineRunInput;
56    type Output = CiPipelineRunOutput;
57    const ID: &'static str = "ci.pipeline.run";
58    const CATEGORY: VerbCategory = VerbCategory::Ci;
59}
60
61// ── ci.pipeline.status ────────────────────────────────────────────────────
62
63/// Marker type for `ci.pipeline.status`.
64pub struct CiPipelineStatus;
65
66/// Request body for `ci.pipeline.status`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
69pub struct CiPipelineStatusInput {
70    /// Run ID from a prior `ci.pipeline.run`.
71    pub run_id: String,
72}
73
74/// Canonical pipeline lifecycle phase.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
76#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
77#[serde(rename_all = "snake_case")]
78pub enum PipelinePhase {
79    /// Queued but not yet started.
80    Pending,
81    /// Currently executing.
82    Running,
83    /// Completed successfully.
84    Passing,
85    /// Completed with failures.
86    Failing,
87    /// Cancelled before completion.
88    Cancelled,
89    /// Phase couldn't be determined or maps to no known phase.
90    Unknown,
91}
92
93/// Response body for `ci.pipeline.status`.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
96pub struct CiPipelineStatusOutput {
97    pub phase: PipelinePhase,
98    /// URL to the run in the provider's UI.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub url: Option<String>,
101    /// Free-form detail — provider error message, failure summary. Populated
102    /// when `phase` is `failing` or `unknown`.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub detail: Option<String>,
105}
106
107impl InternalVerb for CiPipelineStatus {
108    type Input = CiPipelineStatusInput;
109    type Output = CiPipelineStatusOutput;
110    const ID: &'static str = "ci.pipeline.status";
111    const CATEGORY: VerbCategory = VerbCategory::Ci;
112}
113
114// ── ci.artifact.fetch ─────────────────────────────────────────────────────
115
116/// Marker type for `ci.artifact.fetch`.
117pub struct CiArtifactFetch;
118
119/// Request body for `ci.artifact.fetch`.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
122pub struct CiArtifactFetchInput {
123    /// Run ID from a prior `ci.pipeline.run`.
124    pub run_id: String,
125    /// Provider-specific artifact name or path (e.g. `"dist/app.tar.gz"` on
126    /// GitHub Actions, a numeric artifact ID on GitLab).
127    pub artifact_name: String,
128}
129
130/// Response body for `ci.artifact.fetch`.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
133pub struct CiArtifactFetchOutput {
134    /// Pre-signed or authenticated download URL. May be time-limited; see
135    /// `expires_at`.
136    pub download_url: String,
137    /// RFC 3339 expiry of the download URL. `None` when the URL is permanent
138    /// or the provider doesn't report it.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub expires_at: Option<String>,
141    /// Artifact size in bytes. `None` when not reported by the provider.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub size_bytes: Option<u64>,
144}
145
146impl InternalVerb for CiArtifactFetch {
147    type Input = CiArtifactFetchInput;
148    type Output = CiArtifactFetchOutput;
149    const ID: &'static str = "ci.artifact.fetch";
150    const CATEGORY: VerbCategory = VerbCategory::Ci;
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn verb_ids_match_canonical_namespace() {
159        for id in [CiPipelineRun::ID, CiPipelineStatus::ID, CiArtifactFetch::ID] {
160            assert!(id.starts_with("ci."), "{id}");
161        }
162    }
163
164    #[test]
165    fn verbs_are_under_ci_category() {
166        assert_eq!(CiPipelineRun::CATEGORY, VerbCategory::Ci);
167        assert_eq!(CiPipelineStatus::CATEGORY, VerbCategory::Ci);
168        assert_eq!(CiArtifactFetch::CATEGORY, VerbCategory::Ci);
169    }
170
171    #[test]
172    fn pipeline_run_input_ref_renamed_in_wire() {
173        let wire = r#"{"pipeline":"release.yml","ref":"main"}"#;
174        let parsed: CiPipelineRunInput = serde_json::from_str(wire).unwrap();
175        assert_eq!(parsed.git_ref, "main");
176        // Verify it serializes back as "ref".
177        let back = serde_json::to_value(&parsed).unwrap();
178        assert!(back.get("ref").is_some(), "must serialize as 'ref'");
179        assert!(back.get("git_ref").is_none());
180    }
181
182    #[test]
183    fn pipeline_run_variables_optional() {
184        let no_vars = r#"{"pipeline":"build.yml","ref":"main"}"#;
185        let parsed: CiPipelineRunInput = serde_json::from_str(no_vars).unwrap();
186        assert!(parsed.variables.is_none());
187
188        let with_vars = r#"{"pipeline":"build.yml","ref":"main","variables":{"ENV":"staging"}}"#;
189        let parsed: CiPipelineRunInput = serde_json::from_str(with_vars).unwrap();
190        assert_eq!(
191            parsed
192                .variables
193                .as_ref()
194                .unwrap()
195                .get("ENV")
196                .map(|s| s.as_str()),
197            Some("staging")
198        );
199    }
200
201    #[test]
202    fn pipeline_phase_is_snake_case() {
203        assert_eq!(
204            serde_json::to_string(&PipelinePhase::Passing).unwrap(),
205            "\"passing\""
206        );
207        assert_eq!(
208            serde_json::to_string(&PipelinePhase::Unknown).unwrap(),
209            "\"unknown\""
210        );
211    }
212
213    #[test]
214    fn pipeline_status_omits_detail_when_absent() {
215        let out = CiPipelineStatusOutput {
216            phase: PipelinePhase::Running,
217            url: None,
218            detail: None,
219        };
220        let wire = serde_json::to_value(&out).unwrap();
221        assert!(!wire.as_object().unwrap().contains_key("detail"));
222    }
223
224    #[test]
225    fn artifact_fetch_output_omits_optional_fields_when_absent() {
226        let out = CiArtifactFetchOutput {
227            download_url: "https://example.com/artifact.tar.gz".into(),
228            expires_at: None,
229            size_bytes: None,
230        };
231        let wire = serde_json::to_value(&out).unwrap();
232        assert!(!wire.as_object().unwrap().contains_key("expires_at"));
233        assert!(!wire.as_object().unwrap().contains_key("size_bytes"));
234    }
235
236    #[cfg(feature = "json-schema")]
237    #[test]
238    fn verbs_emit_schemas_via_for_verb() {
239        use super::super::VerbDescriptor;
240
241        let run = VerbDescriptor::for_verb::<CiPipelineRun>();
242        assert_eq!(run.id, "ci.pipeline.run");
243        assert!(run.input_schema.to_string().contains("pipeline"));
244
245        let status = VerbDescriptor::for_verb::<CiPipelineStatus>();
246        assert_eq!(status.id, "ci.pipeline.status");
247        assert!(status.output_schema.to_string().contains("phase"));
248
249        let fetch = VerbDescriptor::for_verb::<CiArtifactFetch>();
250        assert_eq!(fetch.id, "ci.artifact.fetch");
251        assert!(fetch.output_schema.to_string().contains("download_url"));
252    }
253}