1use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8use crate::model::{DiffFile, LineSide};
9use crate::vcs::CommitInfo;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct PrId {
14 pub owner: String,
15 pub repo: String,
16 pub number: u64,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct PrMetadata {
22 pub title: String,
23 pub body: String,
24 pub author: String,
25 pub state: PrState,
26 pub base_branch: String,
27 pub head_branch: String,
28 pub head_sha: String,
29 pub created_at: DateTime<Utc>,
30 pub mergeable: Option<MergeableStatus>,
31 pub is_draft: bool,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub enum PrState {
36 Open,
37 Closed,
38 Merged,
39}
40
41impl std::fmt::Display for PrState {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 PrState::Open => write!(f, "open"),
45 PrState::Closed => write!(f, "closed"),
46 PrState::Merged => write!(f, "merged"),
47 }
48 }
49}
50
51impl PrState {
52 #[must_use]
53 pub fn display(&self) -> &'static str {
54 match self {
55 PrState::Open => "open",
56 PrState::Closed => "closed",
57 PrState::Merged => "merged",
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct PrListItem {
68 pub number: u64,
69 pub title: String,
70 pub author: String,
71 pub state: PrState,
72 pub is_draft: bool,
73 pub base_branch: String,
74 pub head_branch: String,
75 pub updated_at: DateTime<Utc>,
76 pub comment_count: u32,
77 pub has_review_requested_from_me: bool,
78 #[serde(default)]
84 pub assignees: Vec<String>,
85 #[serde(default)]
87 pub reviewers: Vec<String>,
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
92pub struct PrListFilter {
93 pub state: Option<PrState>,
94 pub author: Option<String>,
95 pub assignee: Option<String>,
96 pub review_requested: Option<bool>,
97 pub max: Option<u32>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
101pub enum MergeableStatus {
102 Clean,
103 Unstable,
104 Behind,
105 Blocked,
106 Dirty,
107 Draft,
108 Unknown,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
112pub enum MergeMethod {
113 Merge,
114 Squash,
115 Rebase,
116}
117
118impl std::fmt::Display for MergeMethod {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 match self {
121 MergeMethod::Merge => write!(f, "merge"),
122 MergeMethod::Squash => write!(f, "squash"),
123 MergeMethod::Rebase => write!(f, "rebase"),
124 }
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129pub enum ReviewVerdict {
130 Approve,
131 RequestChanges,
132 Comment,
133}
134
135impl std::fmt::Display for ReviewVerdict {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 match self {
138 ReviewVerdict::Approve => write!(f, "approve"),
139 ReviewVerdict::RequestChanges => write!(f, "request_changes"),
140 ReviewVerdict::Comment => write!(f, "comment"),
141 }
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct RemoteComment {
147 pub id: u64,
148 pub author: String,
149 pub body: String,
150 pub path: Option<String>,
151 pub line: Option<u32>,
152 pub side: Option<LineSide>,
153 pub created_at: DateTime<Utc>,
154 pub in_reply_to: Option<u64>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct ReviewThread {
159 pub id: String,
160 pub is_resolved: bool,
161 pub root_comment_id: u64,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct NewComment {
166 pub path: String,
167 pub line: u32,
168 pub side: LineSide,
169 pub body: String,
170 pub start_line: Option<u32>,
171 pub commit_id: Option<String>,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct NewReview {
176 pub verdict: ReviewVerdict,
177 pub body: String,
178 pub comments: Vec<NewComment>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
182pub enum ReactionContent {
183 ThumbsUp,
184 ThumbsDown,
185 Laugh,
186 Hooray,
187 Confused,
188 Heart,
189 Rocket,
190 Eyes,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum ReactionTarget {
195 IssueComment(u64),
196 ReviewComment(u64),
197 Review(String),
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct User {
202 pub login: String,
203 pub id: u64,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct Permissions {
208 pub can_push: bool,
209 pub can_merge: bool,
210 pub allowed_merge_methods: Vec<MergeMethod>,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
214#[must_use]
215pub enum ForgeType {
216 GitHub,
217 GitLab,
218}
219
220impl std::fmt::Display for ForgeType {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 match self {
223 ForgeType::GitHub => write!(f, "GitHub"),
224 ForgeType::GitLab => write!(f, "GitLab"),
225 }
226 }
227}
228
229pub type ForgeWarnHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;
237
238#[async_trait]
254pub trait ForgeRead: Send + Sync {
255 fn forge_type(&self) -> ForgeType;
256
257 async fn get_pr(&self, id: &PrId) -> Result<PrMetadata>;
259 async fn get_pr_commits(&self, id: &PrId) -> Result<Vec<CommitInfo>>;
260 async fn get_pr_files(&self, id: &PrId) -> Result<Vec<DiffFile>>;
261 async fn get_commit_diff(&self, id: &PrId, commit_sha: &str) -> Result<Vec<DiffFile>>;
262
263 async fn list_prs(
269 &self,
270 owner: &str,
271 repo: &str,
272 filter: &PrListFilter,
273 ) -> Result<Vec<PrListItem>>;
274
275 async fn current_user(&self) -> Result<User>;
277 async fn check_permissions(&self, id: &PrId) -> Result<Permissions>;
278}
279
280#[async_trait]
282pub trait ForgeComments: Send + Sync {
283 async fn get_comments(&self, id: &PrId) -> Result<Vec<RemoteComment>>;
284 async fn get_review_threads(&self, id: &PrId) -> Result<Vec<ReviewThread>>;
285 async fn post_comment(&self, id: &PrId, comment: NewComment) -> Result<RemoteComment>;
286 async fn post_reply(&self, id: &PrId, thread_id: &str, body: &str) -> Result<RemoteComment>;
287 async fn edit_comment(&self, id: &PrId, comment_id: u64, body: &str) -> Result<RemoteComment>;
288 async fn delete_comment(&self, id: &PrId, comment_id: u64) -> Result<()>;
289 async fn resolve_thread(&self, thread_id: &str) -> Result<()>;
290 async fn unresolve_thread(&self, thread_id: &str) -> Result<()>;
291}
292
293#[async_trait]
295pub trait ForgeReview: Send + Sync {
296 async fn submit_review(&self, id: &PrId, review: NewReview) -> Result<()>;
297}
298
299#[async_trait]
301pub trait ForgeMerge: Send + Sync {
302 async fn merge(&self, id: &PrId, method: MergeMethod) -> Result<()>;
303 async fn close(&self, id: &PrId) -> Result<()>;
304 async fn reopen(&self, id: &PrId) -> Result<()>;
305}
306
307#[async_trait]
309pub trait ForgeReactions: Send + Sync {
310 async fn add_reaction(&self, target: &ReactionTarget, content: ReactionContent) -> Result<()>;
311 async fn remove_reaction(
312 &self,
313 target: &ReactionTarget,
314 content: ReactionContent,
315 ) -> Result<()>;
316}
317
318pub trait ForgeBackend:
324 ForgeRead + ForgeComments + ForgeReview + ForgeMerge + ForgeReactions
325{
326}
327
328impl<T: ForgeRead + ForgeComments + ForgeReview + ForgeMerge + ForgeReactions + ?Sized> ForgeBackend
329 for T
330{
331}
332
333pub trait ForgeReadComments: ForgeRead + ForgeComments {}
337
338impl<T: ForgeRead + ForgeComments + ?Sized> ForgeReadComments for T {}
339
340pub trait ForgeMcp: ForgeRead + ForgeComments + ForgeReview {}
344
345impl<T: ForgeRead + ForgeComments + ForgeReview + ?Sized> ForgeMcp for T {}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn pr_id_construction() {
353 let id = PrId {
354 owner: "octocat".into(),
355 repo: "hello-world".into(),
356 number: 42,
357 };
358 assert_eq!(id.owner, "octocat");
359 assert_eq!(id.number, 42);
360 }
361
362 #[test]
363 fn pr_id_equality() {
364 let a = PrId {
365 owner: "o".into(),
366 repo: "r".into(),
367 number: 1,
368 };
369 let b = PrId {
370 owner: "o".into(),
371 repo: "r".into(),
372 number: 1,
373 };
374 assert_eq!(a, b);
375 }
376
377 #[test]
378 fn pr_metadata_construction() {
379 let meta = PrMetadata {
380 title: "feat: add login".into(),
381 body: String::new(),
382 author: "alice".into(),
383 state: PrState::Open,
384 base_branch: "main".into(),
385 head_branch: "feat-login".into(),
386 head_sha: "abc123".into(),
387 created_at: Utc::now(),
388 mergeable: Some(MergeableStatus::Clean),
389 is_draft: false,
390 };
391 assert_eq!(meta.state, PrState::Open);
392 assert!(!meta.is_draft);
393 }
394
395 #[test]
396 fn new_comment_equality() {
397 let c1 = NewComment {
398 path: "src/lib.rs".into(),
399 line: 10,
400 side: LineSide::New,
401 body: "fix this".into(),
402 start_line: None,
403 commit_id: None,
404 };
405 let c2 = NewComment {
406 path: "src/lib.rs".into(),
407 line: 10,
408 side: LineSide::New,
409 body: "fix this".into(),
410 start_line: None,
411 commit_id: None,
412 };
413 assert_eq!(c1, c2);
414 }
415
416 #[test]
417 fn debug_output_is_reasonable() {
418 let state = PrState::Merged;
419 let dbg = format!("{state:?}");
420 assert_eq!(dbg, "Merged");
421 }
422
423 #[test]
424 fn reaction_content_all_variants() {
425 let variants = [
426 ReactionContent::ThumbsUp,
427 ReactionContent::ThumbsDown,
428 ReactionContent::Laugh,
429 ReactionContent::Hooray,
430 ReactionContent::Confused,
431 ReactionContent::Heart,
432 ReactionContent::Rocket,
433 ReactionContent::Eyes,
434 ];
435 assert_eq!(variants.len(), 8);
436 for (i, a) in variants.iter().enumerate() {
438 for (j, b) in variants.iter().enumerate() {
439 assert_eq!(i == j, a == b);
440 }
441 }
442 }
443
444 #[test]
445 fn forge_type_variants() {
446 assert_ne!(ForgeType::GitHub, ForgeType::GitLab);
447 let gh = ForgeType::GitHub;
448 let gl = ForgeType::GitLab;
449 assert_eq!(gh, ForgeType::GitHub);
450 assert_eq!(gl, ForgeType::GitLab);
451 }
452
453 #[test]
454 fn user_equality() {
455 let a = User {
456 login: "alice".into(),
457 id: 1,
458 };
459 let b = User {
460 login: "alice".into(),
461 id: 1,
462 };
463 assert_eq!(a, b);
464 }
465
466 #[test]
467 fn pr_list_filter_default_is_empty() {
468 let f = PrListFilter::default();
469 assert!(f.state.is_none());
470 assert!(f.author.is_none());
471 assert!(f.assignee.is_none());
472 assert!(f.review_requested.is_none());
473 assert!(f.max.is_none());
474 }
475
476 #[test]
477 fn pr_list_item_construction() {
478 let item = PrListItem {
479 number: 7,
480 title: "feat: do the thing".into(),
481 author: "alice".into(),
482 state: PrState::Open,
483 is_draft: false,
484 base_branch: "main".into(),
485 head_branch: "feat".into(),
486 updated_at: Utc::now(),
487 comment_count: 2,
488 has_review_requested_from_me: true,
489 assignees: vec![],
490 reviewers: vec![],
491 };
492 assert_eq!(item.number, 7);
493 assert_eq!(item.state, PrState::Open);
494 assert!(item.has_review_requested_from_me);
495 }
496}