1use super::client::Client;
2use crate::api::{resolve_latest_comment, StateChange};
3use crate::error::{GiteeError, Result};
4use crate::models::{
5 FileDiff, Label, MergeMethod, PrComment, PrCommentKind, PrCommit, PrState, PullRequest,
6 UserAssignee,
7};
8use crate::repo::Repo;
9use std::collections::HashSet;
10
11pub struct Pulls<'a> {
12 client: &'a Client,
13 repo: &'a Repo,
14}
15
16#[derive(Default)]
17pub struct PrFilter<'a> {
18 pub state: Option<&'a str>,
19 pub author: Option<&'a str>,
20 pub assignee: Option<&'a str>,
22 pub tester: Option<&'a str>,
24 pub limit: usize,
25}
26
27#[derive(Default)]
28pub struct CreatePr<'a> {
29 pub title: &'a str,
30 pub head: &'a str,
31 pub base: &'a str,
32 pub body: Option<&'a str>,
33 pub labels: Option<&'a str>,
35 pub assignees: Option<&'a str>,
36 pub testers: Option<&'a str>,
37 pub milestone_number: Option<i64>,
38 pub issue: Option<&'a str>,
42 pub close_related_issue: bool,
43 pub draft: bool,
46}
47
48#[derive(Default)]
54pub struct EditPr<'a> {
55 pub title: Option<&'a str>,
56 pub body: Option<&'a str>,
57 pub labels: Option<&'a str>,
58 pub assignees: Option<&'a str>,
59 pub testers: Option<&'a str>,
60 pub milestone_number: Option<i64>,
61}
62
63#[derive(Default)]
66pub struct PrCommentPositional<'a> {
67 pub path: Option<&'a str>,
68 pub position: Option<i64>,
69 pub commit_id: Option<&'a str>,
70}
71
72#[derive(Default)]
75pub struct PrCommentFilter {
76 pub kind: Option<PrCommentKind>,
77 pub limit: usize,
78}
79
80impl Pulls<'_> {
81 pub(crate) fn new<'a>(client: &'a Client, repo: &'a Repo) -> Pulls<'a> {
82 Pulls { client, repo }
83 }
84
85 pub fn list(&self, filter: &PrFilter<'_>) -> Result<Vec<PullRequest>> {
86 let o = self.repo.owner.as_str();
87 let r = self.repo.name.as_str();
88 let mut q: Vec<(&str, String)> = Vec::new();
89 if let Some(s) = filter.state {
90 q.push(("state", s.to_string()));
91 }
92 if let Some(a) = filter.author {
93 q.push(("author", a.to_string()));
94 }
95 if let Some(a) = filter.assignee {
96 q.push(("assignee", a.to_string()));
97 }
98 if let Some(t) = filter.tester {
99 q.push(("tester", t.to_string()));
100 }
101 let qref = Client::str_refs(&q);
102 let path = format!("/repos/{o}/{r}/pulls");
103 self.client.get_paged(&path, &qref, filter.limit)
104 }
105
106 pub fn get(&self, number: i64) -> Result<PullRequest> {
107 let o = self.repo.owner.as_str();
108 let r = self.repo.name.as_str();
109 self.client
110 .get(&format!("/repos/{o}/{r}/pulls/{number}"), &[])
111 }
112
113 pub fn is_merged(&self, number: i64) -> Result<bool> {
116 let o = self.repo.owner.as_str();
117 let r = self.repo.name.as_str();
118 let path = format!("/repos/{o}/{r}/pulls/{number}/merge");
119 match self.client.get_ok(&path) {
120 Ok(()) => Ok(true),
121 Err(GiteeError::NotFound(_)) => Ok(false),
122 Err(e) => Err(e),
123 }
124 }
125
126 pub fn files(&self, number: i64) -> Result<Vec<FileDiff>> {
127 let o = self.repo.owner.as_str();
128 let r = self.repo.name.as_str();
129 self.client
130 .get(&format!("/repos/{o}/{r}/pulls/{number}/files"), &[])
131 }
132
133 pub fn commits(&self, number: i64, limit: usize) -> Result<Vec<PrCommit>> {
136 let o = self.repo.owner.as_str();
137 let r = self.repo.name.as_str();
138 let all: Vec<PrCommit> = self
139 .client
140 .get(&format!("/repos/{o}/{r}/pulls/{number}/commits"), &[])?;
141 Ok(all.into_iter().take(limit).collect())
142 }
143
144 pub fn create(&self, req: &CreatePr<'_>) -> Result<PullRequest> {
145 let o = self.repo.owner.as_str();
146 let r = self.repo.name.as_str();
147 let mut f: Vec<(&str, String)> = vec![
148 ("title", req.title.to_string()),
149 ("head", req.head.to_string()),
150 ("base", req.base.to_string()),
151 ];
152 if let Some(b) = req.body {
153 f.push(("body", b.to_string()));
154 }
155 if let Some(v) = req.labels {
156 f.push(("labels", v.to_string()));
157 }
158 if let Some(v) = req.assignees {
159 f.push(("assignees", v.to_string()));
160 }
161 if let Some(v) = req.testers {
162 f.push(("testers", v.to_string()));
163 }
164 if let Some(n) = req.milestone_number {
165 f.push(("milestone_number", n.to_string()));
166 }
167 if let Some(i) = req.issue {
168 f.push(("issue", i.to_string()));
169 }
170 if req.close_related_issue {
171 f.push(("close_related_issue", "true".to_string()));
172 }
173 if req.draft {
174 f.push(("draft", Client::bool_str(true).to_string()));
175 }
176 let form = Client::str_refs(&f);
177 self.client.post(&format!("/repos/{o}/{r}/pulls"), &form)
178 }
179
180 pub fn merge(&self, number: i64, method: MergeMethod, close_related_issue: bool) -> Result<()> {
181 let o = self.repo.owner.as_str();
182 let r = self.repo.name.as_str();
183 let close = Client::bool_str(close_related_issue);
184 let f: Vec<(&str, String)> = vec![
185 ("merge_method", method.as_str().to_string()),
186 ("close_related_issue", close.to_string()),
187 ];
188 let form = Client::str_refs(&f);
189 self.client
190 .put_ok(&format!("/repos/{o}/{r}/pulls/{number}/merge"), &form)
191 }
192
193 pub fn merge_idempotent(
196 &self,
197 number: i64,
198 method: MergeMethod,
199 close_related_issue: bool,
200 ) -> Result<StateChange<()>> {
201 let cur: PullRequest = self.get(number)?;
202 if cur.state == PrState::Merged {
203 return Ok(StateChange::Already(()));
204 }
205 self.merge(number, method, close_related_issue)?;
206 Ok(StateChange::Changed(()))
207 }
208
209 pub fn comment(
210 &self,
211 number: i64,
212 body: &str,
213 positional: &PrCommentPositional<'_>,
214 ) -> Result<PrComment> {
215 let o = self.repo.owner.as_str();
216 let r = self.repo.name.as_str();
217 let mut f: Vec<(&str, String)> = vec![("body", body.to_string())];
218 if let Some(p) = positional.path {
219 f.push(("path", p.to_string()));
220 }
221 if let Some(pos) = positional.position {
222 f.push(("position", pos.to_string()));
223 }
224 if let Some(c) = positional.commit_id {
225 f.push(("commit_id", c.to_string()));
226 }
227 let form = Client::str_refs(&f);
228 self.client
229 .post(&format!("/repos/{o}/{r}/pulls/{number}/comments"), &form)
230 }
231
232 pub fn list_comments(
235 &self,
236 number: i64,
237 filter: &PrCommentFilter,
238 ) -> Result<Vec<PrComment>> {
239 let o = self.repo.owner.as_str();
240 let r = self.repo.name.as_str();
241 let mut q: Vec<(&str, String)> = Vec::new();
242 if let Some(k) = filter.kind {
243 q.push(("comment_type", k.as_api_str().to_string()));
244 }
245 let qref = Client::str_refs(&q);
246 self.client.get_paged(
247 &format!("/repos/{o}/{r}/pulls/{number}/comments"),
248 &qref,
249 filter.limit,
250 )
251 }
252
253 pub fn latest_comment(&self, number: i64, login: &str) -> Result<PrComment> {
256 let comments = self.list_comments(
257 number,
258 &PrCommentFilter {
259 kind: None,
260 limit: usize::MAX,
261 },
262 )?;
263 resolve_latest_comment(&comments, login)
264 .cloned()
265 .ok_or_else(|| {
266 GiteeError::Usage(format!(
267 "no comment by '{login}' on pull request {number}"
268 ))
269 })
270 }
271
272 pub fn update_comment(&self, id: i64, body: &str) -> Result<PrComment> {
274 let o = self.repo.owner.as_str();
275 let r = self.repo.name.as_str();
276 let f: Vec<(&str, String)> = vec![("body", body.to_string())];
277 let form = Client::str_refs(&f);
278 self.client
279 .patch(&format!("/repos/{o}/{r}/pulls/comments/{id}"), &form)
280 }
281
282 pub fn update_latest_comment(
284 &self,
285 number: i64,
286 login: &str,
287 body: &str,
288 ) -> Result<PrComment> {
289 let comment = self.latest_comment(number, login)?;
290 self.update_comment(comment.id, body)
291 }
292
293 pub fn delete_comment(&self, id: i64) -> Result<StateChange<()>> {
296 let o = self.repo.owner.as_str();
297 let r = self.repo.name.as_str();
298 match self
299 .client
300 .delete_ok(&format!("/repos/{o}/{r}/pulls/comments/{id}"))
301 {
302 Ok(()) => Ok(StateChange::Changed(())),
303 Err(GiteeError::NotFound(_)) => Ok(StateChange::Already(())),
304 Err(e) => Err(e),
305 }
306 }
307
308 pub fn delete_latest_comment(
310 &self,
311 number: i64,
312 login: &str,
313 ) -> Result<StateChange<()>> {
314 let comment = self.latest_comment(number, login)?;
315 self.delete_comment(comment.id)
316 }
317
318 pub fn approve(&self, number: i64, force: bool) -> Result<()> {
320 let o = self.repo.owner.as_str();
321 let r = self.repo.name.as_str();
322 let mut f: Vec<(&str, String)> = Vec::new();
323 if force {
324 f.push(("force", "true".to_string()));
325 }
326 let form = Client::str_refs(&f);
327 self.client
328 .post_ok(&format!("/repos/{o}/{r}/pulls/{number}/review"), &form)
329 }
330
331 pub fn test(&self, number: i64, force: bool) -> Result<()> {
333 let o = self.repo.owner.as_str();
334 let r = self.repo.name.as_str();
335 let mut f: Vec<(&str, String)> = Vec::new();
336 if force {
337 f.push(("force", "true".to_string()));
338 }
339 let form = Client::str_refs(&f);
340 self.client
341 .post_ok(&format!("/repos/{o}/{r}/pulls/{number}/test"), &form)
342 }
343
344 pub fn set_state(&self, number: i64, state: PrState) -> Result<PullRequest> {
346 let o = self.repo.owner.as_str();
347 let r = self.repo.name.as_str();
348 let f: Vec<(&str, String)> = vec![("state", state.as_str().to_string())];
349 let form = Client::str_refs(&f);
350 self.client
351 .patch(&format!("/repos/{o}/{r}/pulls/{number}"), &form)
352 }
353
354 pub fn set_state_idempotent(
357 &self,
358 number: i64,
359 target: PrState,
360 ) -> Result<StateChange<PullRequest>> {
361 let cur: PullRequest = self.get(number)?;
362 if cur.state == target {
363 return Ok(StateChange::Already(cur));
364 }
365 let pr = self.set_state(number, target)?;
366 Ok(StateChange::Changed(pr))
367 }
368
369 pub fn set_draft(&self, number: i64, draft: bool) -> Result<PullRequest> {
371 let o = self.repo.owner.as_str();
372 let r = self.repo.name.as_str();
373 let f: Vec<(&str, String)> = vec![("draft", Client::bool_str(draft).to_string())];
374 let form = Client::str_refs(&f);
375 self.client
376 .patch(&format!("/repos/{o}/{r}/pulls/{number}"), &form)
377 }
378
379 pub fn set_draft_idempotent(
382 &self,
383 number: i64,
384 draft: bool,
385 ) -> Result<StateChange<PullRequest>> {
386 let cur: PullRequest = self.get(number)?;
387 if cur.draft.unwrap_or(false) == draft {
388 return Ok(StateChange::Already(cur));
389 }
390 let pr = self.set_draft(number, draft)?;
391 Ok(StateChange::Changed(pr))
392 }
393
394 pub fn edit(&self, number: i64, req: &EditPr<'_>) -> Result<PullRequest> {
396 let o = self.repo.owner.as_str();
397 let r = self.repo.name.as_str();
398 let mut f: Vec<(&str, String)> = Vec::new();
399 if let Some(v) = req.title {
400 f.push(("title", v.to_string()));
401 }
402 if let Some(v) = req.body {
403 f.push(("body", v.to_string()));
404 }
405 if let Some(v) = req.labels {
406 f.push(("labels", v.to_string()));
407 }
408 if let Some(v) = req.assignees {
409 f.push(("assignees", v.to_string()));
410 }
411 if let Some(v) = req.testers {
412 f.push(("testers", v.to_string()));
413 }
414 if let Some(n) = req.milestone_number {
415 f.push(("milestone_number", n.to_string()));
416 }
417 let form = Client::str_refs(&f);
418 self.client
419 .patch(&format!("/repos/{o}/{r}/pulls/{number}"), &form)
420 }
421
422 pub fn list_labels(&self, number: i64) -> Result<Vec<Label>> {
424 let o = self.repo.owner.as_str();
425 let r = self.repo.name.as_str();
426 self.client.get_paged(
427 &format!("/repos/{o}/{r}/pulls/{number}/labels"),
428 &[],
429 usize::MAX,
430 )
431 }
432
433 pub fn list_assignees(&self, number: i64) -> Result<Vec<UserAssignee>> {
435 Ok(self.get(number)?.assignees.unwrap_or_default())
436 }
437
438 pub fn list_testers(&self, number: i64) -> Result<Vec<UserAssignee>> {
440 Ok(self.get(number)?.testers.unwrap_or_default())
441 }
442
443 pub fn add_assignees_idempotent(
447 &self,
448 number: i64,
449 logins: &[&str],
450 ) -> Result<StateChange<PullRequest>> {
451 self.add_members_idempotent(number, "assignees", logins, |pr| {
452 pr.assignees.as_deref().unwrap_or(&[])
453 })
454 }
455
456 pub fn remove_assignees_idempotent(
459 &self,
460 number: i64,
461 logins: &[&str],
462 ) -> Result<StateChange<()>> {
463 self.remove_members_idempotent(number, "assignees", logins, |pr| {
464 pr.assignees.as_deref().unwrap_or(&[])
465 })
466 }
467
468 pub fn add_testers_idempotent(
471 &self,
472 number: i64,
473 logins: &[&str],
474 ) -> Result<StateChange<PullRequest>> {
475 self.add_members_idempotent(number, "testers", logins, |pr| {
476 pr.testers.as_deref().unwrap_or(&[])
477 })
478 }
479
480 pub fn remove_testers_idempotent(
482 &self,
483 number: i64,
484 logins: &[&str],
485 ) -> Result<StateChange<()>> {
486 self.remove_members_idempotent(number, "testers", logins, |pr| {
487 pr.testers.as_deref().unwrap_or(&[])
488 })
489 }
490
491 fn add_members_idempotent(
492 &self,
493 number: i64,
494 field: &str,
495 logins: &[&str],
496 current_of: impl FnOnce(&PullRequest) -> &[UserAssignee],
497 ) -> Result<StateChange<PullRequest>> {
498 let o = self.repo.owner.as_str();
499 let r = self.repo.name.as_str();
500 let pr = self.get(number)?;
501 let present: HashSet<String> = current_of(&pr).iter().map(|u| u.login.clone()).collect();
502 let mut seen = HashSet::new();
503 let missing: Vec<&str> = logins
504 .iter()
505 .copied()
506 .filter(|n| !present.contains(*n) && seen.insert(*n))
507 .collect();
508 if missing.is_empty() {
509 return Ok(StateChange::Already(pr));
510 }
511 let joined = missing.join(",");
512 let form = [(field, joined.as_str())];
513 let updated: PullRequest = self
514 .client
515 .post(&format!("/repos/{o}/{r}/pulls/{number}/{field}"), &form)?;
516 Ok(StateChange::Changed(updated))
517 }
518
519 fn remove_members_idempotent(
520 &self,
521 number: i64,
522 field: &str,
523 logins: &[&str],
524 current_of: impl FnOnce(&PullRequest) -> &[UserAssignee],
525 ) -> Result<StateChange<()>> {
526 let o = self.repo.owner.as_str();
527 let r = self.repo.name.as_str();
528 let pr = self.get(number)?;
529 let present: HashSet<String> = current_of(&pr).iter().map(|u| u.login.clone()).collect();
530 let mut seen = HashSet::new();
531 let to_remove: Vec<&str> = logins
532 .iter()
533 .copied()
534 .filter(|n| present.contains(*n) && seen.insert(*n))
535 .collect();
536 if to_remove.is_empty() {
537 return Ok(StateChange::Already(()));
538 }
539 let joined = to_remove.join(",");
540 let query = [(field, joined.as_str())];
541 match self
542 .client
543 .delete_ok_query(&format!("/repos/{o}/{r}/pulls/{number}/{field}"), &query)
544 {
545 Ok(()) => Ok(StateChange::Changed(())),
546 Err(GiteeError::NotFound(_)) => Ok(StateChange::Already(())),
547 Err(e) => Err(e),
548 }
549 }
550
551 pub fn add_labels_idempotent(
554 &self,
555 number: i64,
556 names: &[&str],
557 ) -> Result<StateChange<Vec<Label>>> {
558 let o = self.repo.owner.as_str();
559 let r = self.repo.name.as_str();
560 let path = format!("/repos/{o}/{r}/pulls/{number}/labels");
561 let current = self.list_labels(number)?;
562 let present: HashSet<String> = current.iter().map(|l| l.name.clone()).collect();
563 let mut seen = HashSet::new();
564 let missing: Vec<&str> = names
565 .iter()
566 .copied()
567 .filter(|n| !present.contains(*n) && seen.insert(*n))
568 .collect();
569 if missing.is_empty() {
570 return Ok(StateChange::Already(current));
571 }
572 let body = serde_json::Value::Array(
573 missing
574 .iter()
575 .map(|n| serde_json::Value::String((*n).to_string()))
576 .collect(),
577 );
578 let labels: Vec<Label> = self.client.post_json(&path, &body)?;
579 Ok(StateChange::Changed(labels))
580 }
581
582 pub fn remove_labels_idempotent(
585 &self,
586 number: i64,
587 names: &[&str],
588 ) -> Result<StateChange<()>> {
589 let o = self.repo.owner.as_str();
590 let r = self.repo.name.as_str();
591 let current = self.list_labels(number)?;
592 let present: HashSet<String> = current.iter().map(|l| l.name.clone()).collect();
593 let mut seen = HashSet::new();
594 let to_remove: Vec<&str> = names
595 .iter()
596 .copied()
597 .filter(|n| present.contains(*n) && seen.insert(*n))
598 .collect();
599 if to_remove.is_empty() {
600 return Ok(StateChange::Already(()));
601 }
602 let mut changed = false;
603 for name in to_remove {
604 match self
605 .client
606 .delete_ok(&format!("/repos/{o}/{r}/pulls/{number}/labels/{name}"))
607 {
608 Ok(()) => changed = true,
609 Err(GiteeError::NotFound(_)) => {}
610 Err(e) => return Err(e),
611 }
612 }
613 if changed {
614 Ok(StateChange::Changed(()))
615 } else {
616 Ok(StateChange::Already(()))
617 }
618 }
619
620 pub fn link(&self, number: i64, tag: &str) -> Result<bool> {
623 let o = self.repo.owner.as_str();
624 let r = self.repo.name.as_str();
625 let pr: PullRequest = self
626 .client
627 .get(&format!("/repos/{o}/{r}/pulls/{number}"), &[])?;
628 let cur = pr.body.clone().unwrap_or_default();
629 if cur.contains(tag) {
630 return Ok(false);
631 }
632 let new = format!("{cur}\n\nLinked: {tag}");
633 let f: Vec<(&str, String)> = vec![("body", new)];
634 let form = Client::str_refs(&f);
635 let _: PullRequest = self
636 .client
637 .patch(&format!("/repos/{o}/{r}/pulls/{number}"), &form)?;
638 Ok(true)
639 }
640}