Skip to main content

ao_core/
scm.rs

1//! Domain types for the `Scm` and `Tracker` plugin slots.
2//!
3//! Mirrors the SCM/Tracker types in `packages/core/src/types.ts` (lines 500–820
4//! in the reference repo), Rustified:
5//!
6//! - PascalCase struct fields become snake_case.
7//! - `Date` becomes a plain `String` we don't interpret yet — the reaction
8//!   engine only needs *ordering* of reviews, not wall-clock arithmetic, so
9//!   we skip the chrono dep until something actually needs it.
10//! - TS unions like `"open" | "merged" | "closed"` become snake_case enums
11//!   with `#[serde(rename_all = "snake_case")]`.
12//! - Several speculative fields from TS (batch enrichment, webhook parsing,
13//!   GraphQL optimisation) are intentionally left out — Slice 2's reactions
14//!   can be implemented without them and we can add them back if a real use
15//!   case shows up.
16//!
17//! These types are consumed by `Scm` and `Tracker` in `traits.rs`, and later
18//! by the reaction engine in `reactions.rs`.
19
20use serde::{Deserialize, Serialize};
21
22// =============================================================================
23// PR types
24// =============================================================================
25
26/// Metadata about a pull request. Returned by `Scm::detect_pr` and carried
27/// into every other `Scm` method. The TS reference calls this `PRInfo`.
28///
29/// Kept intentionally small — the lifecycle loop derives everything it needs
30/// from `(owner, repo, number)` via follow-up calls to `pr_state`, `ci_status`,
31/// and `review_decision`. Extra PR fields live on the enrichment structs the
32/// plugin can return piecemeal.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct PullRequest {
35    /// GitHub/GitLab PR number. `u32` is deliberate and specific to
36    /// SCM-style numeric PR ids — `Tracker::Issue::id` is `String`
37    /// because issue trackers (Linear `LIN-1327`, Jira `PROJ-42`,
38    /// GitHub `#42`) don't share a numeric type.
39    pub number: u32,
40    pub url: String,
41    pub title: String,
42    pub owner: String,
43    pub repo: String,
44    /// Head branch of the PR (the session's branch).
45    pub branch: String,
46    /// Base branch the PR targets.
47    pub base_branch: String,
48    pub is_draft: bool,
49}
50
51/// Open/merged/closed. TS exports this both as a string union and as
52/// `PR_STATE` constants — we use a plain enum.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum PrState {
56    Open,
57    Merged,
58    Closed,
59}
60
61/// How to merge a PR. Mirrors GitHub's three merge methods exactly.
62///
63/// **Parity note (issue #109):** the default (`Merge`) diverges intentionally
64/// from the ao-ts reference, which defaults to `Squash`. Squash rewrites
65/// commit history, so ao-rs picks the safer default and asks users to opt
66/// into squash/rebase explicitly via the reaction config's `merge_method:`
67/// key (see `ReactionConfig::merge_method`) or the project-level override.
68/// The decision record lives at
69/// `docs/plans/remaining-to-port/7-4-default-merge-method.md`.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
71#[serde(rename_all = "snake_case")]
72pub enum MergeMethod {
73    /// Default merge commit. Safe, preserves history.
74    #[default]
75    Merge,
76    Squash,
77    Rebase,
78}
79
80// =============================================================================
81// CI types
82// =============================================================================
83
84/// Status of an individual CI check. TS calls this `CICheck`; we use
85/// `CheckRun` to match GitHub's own API naming (`check_run` events, etc.).
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct CheckRun {
88    pub name: String,
89    pub status: CheckStatus,
90    /// URL to the check run (for linking humans to logs). Optional because
91    /// some CI providers don't publish a public URL until the run completes.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub url: Option<String>,
94    /// Provider-specific conclusion string — GitHub's `check_run.conclusion`:
95    /// `success`, `failure`, `neutral`, `cancelled`, `skipped`, `timed_out`,
96    /// `action_required`, `stale`. Kept as an opaque `String` because:
97    /// (1) different providers emit different sets and we don't want an
98    /// enum churn every time a new value appears; (2) the `status` field
99    /// above is our normalized view — conclusion is the raw trailer the
100    /// `ci-failed` reaction can include in its message to the agent.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub conclusion: Option<String>,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum CheckStatus {
108    Pending,
109    Running,
110    Passed,
111    Failed,
112    Skipped,
113}
114
115/// Rolled-up CI summary for a PR. `None` means "no CI configured".
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum CiStatus {
119    Pending,
120    Passing,
121    Failing,
122    None,
123}
124
125// =============================================================================
126// Review types
127// =============================================================================
128
129/// A review on a PR. TS includes a `submittedAt: Date` here — we drop it
130/// until the reaction engine actually needs ordering beyond "is there one
131/// newer than the last status check", at which point a string is enough.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct Review {
134    pub author: String,
135    pub state: ReviewState,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub body: Option<String>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum ReviewState {
143    Approved,
144    ChangesRequested,
145    Commented,
146    Dismissed,
147    Pending,
148}
149
150/// Overall review decision — what GitHub shows on the PR header.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum ReviewDecision {
154    Approved,
155    ChangesRequested,
156    Pending,
157    /// No review required / no reviewers assigned.
158    None,
159}
160
161/// A single unresolved review comment. The reaction engine forwards these
162/// verbatim to the agent when handling `changes-requested`.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct ReviewComment {
165    pub id: String,
166    pub author: String,
167    pub body: String,
168    /// File path the comment is pinned to (if inline).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub path: Option<String>,
171    /// Line number inside `path`.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub line: Option<u32>,
174    pub is_resolved: bool,
175    pub url: String,
176}
177
178/// Severity of an automated review comment.
179///
180/// Derived heuristically from the comment body. Mirrors the TS
181/// `AutomatedComment.severity` union (`"error" | "warning" | "info"`).
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum AutomatedCommentSeverity {
185    Error,
186    Warning,
187    Info,
188}
189
190/// A comment on a PR left by an automated bot (linter, security scanner,
191/// review bot). Mirrors the TS `AutomatedComment` type.
192///
193/// Distinct from `ReviewComment` because the reaction engine wants to route
194/// bot chatter (`bugbot-comments`) differently from human review threads.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct AutomatedComment {
197    pub id: String,
198    /// Bot login (e.g. `"dependabot[bot]"`).
199    pub bot_name: String,
200    pub body: String,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub path: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub line: Option<u32>,
205    pub severity: AutomatedCommentSeverity,
206    pub url: String,
207}
208
209// =============================================================================
210// Webhook types
211// =============================================================================
212
213/// Raw webhook delivery as handed to a plugin. Mirrors the TS
214/// `SCMWebhookRequest` shape; headers are case-insensitive per RFC 7230 —
215/// plugins should look them up via the helpers in this module rather than
216/// indexing `headers` directly.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct ScmWebhookRequest {
219    pub method: String,
220    /// Header name → value(s). Values may be a single string or a list
221    /// (some HTTP stacks keep repeated headers as arrays).
222    pub headers: std::collections::HashMap<String, Vec<String>>,
223    pub body: String,
224    /// Raw bytes for signature verification. HMAC must hash the bytes *as
225    /// received*; UTF-8 decoding can lose information on non-ASCII payloads.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub raw_body: Option<Vec<u8>>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub path: Option<String>,
230}
231
232/// Result of `Scm::verify_webhook`. `ok: false` with an actionable `reason`
233/// is the typical failure path — the HTTP handler returns 401/403 with the
234/// reason in logs (not the response body).
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
236pub struct ScmWebhookVerificationResult {
237    pub ok: bool,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub reason: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub delivery_id: Option<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub event_type: Option<String>,
244}
245
246/// Coarse classification of a webhook event. Mirrors TS
247/// `SCMWebhookEventKind`.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum ScmWebhookEventKind {
251    PullRequest,
252    Ci,
253    Review,
254    Comment,
255    Push,
256    Unknown,
257}
258
259/// Provider-agnostic webhook event. `data` carries the full raw payload so
260/// a downstream consumer can extract details we haven't normalised.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct ScmWebhookEvent {
263    /// Plugin name that produced this event (`"github"`, `"gitlab"`, ...).
264    pub provider: String,
265    pub kind: ScmWebhookEventKind,
266    /// Provider-specific action string (e.g. `"opened"`, `"synchronize"`).
267    pub action: String,
268    /// Raw event type header (e.g. `"pull_request"`, `"check_run"`).
269    pub raw_event_type: String,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub delivery_id: Option<String>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub repository: Option<ScmWebhookRepository>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub pr_number: Option<u32>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub branch: Option<String>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub sha: Option<String>,
280    /// Raw JSON payload. `serde_json::Value` rather than a typed struct
281    /// because every provider has its own payload shape and we don't want
282    /// to maintain per-provider types in the domain layer.
283    #[serde(default)]
284    pub data: serde_json::Value,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct ScmWebhookRepository {
289    pub owner: String,
290    pub name: String,
291}
292
293// =============================================================================
294// PR summary
295// =============================================================================
296
297/// Top-line PR stats — used by CLI/dashboard views that want state + diff
298/// size without a full enrichment call. Mirrors the anonymous return type
299/// of TS `SCM.getPRSummary`.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct PrSummary {
302    pub state: PrState,
303    pub title: String,
304    pub additions: u32,
305    pub deletions: u32,
306}
307
308// =============================================================================
309// Merge readiness
310// =============================================================================
311
312/// Result of `Scm::mergeability`. The reaction engine reads this to decide
313/// whether the `approved-and-green` reaction should fire. Every bool is
314/// "true means this particular gate is green".
315///
316/// `mergeable` and `no_conflicts` look redundant but aren't quite:
317/// - `mergeable` is the provider's top-line verdict — GitHub aggregates
318///   branch protection, required reviews, required checks, *and* conflicts
319///   into one bool.
320/// - `no_conflicts` is specifically "the branch has no text-level merge
321///   conflicts with base".
322///
323/// A PR can be `mergeable: false, no_conflicts: true` (branch is clean
324/// but a required review is missing) and that distinction matters for
325/// reaction routing: `changes-requested` vs `merge-conflicts` are
326/// different reaction keys.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct MergeReadiness {
329    pub mergeable: bool,
330    pub ci_passing: bool,
331    pub approved: bool,
332    pub no_conflicts: bool,
333    /// Human-readable reasons the PR isn't mergeable yet. Empty when all
334    /// gates are green.
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub blockers: Vec<String>,
337}
338
339impl MergeReadiness {
340    /// `true` iff every gate passes and `blockers` is empty. Convenience
341    /// for reaction-engine decision points that don't care *why* a PR is
342    /// blocked, only *whether*.
343    pub fn is_ready(&self) -> bool {
344        self.mergeable
345            && self.ci_passing
346            && self.approved
347            && self.no_conflicts
348            && self.blockers.is_empty()
349    }
350}
351
352// =============================================================================
353// Issue tracker types
354// =============================================================================
355
356/// An issue in a tracker (GitHub Issues, Linear, Jira, ...). Slice 2 only
357/// needs this for `Tracker::get_issue`; `Tracker::branch_name` and friends
358/// are string-only.
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360pub struct Issue {
361    pub id: String,
362    pub title: String,
363    pub description: String,
364    pub url: String,
365    pub state: IssueState,
366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
367    pub labels: Vec<String>,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub assignee: Option<String>,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub milestone: Option<String>,
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
375#[serde(rename_all = "snake_case")]
376pub enum IssueState {
377    Open,
378    InProgress,
379    Closed,
380    Cancelled,
381}
382
383/// Filters for listing issues. Mirrors TS `IssueFilters`.
384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
385pub struct IssueFilters {
386    /// `"open"`, `"closed"`, or `"all"`. Defaults to `"open"` when absent.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub state: Option<String>,
389    #[serde(default, skip_serializing_if = "Vec::is_empty")]
390    pub labels: Vec<String>,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub assignee: Option<String>,
393    /// Maximum issues to return. Plugin picks its own cap when absent.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub limit: Option<u32>,
396}
397
398/// Patch fields for updating an existing issue. Mirrors TS `IssueUpdate`.
399/// Only `Some` fields are applied; `None` means "leave unchanged".
400#[derive(Debug, Clone, Default, Serialize, Deserialize)]
401pub struct IssueUpdate {
402    /// New state: `"open"` or `"closed"`.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub state: Option<String>,
405    /// Labels to add.
406    #[serde(default, skip_serializing_if = "Vec::is_empty")]
407    pub labels: Vec<String>,
408    /// Labels to remove.
409    #[serde(default, skip_serializing_if = "Vec::is_empty")]
410    pub remove_labels: Vec<String>,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub assignee: Option<String>,
413    /// Post a comment while updating.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub comment: Option<String>,
416}
417
418/// Input for creating a new issue. Mirrors TS `CreateIssueInput`.
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct CreateIssueInput {
421    pub title: String,
422    pub description: String,
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub labels: Vec<String>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub assignee: Option<String>,
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn pull_request_roundtrips_yaml() {
435        let pr = PullRequest {
436            number: 42,
437            url: "https://github.com/acme/widgets/pull/42".into(),
438            title: "fix the widgets".into(),
439            owner: "acme".into(),
440            repo: "widgets".into(),
441            branch: "ao-3a4b5c6d".into(),
442            base_branch: "main".into(),
443            is_draft: false,
444        };
445        let yaml = serde_yaml::to_string(&pr).unwrap();
446        let back: PullRequest = serde_yaml::from_str(&yaml).unwrap();
447        assert_eq!(pr, back);
448    }
449
450    #[test]
451    fn pr_state_uses_snake_case() {
452        let yaml = serde_yaml::to_string(&PrState::Merged).unwrap();
453        assert_eq!(yaml.trim(), "merged");
454        let parsed: PrState = serde_yaml::from_str("open").unwrap();
455        assert_eq!(parsed, PrState::Open);
456    }
457
458    #[test]
459    fn merge_method_default_is_merge() {
460        assert_eq!(MergeMethod::default(), MergeMethod::Merge);
461    }
462
463    #[test]
464    fn check_run_optional_fields_skip_when_none() {
465        let run = CheckRun {
466            name: "ci/build".into(),
467            status: CheckStatus::Passed,
468            url: None,
469            conclusion: None,
470        };
471        let yaml = serde_yaml::to_string(&run).unwrap();
472        // No `url:` or `conclusion:` keys at all — skip_serializing_if eats them.
473        assert!(!yaml.contains("url"));
474        assert!(!yaml.contains("conclusion"));
475        let back: CheckRun = serde_yaml::from_str(&yaml).unwrap();
476        assert_eq!(run, back);
477    }
478
479    #[test]
480    fn check_status_variants_serialize_snake_case() {
481        assert_eq!(
482            serde_yaml::to_string(&CheckStatus::Running).unwrap().trim(),
483            "running"
484        );
485        assert_eq!(
486            serde_yaml::to_string(&CheckStatus::Failed).unwrap().trim(),
487            "failed"
488        );
489    }
490
491    #[test]
492    fn ci_status_none_variant_roundtrips() {
493        // "None" the variant, not `Option::None` — if this ever starts
494        // serializing as the YAML null `~` we've broken config compat.
495        let yaml = serde_yaml::to_string(&CiStatus::None).unwrap();
496        assert_eq!(yaml.trim(), "none");
497        let back: CiStatus = serde_yaml::from_str("none").unwrap();
498        assert_eq!(back, CiStatus::None);
499    }
500
501    #[test]
502    fn review_state_changes_requested_serializes_correctly() {
503        let review = Review {
504            author: "alice".into(),
505            state: ReviewState::ChangesRequested,
506            body: Some("needs work".into()),
507        };
508        let yaml = serde_yaml::to_string(&review).unwrap();
509        assert!(yaml.contains("state: changes_requested"));
510        let back: Review = serde_yaml::from_str(&yaml).unwrap();
511        assert_eq!(review, back);
512    }
513
514    #[test]
515    fn review_comment_inline_fields_optional() {
516        let comment = ReviewComment {
517            id: "c1".into(),
518            author: "bot".into(),
519            body: "nit: rename foo".into(),
520            path: Some("src/foo.rs".into()),
521            line: Some(42),
522            is_resolved: false,
523            url: "https://github.com/acme/widgets/pull/42#discussion_r1".into(),
524        };
525        let back: ReviewComment =
526            serde_yaml::from_str(&serde_yaml::to_string(&comment).unwrap()).unwrap();
527        assert_eq!(comment, back);
528    }
529
530    #[test]
531    fn merge_readiness_is_ready_requires_every_gate() {
532        let green = MergeReadiness {
533            mergeable: true,
534            ci_passing: true,
535            approved: true,
536            no_conflicts: true,
537            blockers: vec![],
538        };
539        assert!(green.is_ready());
540
541        // Any single false flips it.
542        for mutate in [
543            |r: &mut MergeReadiness| r.mergeable = false,
544            |r: &mut MergeReadiness| r.ci_passing = false,
545            |r: &mut MergeReadiness| r.approved = false,
546            |r: &mut MergeReadiness| r.no_conflicts = false,
547            |r: &mut MergeReadiness| r.blockers.push("branch protection".into()),
548        ] {
549            let mut r = green.clone();
550            mutate(&mut r);
551            assert!(!r.is_ready());
552        }
553    }
554
555    #[test]
556    fn issue_roundtrip_with_labels() {
557        let issue = Issue {
558            id: "#7".into(),
559            title: "add dark mode".into(),
560            description: "users keep asking".into(),
561            url: "https://github.com/acme/widgets/issues/7".into(),
562            state: IssueState::InProgress,
563            labels: vec!["feature".into(), "ui".into()],
564            assignee: Some("bob".into()),
565            milestone: None,
566        };
567        let back: Issue = serde_yaml::from_str(&serde_yaml::to_string(&issue).unwrap()).unwrap();
568        assert_eq!(issue, back);
569    }
570
571    #[test]
572    fn issue_state_in_progress_uses_snake_case() {
573        let yaml = serde_yaml::to_string(&IssueState::InProgress).unwrap();
574        assert_eq!(yaml.trim(), "in_progress");
575    }
576}