use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequest {
pub number: u32,
pub url: String,
pub title: String,
pub owner: String,
pub repo: String,
pub branch: String,
pub base_branch: String,
pub is_draft: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrState {
Open,
Merged,
Closed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MergeMethod {
#[default]
Merge,
Squash,
Rebase,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckRun {
pub name: String,
pub status: CheckStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conclusion: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
Pending,
Running,
Passed,
Failed,
Skipped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CiStatus {
Pending,
Passing,
Failing,
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Review {
pub author: String,
pub state: ReviewState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewState {
Approved,
ChangesRequested,
Commented,
Dismissed,
Pending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewDecision {
Approved,
ChangesRequested,
Pending,
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewComment {
pub id: String,
pub author: String,
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
pub is_resolved: bool,
pub url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AutomatedCommentSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutomatedComment {
pub id: String,
pub bot_name: String,
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
pub severity: AutomatedCommentSeverity,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScmWebhookRequest {
pub method: String,
pub headers: std::collections::HashMap<String, Vec<String>>,
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_body: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ScmWebhookVerificationResult {
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivery_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event_type: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScmWebhookEventKind {
PullRequest,
Ci,
Review,
Comment,
Push,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScmWebhookEvent {
pub provider: String,
pub kind: ScmWebhookEventKind,
pub action: String,
pub raw_event_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivery_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<ScmWebhookRepository>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr_number: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha: Option<String>,
#[serde(default)]
pub data: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScmWebhookRepository {
pub owner: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrSummary {
pub state: PrState,
pub title: String,
pub additions: u32,
pub deletions: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MergeReadiness {
pub mergeable: bool,
pub ci_passing: bool,
pub approved: bool,
pub no_conflicts: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blockers: Vec<String>,
}
impl MergeReadiness {
pub fn is_ready(&self) -> bool {
self.mergeable
&& self.ci_passing
&& self.approved
&& self.no_conflicts
&& self.blockers.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Issue {
pub id: String,
pub title: String,
pub description: String,
pub url: String,
pub state: IssueState,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub milestone: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueState {
Open,
InProgress,
Closed,
Cancelled,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IssueFilters {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IssueUpdate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub remove_labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateIssueInput {
pub title: String,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pull_request_roundtrips_yaml() {
let pr = PullRequest {
number: 42,
url: "https://github.com/acme/widgets/pull/42".into(),
title: "fix the widgets".into(),
owner: "acme".into(),
repo: "widgets".into(),
branch: "ao-3a4b5c6d".into(),
base_branch: "main".into(),
is_draft: false,
};
let yaml = serde_yaml::to_string(&pr).unwrap();
let back: PullRequest = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(pr, back);
}
#[test]
fn pr_state_uses_snake_case() {
let yaml = serde_yaml::to_string(&PrState::Merged).unwrap();
assert_eq!(yaml.trim(), "merged");
let parsed: PrState = serde_yaml::from_str("open").unwrap();
assert_eq!(parsed, PrState::Open);
}
#[test]
fn merge_method_default_is_merge() {
assert_eq!(MergeMethod::default(), MergeMethod::Merge);
}
#[test]
fn check_run_optional_fields_skip_when_none() {
let run = CheckRun {
name: "ci/build".into(),
status: CheckStatus::Passed,
url: None,
conclusion: None,
};
let yaml = serde_yaml::to_string(&run).unwrap();
assert!(!yaml.contains("url"));
assert!(!yaml.contains("conclusion"));
let back: CheckRun = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(run, back);
}
#[test]
fn check_status_variants_serialize_snake_case() {
assert_eq!(
serde_yaml::to_string(&CheckStatus::Running).unwrap().trim(),
"running"
);
assert_eq!(
serde_yaml::to_string(&CheckStatus::Failed).unwrap().trim(),
"failed"
);
}
#[test]
fn ci_status_none_variant_roundtrips() {
let yaml = serde_yaml::to_string(&CiStatus::None).unwrap();
assert_eq!(yaml.trim(), "none");
let back: CiStatus = serde_yaml::from_str("none").unwrap();
assert_eq!(back, CiStatus::None);
}
#[test]
fn review_state_changes_requested_serializes_correctly() {
let review = Review {
author: "alice".into(),
state: ReviewState::ChangesRequested,
body: Some("needs work".into()),
};
let yaml = serde_yaml::to_string(&review).unwrap();
assert!(yaml.contains("state: changes_requested"));
let back: Review = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(review, back);
}
#[test]
fn review_comment_inline_fields_optional() {
let comment = ReviewComment {
id: "c1".into(),
author: "bot".into(),
body: "nit: rename foo".into(),
path: Some("src/foo.rs".into()),
line: Some(42),
is_resolved: false,
url: "https://github.com/acme/widgets/pull/42#discussion_r1".into(),
};
let back: ReviewComment =
serde_yaml::from_str(&serde_yaml::to_string(&comment).unwrap()).unwrap();
assert_eq!(comment, back);
}
#[test]
fn merge_readiness_is_ready_requires_every_gate() {
let green = MergeReadiness {
mergeable: true,
ci_passing: true,
approved: true,
no_conflicts: true,
blockers: vec![],
};
assert!(green.is_ready());
for mutate in [
|r: &mut MergeReadiness| r.mergeable = false,
|r: &mut MergeReadiness| r.ci_passing = false,
|r: &mut MergeReadiness| r.approved = false,
|r: &mut MergeReadiness| r.no_conflicts = false,
|r: &mut MergeReadiness| r.blockers.push("branch protection".into()),
] {
let mut r = green.clone();
mutate(&mut r);
assert!(!r.is_ready());
}
}
#[test]
fn issue_roundtrip_with_labels() {
let issue = Issue {
id: "#7".into(),
title: "add dark mode".into(),
description: "users keep asking".into(),
url: "https://github.com/acme/widgets/issues/7".into(),
state: IssueState::InProgress,
labels: vec!["feature".into(), "ui".into()],
assignee: Some("bob".into()),
milestone: None,
};
let back: Issue = serde_yaml::from_str(&serde_yaml::to_string(&issue).unwrap()).unwrap();
assert_eq!(issue, back);
}
#[test]
fn issue_state_in_progress_uses_snake_case() {
let yaml = serde_yaml::to_string(&IssueState::InProgress).unwrap();
assert_eq!(yaml.trim(), "in_progress");
}
}