1use std::collections::HashMap;
106use std::path::Path;
107
108use anyhow::{Context, Result, bail};
109use async_trait::async_trait;
110use serde::Deserialize;
111use serde::de::DeserializeOwned;
112use serde_json::{Value, json};
113
114use crate::config::MozPhabConfig;
115use crate::source::diffstat::{FileChange, format_diffstat};
116use crate::source::{Checkout, Lifecycle, RepoRef, Review, ReviewKey, ReviewKind, ReviewSource};
117
118pub const NAME: &str = "phab";
120
121pub struct MozPhabSource {
122 cfg: MozPhabConfig,
123 client: reqwest::Client,
124 token: Option<String>,
125}
126
127impl MozPhabSource {
128 pub async fn new(cfg: MozPhabConfig) -> Result<Self> {
129 let token = resolve_token(&cfg).await?;
130 Ok(Self {
131 cfg,
132 client: reqwest::Client::new(),
133 token,
134 })
135 }
136
137 #[cfg(test)]
138 pub(crate) fn for_test(cfg: MozPhabConfig, token: Option<String>) -> Self {
139 Self {
140 cfg,
141 client: reqwest::Client::new(),
142 token,
143 }
144 }
145
146 async fn call<T: DeserializeOwned>(&self, method: &str, params: Value) -> Result<T> {
147 let token = self
148 .token
149 .as_ref()
150 .context("moz-phab source has no token")?;
151 let mut form = flatten_params(¶ms);
152 form.push(("api.token".to_string(), token.clone()));
153
154 let url = format!("{}/api/{method}", self.cfg.url.trim_end_matches('/'));
155 let resp = self
156 .client
157 .post(&url)
158 .form(&form)
159 .send()
160 .await
161 .with_context(|| format!("POST {url}"))?;
162 let status = resp.status();
163 if !status.is_success() {
164 let body = resp.text().await.unwrap_or_default();
165 bail!("POST {url} failed: {status} {body}");
166 }
167
168 let raw: ConduitEnvelope = resp
169 .json()
170 .await
171 .with_context(|| format!("parsing response from {url}"))?;
172 if let Some(code) = raw.error_code {
173 bail!(
174 "{method} failed ({code}): {}",
175 raw.error_info.unwrap_or_default()
176 );
177 }
178 serde_json::from_value(raw.result)
179 .with_context(|| format!("parsing `result` from {method}"))
180 }
181
182 async fn search_all<T: DeserializeOwned>(
184 &self,
185 method: &str,
186 mut params: Value,
187 ) -> Result<Vec<T>> {
188 let mut out = Vec::new();
189 let mut after: Option<String> = None;
190 for _ in 0..10 {
191 if let Some(a) = &after {
192 params["after"] = json!(a);
193 }
194 let page: SearchResult<T> = self.call(method, params.clone()).await?;
195 let done = page.cursor.after.is_none();
196 out.extend(page.data);
197 if done {
198 break;
199 }
200 after = page.cursor.after;
201 }
202 Ok(out)
203 }
204
205 async fn resolve_stacks(
214 &self,
215 seeds: Vec<StackMember>,
216 ) -> Result<HashMap<u64, Vec<StackMember>>> {
217 let mut chains: HashMap<u64, Vec<StackMember>> = HashMap::new();
218 let mut current_phid: HashMap<u64, String> = HashMap::new();
219 for seed in seeds {
220 current_phid.insert(seed.revision_id, seed.revision_phid.clone());
221 chains.insert(seed.revision_id, vec![seed]);
222 }
223
224 for _ in 0..50 {
225 if current_phid.is_empty() {
227 break;
228 }
229 let phids: Vec<String> = current_phid
230 .values()
231 .cloned()
232 .collect::<std::collections::BTreeSet<_>>()
233 .into_iter()
234 .collect();
235 let edges: Vec<EdgeItem> = self
236 .search_all(
237 "edge.search",
238 json!({"sourcePHIDs": phids, "types": ["revision.parent"]}),
239 )
240 .await?;
241 let mut parent_of: HashMap<String, String> = HashMap::new();
242 for edge in edges {
243 parent_of
244 .entry(edge.source_phid)
245 .or_insert(edge.destination_phid);
246 }
247
248 let parent_phids: Vec<String> = current_phid
249 .values()
250 .filter_map(|phid| parent_of.get(phid).cloned())
251 .collect::<std::collections::BTreeSet<_>>()
252 .into_iter()
253 .collect();
254 let parents: HashMap<String, RevisionItem> = if parent_phids.is_empty() {
255 HashMap::new()
256 } else {
257 self.search_all::<RevisionItem>(
258 "differential.revision.search",
259 json!({"constraints": {"phids": parent_phids}}),
260 )
261 .await?
262 .into_iter()
263 .map(|r| (r.phid.clone(), r))
264 .collect()
265 };
266
267 let mut next_current = HashMap::new();
268 for (revision_id, phid) in current_phid {
269 let Some(parent_phid) = parent_of.get(&phid) else {
270 continue; };
272 let Some(parent) = parents.get(parent_phid) else {
273 continue; };
275 if is_closed(&parent.fields.status.value) {
276 continue; }
278 chains.get_mut(&revision_id).unwrap().push(StackMember {
279 revision_id: parent.id,
280 revision_phid: parent.phid.clone(),
281 date_modified: parent.fields.date_modified.unwrap_or(0),
282 });
283 next_current.insert(revision_id, parent.phid.clone());
284 }
285 current_phid = next_current;
286 }
287
288 for chain in chains.values_mut() {
289 chain.reverse();
290 }
291 Ok(chains)
292 }
293
294 async fn diff_stats_for<'a>(
314 &self,
315 revisions: impl IntoIterator<Item = &'a RevisionItem>,
316 ) -> HashMap<u64, String> {
317 self.diff_stats_for_inner(revisions)
318 .await
319 .unwrap_or_default()
320 }
321
322 async fn diff_stats_for_inner<'a>(
323 &self,
324 revisions: impl IntoIterator<Item = &'a RevisionItem>,
325 ) -> Result<HashMap<u64, String>> {
326 let phid_to_rev: HashMap<String, u64> = revisions
327 .into_iter()
328 .filter_map(|r| r.fields.diff_phid.clone().map(|phid| (phid, r.id)))
329 .collect();
330 if phid_to_rev.is_empty() {
331 return Ok(HashMap::new());
332 }
333
334 let phids: Vec<String> = phid_to_rev
336 .keys()
337 .cloned()
338 .collect::<std::collections::BTreeSet<_>>()
339 .into_iter()
340 .collect();
341 let diffs: Vec<DiffItem> = self
342 .search_all(
343 "differential.diff.search",
344 json!({"constraints": {"phids": phids}}),
345 )
346 .await?;
347 let diff_id_to_rev: HashMap<u64, u64> = diffs
348 .into_iter()
349 .filter_map(|d| phid_to_rev.get(&d.phid).map(|&rev_id| (d.id, rev_id)))
350 .collect();
351
352 let mut out = HashMap::new();
353 for (diff_id, revision_id) in diff_id_to_rev {
354 let Ok(raw) = self
355 .call::<String>("differential.getrawdiff", json!({"diffID": diff_id}))
356 .await
357 else {
358 continue; };
360 out.insert(revision_id, format_diffstat(&parse_unified_diff(&raw)));
361 }
362 Ok(out)
363 }
364
365 #[cfg(test)]
366 async fn resolve_stack(&self, revision_id: u64) -> Result<Vec<StackMember>> {
367 let start: Vec<RevisionItem> = self
368 .search_all(
369 "differential.revision.search",
370 json!({"constraints": {"ids": [revision_id]}}),
371 )
372 .await?;
373 let start = start
374 .into_iter()
375 .next()
376 .with_context(|| format!("revision D{revision_id} not found"))?;
377 let seed = StackMember {
378 revision_id: start.id,
379 revision_phid: start.phid,
380 date_modified: start.fields.date_modified.unwrap_or(0),
381 };
382 let mut chains = self.resolve_stacks(vec![seed]).await?;
383 Ok(chains.remove(&revision_id).unwrap_or_default())
384 }
385
386 async fn resolve_usernames(&self, phids: &[String]) -> Result<HashMap<String, String>> {
390 if phids.is_empty() {
391 return Ok(HashMap::new());
392 }
393 let users: Vec<UserItem> = self
394 .search_all("user.search", json!({"constraints": {"phids": phids}}))
395 .await?;
396 Ok(users
397 .into_iter()
398 .map(|u| (u.phid, u.fields.username))
399 .collect())
400 }
401
402 async fn repo_refs_for(&self, repository_phids: &[String]) -> Result<HashMap<String, RepoRef>> {
405 if repository_phids.is_empty() {
406 return Ok(HashMap::new());
407 }
408 let repos: Vec<RepoItem> = self
409 .search_all(
410 "diffusion.repository.search",
411 json!({"constraints": {"phids": repository_phids}, "attachments": {"uris": true}}),
412 )
413 .await?;
414 let mut out = HashMap::new();
415 for repo in repos {
416 let urls: Vec<String> = repo
417 .attachments
418 .and_then(|a| a.uris)
419 .map(|u| {
420 u.uris
421 .into_iter()
422 .map(|item| item.fields.uri.effective)
423 .collect()
424 })
425 .unwrap_or_default();
426 if urls.is_empty() {
427 bail!("repository {} has no URIs", repo.phid);
428 }
429 out.insert(
430 repo.phid.clone(),
431 RepoRef {
432 urls,
433 display_name: repo.fields.short_name.unwrap_or(repo.phid),
434 },
435 );
436 }
437 Ok(out)
438 }
439
440 async fn ensure_arcconfig(&self, canonical_repo: &Path, revision_id: u64) -> Result<()> {
445 let revisions: Vec<RevisionItem> = self
446 .search_all(
447 "differential.revision.search",
448 json!({"constraints": {"ids": [revision_id]}}),
449 )
450 .await?;
451 let rev = revisions
452 .into_iter()
453 .next()
454 .with_context(|| format!("revision D{revision_id} not found"))?;
455 let repository_phid = rev
456 .fields
457 .repository_phid
458 .with_context(|| format!("D{revision_id} has no repository attached"))?;
459
460 let repos: Vec<RepoItem> = self
461 .search_all(
462 "diffusion.repository.search",
463 json!({"constraints": {"phids": [repository_phid]}}),
464 )
465 .await?;
466 let repo = repos
467 .into_iter()
468 .next()
469 .with_context(|| format!("repository {repository_phid} not found"))?;
470 let callsign = repo
471 .fields
472 .callsign
473 .with_context(|| format!("repository {repository_phid} has no Phabricator callsign; moz-phab can't patch into it"))?;
474
475 let git_dir = canonical_repo.join(".git");
476 if !git_dir.is_dir() {
477 bail!(
478 "expected a colocated `.git` at {} for moz-phab to use",
479 canonical_repo.display()
480 );
481 }
482 let arcconfig_path = git_dir.join(".arcconfig");
483 let contents = serde_json::to_string_pretty(
484 &json!({"phabricator.uri": self.cfg.url, "repository.callsign": callsign}),
485 )?;
486 std::fs::write(&arcconfig_path, contents)
487 .with_context(|| format!("writing {}", arcconfig_path.display()))?;
488 Ok(())
489 }
490}
491
492#[async_trait]
493impl ReviewSource for MozPhabSource {
494 fn name(&self) -> &str {
495 NAME
496 }
497
498 async fn fetch_queue(&self) -> Result<Vec<Review>> {
499 let who: WhoAmI = self.call("user.whoami", json!({})).await?;
500 let my_phid = who.phid;
501
502 let mut mine_phids = vec![my_phid.clone()];
503 let mut group_names: HashMap<String, String> = HashMap::new();
504 if self.cfg.include_groups {
505 let projects: Vec<ProjectItem> = self
506 .search_all(
507 "project.search",
508 json!({"constraints": {"members": [my_phid]}}),
509 )
510 .await?;
511 for p in projects {
512 group_names.insert(p.phid.clone(), p.fields.name);
513 mine_phids.push(p.phid);
514 }
515 }
516
517 let revisions: Vec<RevisionItem> = self
518 .search_all(
519 "differential.revision.search",
520 json!({"queryKey": "active", "attachments": {"reviewers": true}}),
521 )
522 .await?;
523
524 let author_phids: Vec<String> = revisions
525 .iter()
526 .map(|r| r.fields.author_phid.clone())
527 .collect::<std::collections::BTreeSet<_>>()
528 .into_iter()
529 .collect();
530 let usernames = self.resolve_usernames(&author_phids).await?;
531
532 let mut candidates = Vec::new();
535 for rev in revisions {
536 let Some(kind) = bucket_revision(&my_phid, &mine_phids, &group_names, &rev) else {
537 continue;
538 };
539 let Some(repository_phid) = rev.fields.repository_phid.clone() else {
540 continue; };
542 candidates.push((rev, kind, repository_phid));
543 }
544
545 let repo_phids: Vec<String> = candidates
546 .iter()
547 .map(|(_, _, phid)| phid.clone())
548 .collect::<std::collections::BTreeSet<_>>()
549 .into_iter()
550 .collect();
551 let repos = self.repo_refs_for(&repo_phids).await?;
552
553 let seeds: Vec<StackMember> = candidates
554 .iter()
555 .map(|(rev, _, _)| StackMember {
556 revision_id: rev.id,
557 revision_phid: rev.phid.clone(),
558 date_modified: rev.fields.date_modified.unwrap_or(0),
559 })
560 .collect();
561 let mut stacks = self.resolve_stacks(seeds).await?;
562 let diff_stats = self
563 .diff_stats_for(candidates.iter().map(|(rev, _, _)| rev))
564 .await;
565
566 let mut reviews = Vec::new();
567 for (rev, kind, repository_phid) in &candidates {
568 let repo = repos
569 .get(repository_phid)
570 .cloned()
571 .with_context(|| format!("repository {repository_phid} not found"))?;
572 let stack = stacks.remove(&rev.id).unwrap_or_default();
573 let ancestors: Vec<ReviewKey> = stack
575 .iter()
576 .filter(|m| m.revision_id != rev.id)
577 .map(|m| ReviewKey::new(NAME, format!("D{}", m.revision_id)))
578 .collect();
579 let version = stack
580 .iter()
581 .map(|m| m.date_modified.to_string())
582 .collect::<Vec<_>>()
583 .join(",");
584
585 reviews.push(Review {
586 key: ReviewKey::new(NAME, format!("D{}", rev.id)),
587 title: rev.fields.title.clone(),
588 author: usernames
589 .get(&rev.fields.author_phid)
590 .cloned()
591 .unwrap_or_else(|| rev.fields.author_phid.clone()),
592 url: format!("{}/D{}", self.cfg.url.trim_end_matches('/'), rev.id),
593 repo,
594 version,
595 kind: kind.clone(),
596 diff_stat: diff_stats.get(&rev.id).cloned(),
597 description: rev.fields.summary.clone(),
598 ancestors,
599 });
600 }
601 Ok(reviews)
602 }
603
604 async fn checkout_spec(&self, review: &Review, canonical_repo: &Path) -> Result<Checkout> {
605 let revision_id = parse_id(&review.key.id)?;
606 self.ensure_arcconfig(canonical_repo, revision_id).await?;
607
608 let mut env = Vec::new();
609 if let Some(token) = &self.token {
610 env.push(("MOZPHAB_PHABRICATOR_API_TOKEN".to_string(), token.clone()));
611 }
612 Ok(Checkout::ExternalCommand {
613 program: "moz-phab".to_string(),
614 args: vec![
615 "patch".to_string(),
616 format!("D{revision_id}"),
617 "--apply-to".to_string(),
618 "base".to_string(),
619 "--yes".to_string(),
620 "--name".to_string(),
621 review.key.slug().replace('/', "-"),
622 ],
623 env,
624 })
625 }
626
627 fn is_commit_for(&self, review: &ReviewKey, message: &str) -> bool {
628 commit_message_is_for(message, &review.id)
629 }
630
631 async fn fetch_status(&self, ids: &[String]) -> Result<Vec<(String, Lifecycle)>> {
632 let numeric_ids: Vec<u64> = ids.iter().map(|s| parse_id(s)).collect::<Result<_>>()?;
633 let revisions: Vec<RevisionItem> = self
634 .search_all(
635 "differential.revision.search",
636 json!({"constraints": {"ids": numeric_ids}}),
637 )
638 .await?;
639 let by_id: HashMap<u64, RevisionItem> = revisions.into_iter().map(|r| (r.id, r)).collect();
640
641 let mut out = Vec::with_capacity(ids.len());
642 for id_str in ids {
643 let id = parse_id(id_str)?;
644 let lifecycle = match by_id.get(&id) {
645 Some(r) if is_closed(&r.fields.status.value) => Lifecycle::Resolved,
646 Some(_) => Lifecycle::Open,
647 None => Lifecycle::Resolved,
649 };
650 out.push((id_str.clone(), lifecycle));
651 }
652 Ok(out)
653 }
654}
655
656fn commit_message_is_for(message: &str, id: &str) -> bool {
659 message.lines().any(|line| {
660 line.trim()
661 .strip_prefix("Differential Revision:")
662 .and_then(|url| url.trim().rsplit('/').next())
663 == Some(id)
664 })
665}
666
667fn is_closed(status: &str) -> bool {
668 matches!(status, "published" | "abandoned")
669}
670
671fn is_actionable(status: &str) -> bool {
672 matches!(
673 status,
674 "blocking" | "rejected" | "rejected-older" | "added" | "commented"
675 )
676}
677
678fn bucket_revision(
686 my_phid: &str,
687 mine_phids: &[String],
688 group_names: &HashMap<String, String>,
689 rev: &RevisionItem,
690) -> Option<ReviewKind> {
691 if rev.fields.author_phid == my_phid {
692 return None;
693 }
694 let reviewers = rev
695 .attachments
696 .as_ref()
697 .and_then(|a| a.reviewers.as_ref())?;
698 let mine: Vec<&ReviewerEntry> = reviewers
699 .reviewers
700 .iter()
701 .filter(|r| mine_phids.contains(&r.reviewer_phid))
702 .collect();
703 if mine.is_empty() || mine.iter().any(|r| r.status == "resigned") {
704 return None;
705 }
706 if rev.fields.status.value != "needs-review" {
707 return None;
708 }
709 let matched = mine.iter().find(|r| is_actionable(&r.status))?;
710 Some(if matched.reviewer_phid == my_phid {
711 ReviewKind::Direct
712 } else {
713 ReviewKind::Group(
714 group_names
715 .get(&matched.reviewer_phid)
716 .cloned()
717 .unwrap_or_else(|| matched.reviewer_phid.clone()),
718 )
719 })
720}
721
722async fn resolve_token(cfg: &MozPhabConfig) -> Result<Option<String>> {
724 if let Some(t) = &cfg.token {
725 return Ok(Some(t.clone()));
726 }
727 if let Some(cmd) = &cfg.token_cmd {
728 let out = tokio::process::Command::new("sh")
729 .arg("-c")
730 .arg(cmd)
731 .output()
732 .await
733 .with_context(|| format!("running token_cmd `{cmd}`"))?;
734 if !out.status.success() {
735 bail!(
736 "token_cmd `{cmd}` failed: {}",
737 String::from_utf8_lossy(&out.stderr).trim()
738 );
739 }
740 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
741 if !token.is_empty() {
742 return Ok(Some(token));
743 }
744 }
745 if let Some(base_dirs) = directories::BaseDirs::new() {
746 let arcrc_path = base_dirs.home_dir().join(".arcrc");
747 if let Ok(text) = std::fs::read_to_string(&arcrc_path)
748 && let Ok(value) = serde_json::from_str::<Value>(&text)
749 {
750 let key = format!("{}/api/", cfg.url.trim_end_matches('/'));
751 if let Some(token) = value
752 .get("hosts")
753 .and_then(|h| h.get(&key))
754 .and_then(|h| h.get("token"))
755 .and_then(|t| t.as_str())
756 {
757 return Ok(Some(token.to_string()));
758 }
759 }
760 }
761 Ok(None)
762}
763
764fn parse_id(id: &str) -> Result<u64> {
766 id.strip_prefix('D')
767 .with_context(|| format!("bad moz-phab review id `{id}`"))?
768 .parse()
769 .with_context(|| format!("bad moz-phab review id `{id}`"))
770}
771
772fn flatten_params(value: &Value) -> Vec<(String, String)> {
775 let mut out = Vec::new();
776 if let Value::Object(map) = value {
777 for (k, v) in map {
778 flatten_into(k, v, &mut out);
779 }
780 }
781 out
782}
783
784fn flatten_into(prefix: &str, value: &Value, out: &mut Vec<(String, String)>) {
785 match value {
786 Value::Null => {}
787 Value::Bool(b) => out.push((prefix.to_string(), b.to_string())),
788 Value::Number(n) => out.push((prefix.to_string(), n.to_string())),
789 Value::String(s) => out.push((prefix.to_string(), s.clone())),
790 Value::Array(items) => {
791 for (i, item) in items.iter().enumerate() {
792 flatten_into(&format!("{prefix}[{i}]"), item, out);
793 }
794 }
795 Value::Object(map) => {
796 for (k, v) in map {
797 flatten_into(&format!("{prefix}[{k}]"), v, out);
798 }
799 }
800 }
801}
802
803#[allow(dead_code)]
807struct StackMember {
808 revision_id: u64,
809 revision_phid: String,
810 date_modified: i64,
811}
812
813#[derive(Deserialize)]
814struct ConduitEnvelope {
815 result: Value,
816 error_code: Option<String>,
817 error_info: Option<String>,
818}
819
820#[derive(Deserialize)]
821struct SearchResult<T> {
822 data: Vec<T>,
823 cursor: SearchCursor,
824}
825
826#[derive(Deserialize)]
827struct SearchCursor {
828 after: Option<String>,
829}
830
831#[derive(Debug, Deserialize)]
832struct WhoAmI {
833 phid: String,
834}
835
836#[derive(Deserialize)]
837struct ProjectItem {
838 phid: String,
839 fields: ProjectFields,
840}
841
842#[derive(Deserialize)]
843struct ProjectFields {
844 name: String,
845}
846
847#[derive(Deserialize)]
848struct UserItem {
849 phid: String,
850 fields: UserFields,
851}
852
853#[derive(Deserialize)]
854struct UserFields {
855 username: String,
856}
857
858#[derive(Deserialize)]
859struct RevisionItem {
860 id: u64,
861 phid: String,
862 fields: RevisionFields,
863 attachments: Option<RevisionAttachments>,
864}
865
866#[derive(Deserialize)]
867struct RevisionFields {
868 title: String,
869 #[serde(rename = "authorPHID")]
870 author_phid: String,
871 status: StatusField,
872 #[serde(rename = "repositoryPHID")]
873 repository_phid: Option<String>,
874 #[serde(rename = "dateModified")]
875 date_modified: Option<i64>,
876 #[serde(rename = "diffPHID")]
877 diff_phid: Option<String>,
878 #[serde(default)]
879 summary: Option<String>,
880}
881
882#[derive(Deserialize)]
883struct StatusField {
884 value: String,
885}
886
887#[derive(Deserialize)]
888struct RevisionAttachments {
889 reviewers: Option<ReviewersAttachment>,
890}
891
892#[derive(Deserialize)]
893struct ReviewersAttachment {
894 reviewers: Vec<ReviewerEntry>,
895}
896
897#[derive(Deserialize)]
898struct ReviewerEntry {
899 #[serde(rename = "reviewerPHID")]
900 reviewer_phid: String,
901 status: String,
902}
903
904#[derive(Deserialize)]
905struct EdgeItem {
906 #[serde(rename = "sourcePHID")]
907 source_phid: String,
908 #[serde(rename = "destinationPHID")]
909 destination_phid: String,
910}
911
912#[derive(Deserialize)]
916struct DiffItem {
917 id: u64,
918 phid: String,
919}
920
921fn parse_unified_diff(diff: &str) -> Vec<FileChange> {
928 let mut changes = Vec::new();
929 let mut current: Option<FileChange> = None;
930
931 for line in diff.lines() {
932 if let Some(rest) = line.strip_prefix("diff --git a/") {
933 if let Some(c) = current.take() {
934 changes.push(c);
935 }
936 let path = rest
937 .rsplit_once(" b/")
938 .map_or(rest, |(_, new_path)| new_path)
939 .to_string();
940 current = Some(FileChange {
941 path,
942 additions: 0,
943 deletions: 0,
944 });
945 } else if line.starts_with("+++") || line.starts_with("---") {
946 continue; } else if let Some(c) = current.as_mut() {
948 if line.starts_with('+') {
949 c.additions += 1;
950 } else if line.starts_with('-') {
951 c.deletions += 1;
952 }
953 }
954 }
955 if let Some(c) = current.take() {
956 changes.push(c);
957 }
958 changes
959}
960
961#[derive(Deserialize)]
962struct RepoItem {
963 phid: String,
964 fields: RepoFields,
965 attachments: Option<RepoAttachments>,
966}
967
968#[derive(Deserialize)]
969struct RepoFields {
970 #[serde(rename = "shortName")]
971 short_name: Option<String>,
972 callsign: Option<String>,
973}
974
975#[derive(Deserialize)]
976struct RepoAttachments {
977 uris: Option<UrisAttachment>,
978}
979
980#[derive(Deserialize)]
981struct UrisAttachment {
982 uris: Vec<UriItem>,
983}
984
985#[derive(Deserialize)]
986struct UriItem {
987 fields: UriItemFields,
988}
989
990#[derive(Deserialize)]
991struct UriItemFields {
992 uri: UriValue,
993}
994
995#[derive(Deserialize)]
996struct UriValue {
997 effective: String,
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003 use tempfile::TempDir;
1004 use wiremock::matchers::{body_string_contains, method, path};
1005 use wiremock::{Mock, MockServer, ResponseTemplate};
1006
1007 const ME: &str = "PHID-USER-me";
1008 const GROUP: &str = "PHID-PROJ-reviewers";
1009
1010 fn cfg(url: &str) -> MozPhabConfig {
1011 MozPhabConfig {
1012 url: url.into(),
1013 token: Some("t".into()),
1014 token_cmd: None,
1015 include_groups: true,
1016 }
1017 }
1018
1019 fn revision(author: &str, status: &str, reviewers: Vec<(&str, &str)>) -> RevisionItem {
1020 RevisionItem {
1021 id: 1,
1022 phid: "PHID-DREV-1".into(),
1023 fields: RevisionFields {
1024 title: "Fix the thing".into(),
1025 author_phid: author.into(),
1026 status: StatusField {
1027 value: status.into(),
1028 },
1029 repository_phid: Some("PHID-REPO-1".into()),
1030 date_modified: Some(1700000000),
1031 diff_phid: None,
1032 summary: None,
1033 },
1034 attachments: Some(RevisionAttachments {
1035 reviewers: Some(ReviewersAttachment {
1036 reviewers: reviewers
1037 .into_iter()
1038 .map(|(phid, status)| ReviewerEntry {
1039 reviewer_phid: phid.into(),
1040 status: status.into(),
1041 })
1042 .collect(),
1043 }),
1044 }),
1045 }
1046 }
1047
1048 fn mine() -> Vec<String> {
1049 vec![ME.to_string(), GROUP.to_string()]
1050 }
1051
1052 fn groups() -> HashMap<String, String> {
1053 HashMap::from([(GROUP.to_string(), "reviewers".to_string())])
1054 }
1055
1056 #[test]
1057 fn flatten_params_produces_bracket_notation() {
1058 let params = json!({
1059 "queryKey": "active",
1060 "constraints": {"members": ["PHID-1"], "ids": [1, 2]},
1061 "attachments": {"reviewers": true},
1062 });
1063 let pairs = flatten_params(¶ms);
1064 assert!(pairs.contains(&("queryKey".to_string(), "active".to_string())));
1065 assert!(pairs.contains(&("constraints[members][0]".to_string(), "PHID-1".to_string())));
1066 assert!(pairs.contains(&("constraints[ids][0]".to_string(), "1".to_string())));
1067 assert!(pairs.contains(&("constraints[ids][1]".to_string(), "2".to_string())));
1068 assert!(pairs.contains(&("attachments[reviewers]".to_string(), "true".to_string())));
1069 }
1070
1071 #[test]
1072 fn parse_id_round_trip() {
1073 assert_eq!(parse_id("D12345").unwrap(), 12345);
1074 assert!(parse_id("12345").is_err());
1075 assert!(parse_id("Dabc").is_err());
1076 }
1077
1078 #[test]
1079 fn bucket_skips_own_revision() {
1080 let rev = revision(ME, "needs-review", vec![("PHID-USER-other", "added")]);
1081 assert_eq!(bucket_revision(ME, &mine(), &groups(), &rev), None);
1082 }
1083
1084 #[test]
1085 fn bucket_skips_when_resigned() {
1086 let rev = revision("PHID-USER-author", "needs-review", vec![(ME, "resigned")]);
1087 assert_eq!(bucket_revision(ME, &mine(), &groups(), &rev), None);
1088 }
1089
1090 #[test]
1091 fn bucket_skips_non_needs_review_status() {
1092 let rev = revision("PHID-USER-author", "changes-planned", vec![(ME, "added")]);
1093 assert_eq!(bucket_revision(ME, &mine(), &groups(), &rev), None);
1094 }
1095
1096 #[test]
1097 fn bucket_excludes_accepted() {
1098 let rev = revision("PHID-USER-author", "needs-review", vec![(ME, "accepted")]);
1099 assert_eq!(
1100 bucket_revision(ME, &mine(), &groups(), &rev),
1101 None,
1102 "accepted has nothing left to do"
1103 );
1104 }
1105
1106 #[test]
1107 fn bucket_skips_when_no_reviewer_entry_of_mine() {
1108 let rev = revision(
1109 "PHID-USER-author",
1110 "needs-review",
1111 vec![("PHID-USER-other", "added")],
1112 );
1113 assert_eq!(bucket_revision(ME, &mine(), &groups(), &rev), None);
1114 }
1115
1116 #[test]
1117 fn bucket_includes_all_actionable_statuses_direct() {
1118 for status in [
1119 "blocking",
1120 "rejected",
1121 "rejected-older",
1122 "added",
1123 "commented",
1124 ] {
1125 let rev = revision("PHID-USER-author", "needs-review", vec![(ME, status)]);
1126 assert_eq!(
1127 bucket_revision(ME, &mine(), &groups(), &rev),
1128 Some(ReviewKind::Direct),
1129 "status `{status}` should be actionable"
1130 );
1131 }
1132 }
1133
1134 #[test]
1135 fn bucket_includes_group_match_with_group_name() {
1136 let rev = revision("PHID-USER-author", "needs-review", vec![(GROUP, "added")]);
1137 assert_eq!(
1138 bucket_revision(ME, &mine(), &groups(), &rev),
1139 Some(ReviewKind::Group("reviewers".to_string()))
1140 );
1141 }
1142
1143 #[test]
1144 fn bucket_ignores_group_match_when_groups_not_passed() {
1145 let rev = revision("PHID-USER-author", "needs-review", vec![(GROUP, "added")]);
1147 assert_eq!(
1148 bucket_revision(ME, &[ME.to_string()], &groups(), &rev),
1149 None
1150 );
1151 }
1152
1153 #[test]
1154 fn bucket_skips_when_no_reviewers_attachment() {
1155 let mut rev = revision("PHID-USER-author", "needs-review", vec![(ME, "added")]);
1156 rev.attachments = None;
1157 assert_eq!(bucket_revision(ME, &mine(), &groups(), &rev), None);
1158 }
1159
1160 fn search_response(items: &[Value]) -> Value {
1161 json!({"result": {"data": items, "cursor": {"after": null}}, "error_code": null, "error_info": null})
1162 }
1163
1164 fn call_response(result: Value) -> Value {
1165 json!({"result": result, "error_code": null, "error_info": null})
1166 }
1167
1168 #[tokio::test]
1169 async fn fetch_status_maps_statuses_and_missing_ids() {
1170 let server = MockServer::start().await;
1171 Mock::given(method("POST"))
1172 .and(path("/api/differential.revision.search"))
1173 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[
1174 json!({"id": 1, "phid": "PHID-DREV-1", "fields": {"title": "x", "authorPHID": "a", "status": {"value": "published"}, "repositoryPHID": null, "dateModified": 1}}),
1175 json!({"id": 2, "phid": "PHID-DREV-2", "fields": {"title": "x", "authorPHID": "a", "status": {"value": "needs-review"}, "repositoryPHID": null, "dateModified": 1}}),
1176 json!({"id": 3, "phid": "PHID-DREV-3", "fields": {"title": "x", "authorPHID": "a", "status": {"value": "abandoned"}, "repositoryPHID": null, "dateModified": 1}}),
1177 ])))
1178 .mount(&server)
1179 .await;
1180
1181 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1182 let ids = vec![
1183 "D1".to_string(),
1184 "D2".to_string(),
1185 "D3".to_string(),
1186 "D4".to_string(),
1187 ];
1188 let statuses = src.fetch_status(&ids).await.unwrap();
1189
1190 assert_eq!(statuses[0], ("D1".to_string(), Lifecycle::Resolved));
1191 assert_eq!(statuses[1], ("D2".to_string(), Lifecycle::Open));
1192 assert_eq!(statuses[2], ("D3".to_string(), Lifecycle::Resolved));
1193 assert_eq!(
1194 statuses[3],
1195 ("D4".to_string(), Lifecycle::Resolved),
1196 "an id missing from the response should count as resolved"
1197 );
1198 }
1199
1200 #[tokio::test]
1201 async fn call_surfaces_conduit_errors() {
1202 let server = MockServer::start().await;
1203 Mock::given(method("POST"))
1204 .and(path("/api/user.whoami"))
1205 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1206 "result": null, "error_code": "ERR-INVALID-AUTH", "error_info": "Bad token",
1207 })))
1208 .mount(&server)
1209 .await;
1210
1211 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1212 let err = src
1213 .call::<WhoAmI>("user.whoami", json!({}))
1214 .await
1215 .unwrap_err();
1216 assert!(
1217 err.to_string().contains("ERR-INVALID-AUTH"),
1218 "unexpected error: {err}"
1219 );
1220 }
1221
1222 #[tokio::test]
1223 async fn resolve_stack_stops_at_a_closed_ancestor() {
1224 let server = MockServer::start().await;
1225 Mock::given(method("POST"))
1227 .and(path("/api/differential.revision.search"))
1228 .and(body_string_contains("constraints%5Bids%5D%5B0%5D=2"))
1229 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1230 "id": 2, "phid": "PHID-DREV-2", "fields": {"title": "child", "authorPHID": "a", "status": {"value": "needs-review"}, "repositoryPHID": null, "dateModified": 200},
1231 })])))
1232 .mount(&server)
1233 .await;
1234 Mock::given(method("POST"))
1235 .and(path("/api/edge.search"))
1236 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[
1237 json!({"sourcePHID": "PHID-DREV-2", "destinationPHID": "PHID-DREV-1"}),
1238 ])))
1239 .mount(&server)
1240 .await;
1241 Mock::given(method("POST"))
1242 .and(path("/api/differential.revision.search"))
1243 .and(body_string_contains("constraints%5Bphids%5D%5B0%5D=PHID-DREV-1"))
1244 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1245 "id": 1, "phid": "PHID-DREV-1", "fields": {"title": "parent", "authorPHID": "a", "status": {"value": "published"}, "repositoryPHID": null, "dateModified": 100},
1246 })])))
1247 .mount(&server)
1248 .await;
1249
1250 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1251 let stack = src.resolve_stack(2).await.unwrap();
1252
1253 assert_eq!(
1254 stack.len(),
1255 1,
1256 "the landed parent must not be included in the stack"
1257 );
1258 assert_eq!(stack[0].revision_id, 2);
1259 assert_eq!(stack[0].date_modified, 200);
1260 }
1261
1262 #[tokio::test]
1263 async fn fetch_queue_end_to_end() {
1264 let server = MockServer::start().await;
1265 Mock::given(method("POST"))
1266 .and(path("/api/user.whoami"))
1267 .respond_with(
1268 ResponseTemplate::new(200)
1269 .set_body_json(call_response(json!({"phid": ME, "userName": "ahal"}))),
1270 )
1271 .mount(&server)
1272 .await;
1273 Mock::given(method("POST"))
1274 .and(path("/api/project.search"))
1275 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[])))
1276 .mount(&server)
1277 .await;
1278 Mock::given(method("POST"))
1279 .and(path("/api/differential.revision.search"))
1280 .and(body_string_contains("queryKey=active"))
1281 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1282 "id": 1, "phid": "PHID-DREV-1",
1283 "fields": {"title": "Fix the thing", "authorPHID": "PHID-USER-other", "status": {"value": "needs-review"}, "repositoryPHID": "PHID-REPO-1", "dateModified": 1700000000},
1284 "attachments": {"reviewers": {"reviewers": [{"reviewerPHID": ME, "status": "added"}]}},
1285 })])))
1286 .mount(&server)
1287 .await;
1288 Mock::given(method("POST"))
1290 .and(path("/api/edge.search"))
1291 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[])))
1292 .mount(&server)
1293 .await;
1294 Mock::given(method("POST"))
1295 .and(path("/api/diffusion.repository.search"))
1296 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1297 "phid": "PHID-REPO-1", "fields": {"shortName": "proj", "callsign": "PROJ"},
1298 "attachments": {"uris": {"uris": [{"fields": {"uri": {"effective": "https://phab.example.com/source/proj.git"}}}]}},
1299 })])))
1300 .mount(&server)
1301 .await;
1302 Mock::given(method("POST"))
1303 .and(path("/api/user.search"))
1304 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[
1305 json!({"phid": "PHID-USER-other", "fields": {"username": "alice"}}),
1306 ])))
1307 .mount(&server)
1308 .await;
1309
1310 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1311 let reviews = src.fetch_queue().await.unwrap();
1312
1313 assert_eq!(reviews.len(), 1);
1314 let r = &reviews[0];
1315 assert_eq!(r.key, ReviewKey::new("phab", "D1"));
1316 assert_eq!(r.title, "Fix the thing");
1317 assert_eq!(
1318 r.author, "alice",
1319 "author PHID should resolve to a username"
1320 );
1321 assert_eq!(r.version, "1700000000");
1322 assert_eq!(r.kind, ReviewKind::Direct);
1323 assert_eq!(
1324 r.repo.urls,
1325 vec!["https://phab.example.com/source/proj.git".to_string()]
1326 );
1327 }
1328
1329 #[tokio::test]
1334 async fn fetch_queue_batches_repo_and_stack_lookups_across_revisions() {
1335 let server = MockServer::start().await;
1336 Mock::given(method("POST"))
1337 .and(path("/api/user.whoami"))
1338 .respond_with(
1339 ResponseTemplate::new(200)
1340 .set_body_json(call_response(json!({"phid": ME, "userName": "ahal"}))),
1341 )
1342 .mount(&server)
1343 .await;
1344 Mock::given(method("POST"))
1345 .and(path("/api/project.search"))
1346 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[])))
1347 .mount(&server)
1348 .await;
1349 Mock::given(method("POST"))
1350 .and(path("/api/differential.revision.search"))
1351 .and(body_string_contains("queryKey=active"))
1352 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[
1353 json!({
1354 "id": 1, "phid": "PHID-DREV-1",
1355 "fields": {"title": "D1", "authorPHID": "PHID-USER-other", "status": {"value": "needs-review"}, "repositoryPHID": "PHID-REPO-1", "dateModified": 1},
1356 "attachments": {"reviewers": {"reviewers": [{"reviewerPHID": ME, "status": "added"}]}},
1357 }),
1358 json!({
1359 "id": 2, "phid": "PHID-DREV-2",
1360 "fields": {"title": "D2", "authorPHID": "PHID-USER-other", "status": {"value": "needs-review"}, "repositoryPHID": "PHID-REPO-1", "dateModified": 2},
1361 "attachments": {"reviewers": {"reviewers": [{"reviewerPHID": ME, "status": "added"}]}},
1362 }),
1363 ])))
1364 .mount(&server)
1365 .await;
1366 Mock::given(method("POST"))
1367 .and(path("/api/edge.search"))
1368 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[])))
1369 .expect(1)
1370 .mount(&server)
1371 .await;
1372 Mock::given(method("POST"))
1373 .and(path("/api/diffusion.repository.search"))
1374 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1375 "phid": "PHID-REPO-1", "fields": {"shortName": "proj", "callsign": "PROJ"},
1376 "attachments": {"uris": {"uris": [{"fields": {"uri": {"effective": "https://phab.example.com/source/proj.git"}}}]}},
1377 })])))
1378 .expect(1)
1379 .mount(&server)
1380 .await;
1381 Mock::given(method("POST"))
1382 .and(path("/api/user.search"))
1383 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[
1384 json!({"phid": "PHID-USER-other", "fields": {"username": "alice"}}),
1385 ])))
1386 .mount(&server)
1387 .await;
1388
1389 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1390 let mut reviews = src.fetch_queue().await.unwrap();
1391 reviews.sort_by(|a, b| a.key.id.cmp(&b.key.id));
1392
1393 assert_eq!(reviews.len(), 2);
1394 assert_eq!(reviews[0].key, ReviewKey::new("phab", "D1"));
1395 assert_eq!(reviews[1].key, ReviewKey::new("phab", "D2"));
1396 for r in &reviews {
1397 assert_eq!(
1398 r.repo.urls,
1399 vec!["https://phab.example.com/source/proj.git".to_string()]
1400 );
1401 }
1402 }
1403
1404 #[tokio::test]
1405 async fn fetch_queue_reports_each_reviews_ancestors_bottom_first() {
1406 let server = MockServer::start().await;
1407 Mock::given(method("POST"))
1408 .and(path("/api/user.whoami"))
1409 .respond_with(
1410 ResponseTemplate::new(200)
1411 .set_body_json(call_response(json!({"phid": ME, "userName": "ahal"}))),
1412 )
1413 .mount(&server)
1414 .await;
1415 Mock::given(method("POST"))
1416 .and(path("/api/project.search"))
1417 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[])))
1418 .mount(&server)
1419 .await;
1420 let d1 = json!({
1421 "id": 1, "phid": "PHID-DREV-1",
1422 "fields": {"title": "D1", "authorPHID": "PHID-USER-other", "status": {"value": "needs-review"}, "repositoryPHID": "PHID-REPO-1", "dateModified": 1},
1423 "attachments": {"reviewers": {"reviewers": [{"reviewerPHID": ME, "status": "added"}]}},
1424 });
1425 let d2 = json!({
1426 "id": 2, "phid": "PHID-DREV-2",
1427 "fields": {"title": "D2", "authorPHID": "PHID-USER-other", "status": {"value": "needs-review"}, "repositoryPHID": "PHID-REPO-1", "dateModified": 2},
1428 "attachments": {"reviewers": {"reviewers": [{"reviewerPHID": ME, "status": "added"}]}},
1429 });
1430 Mock::given(method("POST"))
1431 .and(path("/api/differential.revision.search"))
1432 .and(body_string_contains("queryKey=active"))
1433 .respond_with(
1434 ResponseTemplate::new(200).set_body_json(search_response(&[d1.clone(), d2])),
1435 )
1436 .mount(&server)
1437 .await;
1438 Mock::given(method("POST"))
1440 .and(path("/api/differential.revision.search"))
1441 .and(body_string_contains(
1442 "constraints%5Bphids%5D%5B0%5D=PHID-DREV-1",
1443 ))
1444 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[d1])))
1445 .mount(&server)
1446 .await;
1447 Mock::given(method("POST"))
1448 .and(path("/api/edge.search"))
1449 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[
1450 json!({"sourcePHID": "PHID-DREV-2", "destinationPHID": "PHID-DREV-1"}),
1451 ])))
1452 .mount(&server)
1453 .await;
1454 Mock::given(method("POST"))
1455 .and(path("/api/diffusion.repository.search"))
1456 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1457 "phid": "PHID-REPO-1", "fields": {"shortName": "proj", "callsign": "PROJ"},
1458 "attachments": {"uris": {"uris": [{"fields": {"uri": {"effective": "https://phab.example.com/source/proj.git"}}}]}},
1459 })])))
1460 .mount(&server)
1461 .await;
1462 Mock::given(method("POST"))
1463 .and(path("/api/user.search"))
1464 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[
1465 json!({"phid": "PHID-USER-other", "fields": {"username": "alice"}}),
1466 ])))
1467 .mount(&server)
1468 .await;
1469
1470 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1471 let mut reviews = src.fetch_queue().await.unwrap();
1472 reviews.sort_by(|a, b| a.key.id.cmp(&b.key.id));
1473
1474 assert!(reviews[0].ancestors.is_empty());
1475 assert_eq!(reviews[1].ancestors, vec![ReviewKey::new("phab", "D1")]);
1476 }
1477
1478 #[test]
1479 fn commit_message_is_matched_by_its_differential_revision_trailer() {
1480 let msg = "Bug 1 - Fix it r=me\n\nDifferential Revision: https://phabricator.services.mozilla.com/D123\n";
1481 assert!(commit_message_is_for(msg, "D123"));
1482 assert!(!commit_message_is_for(msg, "D12"));
1483 assert!(!commit_message_is_for(msg, "D1234"));
1484 assert!(!commit_message_is_for("Bug 1 - no trailer", "D123"));
1485 }
1486
1487 #[tokio::test]
1488 async fn checkout_spec_writes_arcconfig_and_builds_moz_phab_command() {
1489 let server = MockServer::start().await;
1490 Mock::given(method("POST"))
1491 .and(path("/api/differential.revision.search"))
1492 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1493 "id": 1, "phid": "PHID-DREV-1",
1494 "fields": {"title": "x", "authorPHID": "a", "status": {"value": "needs-review"}, "repositoryPHID": "PHID-REPO-1", "dateModified": 1},
1495 })])))
1496 .mount(&server)
1497 .await;
1498 Mock::given(method("POST"))
1499 .and(path("/api/diffusion.repository.search"))
1500 .respond_with(
1501 ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1502 "phid": "PHID-REPO-1", "fields": {"shortName": "proj", "callsign": "PROJ"},
1503 })])),
1504 )
1505 .mount(&server)
1506 .await;
1507
1508 let canon = TempDir::new().unwrap();
1509 std::fs::create_dir(canon.path().join(".git")).unwrap();
1510
1511 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("secret-token".into()));
1512 let review = Review {
1513 key: ReviewKey::new("phab", "D1"),
1514 title: "x".into(),
1515 author: "a".into(),
1516 url: "https://phab.example.com/D1".into(),
1517 repo: RepoRef {
1518 urls: vec![],
1519 display_name: "repo".into(),
1520 },
1521 version: "1".into(),
1522 kind: ReviewKind::Direct,
1523 diff_stat: None,
1524 description: None,
1525 ancestors: Vec::new(),
1526 };
1527 let checkout = src.checkout_spec(&review, canon.path()).await.unwrap();
1528
1529 let arcconfig: Value = serde_json::from_str(
1530 &std::fs::read_to_string(canon.path().join(".git/.arcconfig")).unwrap(),
1531 )
1532 .unwrap();
1533 assert_eq!(arcconfig["phabricator.uri"], server.uri());
1534 assert_eq!(arcconfig["repository.callsign"], "PROJ");
1535
1536 match checkout {
1537 Checkout::ExternalCommand { program, args, env } => {
1538 assert_eq!(program, "moz-phab");
1539 assert_eq!(
1540 args,
1541 vec![
1542 "patch",
1543 "D1",
1544 "--apply-to",
1545 "base",
1546 "--yes",
1547 "--name",
1548 "phab-D1"
1549 ]
1550 );
1551 assert!(env.contains(&(
1552 "MOZPHAB_PHABRICATOR_API_TOKEN".to_string(),
1553 "secret-token".to_string()
1554 )));
1555 }
1556 _ => panic!("expected ExternalCommand"),
1557 }
1558 }
1559
1560 #[tokio::test]
1561 async fn checkout_spec_errors_without_a_callsign() {
1562 let server = MockServer::start().await;
1563 Mock::given(method("POST"))
1564 .and(path("/api/differential.revision.search"))
1565 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1566 "id": 1, "phid": "PHID-DREV-1",
1567 "fields": {"title": "x", "authorPHID": "a", "status": {"value": "needs-review"}, "repositoryPHID": "PHID-REPO-1", "dateModified": 1},
1568 })])))
1569 .mount(&server)
1570 .await;
1571 Mock::given(method("POST"))
1572 .and(path("/api/diffusion.repository.search"))
1573 .respond_with(
1574 ResponseTemplate::new(200).set_body_json(search_response(&[json!({
1575 "phid": "PHID-REPO-1", "fields": {"shortName": "proj", "callsign": null},
1576 })])),
1577 )
1578 .mount(&server)
1579 .await;
1580
1581 let canon = TempDir::new().unwrap();
1582 std::fs::create_dir(canon.path().join(".git")).unwrap();
1583
1584 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1585 let review = Review {
1586 key: ReviewKey::new("phab", "D1"),
1587 title: "x".into(),
1588 author: "a".into(),
1589 url: "https://phab.example.com/D1".into(),
1590 repo: RepoRef {
1591 urls: vec![],
1592 display_name: "repo".into(),
1593 },
1594 version: "1".into(),
1595 kind: ReviewKind::Direct,
1596 diff_stat: None,
1597 description: None,
1598 ancestors: Vec::new(),
1599 };
1600 let err = src.checkout_spec(&review, canon.path()).await.unwrap_err();
1601 assert!(
1602 err.to_string().contains("callsign"),
1603 "unexpected error: {err}"
1604 );
1605 }
1606
1607 fn revision_with_diff(id: u64, diff_phid: Option<&str>) -> RevisionItem {
1608 RevisionItem {
1609 id,
1610 phid: format!("PHID-DREV-{id}"),
1611 fields: RevisionFields {
1612 title: "x".into(),
1613 author_phid: "a".into(),
1614 status: StatusField {
1615 value: "needs-review".into(),
1616 },
1617 repository_phid: None,
1618 date_modified: Some(1),
1619 diff_phid: diff_phid.map(String::from),
1620 summary: None,
1621 },
1622 attachments: None,
1623 }
1624 }
1625
1626 #[tokio::test]
1627 async fn diff_stats_for_batches_diff_search_then_fetches_each_raw_diff() {
1628 let server = MockServer::start().await;
1629 Mock::given(method("POST"))
1630 .and(path("/api/differential.diff.search"))
1631 .and(body_string_contains(
1632 "constraints%5Bphids%5D%5B0%5D=PHID-DIFF-1",
1633 ))
1634 .and(body_string_contains(
1635 "constraints%5Bphids%5D%5B1%5D=PHID-DIFF-2",
1636 ))
1637 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[
1638 json!({"id": 42, "phid": "PHID-DIFF-1"}),
1639 json!({"id": 43, "phid": "PHID-DIFF-2"}),
1640 ])))
1641 .mount(&server)
1642 .await;
1643 Mock::given(method("POST"))
1644 .and(path("/api/differential.getrawdiff"))
1645 .and(body_string_contains("diffID=42"))
1646 .respond_with(ResponseTemplate::new(200).set_body_json(call_response(json!(
1647 "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,1 +1,3 @@\n+one\n+two\n-old\n"
1648 ))))
1649 .mount(&server)
1650 .await;
1651 Mock::given(method("POST"))
1652 .and(path("/api/differential.getrawdiff"))
1653 .and(body_string_contains("diffID=43"))
1654 .respond_with(ResponseTemplate::new(200).set_body_json(call_response(json!(
1655 "diff --git a/old.rs b/old.rs\n--- a/old.rs\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-gone\n"
1656 ))))
1657 .mount(&server)
1658 .await;
1659
1660 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1661 let revisions = [
1662 revision_with_diff(1, Some("PHID-DIFF-1")),
1663 revision_with_diff(2, Some("PHID-DIFF-2")),
1664 ];
1665 let stats = src.diff_stats_for(revisions.iter()).await;
1666
1667 assert!(stats[&1].contains("src/main.rs"));
1668 assert!(stats[&1].ends_with("1 file changed, 2 insertions(+), 1 deletion(-)"));
1669 assert!(stats[&2].contains("old.rs"));
1670 assert!(stats[&2].ends_with("1 file changed, 1 deletion(-)"));
1671 }
1672
1673 #[tokio::test]
1674 async fn diff_stats_for_skips_revisions_with_no_active_diff() {
1675 let server = MockServer::start().await;
1676 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1679 let revisions = [revision_with_diff(1, None)];
1680
1681 let stats = src.diff_stats_for(revisions.iter()).await;
1682
1683 assert!(stats.is_empty());
1684 }
1685
1686 #[tokio::test]
1687 async fn diff_stats_for_skips_just_the_revision_whose_raw_diff_fetch_fails() {
1688 let server = MockServer::start().await;
1689 Mock::given(method("POST"))
1690 .and(path("/api/differential.diff.search"))
1691 .respond_with(ResponseTemplate::new(200).set_body_json(search_response(&[
1692 json!({"id": 42, "phid": "PHID-DIFF-1"}),
1693 json!({"id": 43, "phid": "PHID-DIFF-2"}),
1694 ])))
1695 .mount(&server)
1696 .await;
1697 Mock::given(method("POST"))
1698 .and(path("/api/differential.getrawdiff"))
1699 .and(body_string_contains("diffID=42"))
1700 .respond_with(ResponseTemplate::new(500))
1701 .mount(&server)
1702 .await;
1703 Mock::given(method("POST"))
1704 .and(path("/api/differential.getrawdiff"))
1705 .and(body_string_contains("diffID=43"))
1706 .respond_with(
1707 ResponseTemplate::new(200).set_body_json(call_response(json!(
1708 "diff --git a/ok.rs b/ok.rs\n--- a/ok.rs\n+++ b/ok.rs\n@@ -0,0 +1,1 @@\n+ok\n"
1709 ))),
1710 )
1711 .mount(&server)
1712 .await;
1713
1714 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1715 let revisions = [
1716 revision_with_diff(1, Some("PHID-DIFF-1")),
1717 revision_with_diff(2, Some("PHID-DIFF-2")),
1718 ];
1719 let stats = src.diff_stats_for(revisions.iter()).await;
1720
1721 assert!(
1722 !stats.contains_key(&1),
1723 "the failing revision should just be skipped"
1724 );
1725 assert!(stats[&2].contains("ok.rs"));
1726 }
1727
1728 #[tokio::test]
1729 async fn diff_stats_for_is_best_effort_on_conduit_failure() {
1730 let server = MockServer::start().await;
1731 Mock::given(method("POST"))
1732 .and(path("/api/differential.diff.search"))
1733 .respond_with(ResponseTemplate::new(500))
1734 .mount(&server)
1735 .await;
1736
1737 let src = MozPhabSource::for_test(cfg(&server.uri()), Some("t".into()));
1738 let revisions = [revision_with_diff(1, Some("PHID-DIFF-1"))];
1739
1740 let stats = src.diff_stats_for(revisions.iter()).await;
1741
1742 assert!(
1743 stats.is_empty(),
1744 "a Conduit failure should not panic or propagate"
1745 );
1746 }
1747
1748 #[test]
1749 fn parse_unified_diff_counts_added_and_removed_lines_per_file() {
1750 let diff = "diff --git a/a.rs b/a.rs\n\
1751 --- a/a.rs\n\
1752 +++ b/a.rs\n\
1753 @@ -1,2 +1,3 @@\n\
1754 +one\n\
1755 +two\n\
1756 -old\n\
1757 diff --git a/b.rs b/b.rs\n\
1758 --- a/b.rs\n\
1759 +++ b/b.rs\n\
1760 @@ -1,1 +1,1 @@\n\
1761 -bye\n";
1762
1763 let changes = parse_unified_diff(diff);
1764
1765 assert_eq!(changes.len(), 2);
1766 assert_eq!(changes[0].path, "a.rs");
1767 assert_eq!(changes[0].additions, 2);
1768 assert_eq!(changes[0].deletions, 1);
1769 assert_eq!(changes[1].path, "b.rs");
1770 assert_eq!(changes[1].additions, 0);
1771 assert_eq!(changes[1].deletions, 1);
1772 }
1773
1774 #[test]
1775 fn parse_unified_diff_uses_the_new_path_from_the_diff_git_header() {
1776 let diff = "diff --git a/old-name.rs b/new-name.rs\n\
1777 --- a/old-name.rs\n\
1778 +++ b/new-name.rs\n\
1779 @@ -1,1 +1,1 @@\n\
1780 +x\n";
1781
1782 let changes = parse_unified_diff(diff);
1783
1784 assert_eq!(changes.len(), 1);
1785 assert_eq!(changes[0].path, "new-name.rs");
1786 }
1787
1788 #[test]
1789 fn parse_unified_diff_on_empty_text_finds_no_files() {
1790 assert!(parse_unified_diff("").is_empty());
1791 }
1792}