1use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct PullRequest {
35 pub number: u32,
40 pub url: String,
41 pub title: String,
42 pub owner: String,
43 pub repo: String,
44 pub branch: String,
46 pub base_branch: String,
48 pub is_draft: bool,
49}
50
51#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
71#[serde(rename_all = "snake_case")]
72pub enum MergeMethod {
73 #[default]
75 Merge,
76 Squash,
77 Rebase,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct CheckRun {
88 pub name: String,
89 pub status: CheckStatus,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub url: Option<String>,
94 #[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#[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#[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#[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 None,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct ReviewComment {
165 pub id: String,
166 pub author: String,
167 pub body: String,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub path: Option<String>,
171 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct AutomatedComment {
197 pub id: String,
198 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct ScmWebhookRequest {
219 pub method: String,
220 pub headers: std::collections::HashMap<String, Vec<String>>,
223 pub body: String,
224 #[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct ScmWebhookEvent {
263 pub provider: String,
265 pub kind: ScmWebhookEventKind,
266 pub action: String,
268 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 #[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#[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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub blockers: Vec<String>,
337}
338
339impl MergeReadiness {
340 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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
385pub struct IssueFilters {
386 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub limit: Option<u32>,
396}
397
398#[derive(Debug, Clone, Default, Serialize, Deserialize)]
401pub struct IssueUpdate {
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub state: Option<String>,
405 #[serde(default, skip_serializing_if = "Vec::is_empty")]
407 pub labels: Vec<String>,
408 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub comment: Option<String>,
416}
417
418#[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 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 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 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}