Skip to main content

review_queue/source/
moz_phab.rs

1//! Mozilla's Phabricator, via the `moz-phab` CLI. Not a general-purpose Phabricator source -
2//! see the module name. `moz-phab` is the tool every Mozilla contributor already uses to submit
3//! and apply patches; delegating the actual checkout to it, rather than reimplementing diff
4//! fetching and application ourselves, was a deliberate pivot after an earlier from-scratch
5//! implementation turned out to duplicate work `moz-phab` already does more robustly. Everything
6//! below the "Checkout" heading was verified empirically against the real, live Mozilla
7//! Phabricator and a real `moz-phab` install (not just read from its source), specifically
8//! because the earlier from-scratch approach had exactly this kind of thing wrong.
9//!
10//! Auth: POST form-encoded to `{url}/api/{method}` with `api.token=...`. Nested params flatten
11//! to `constraints[members][0]=...` (see `flatten_params`). Token comes from, in order: config
12//! `token`, `token_cmd`, then `~/.arcrc` (`hosts["{url}/api/"].token`, which `moz-phab` already
13//! writes, so it usually works with no setup). The same resolved token is passed to `moz-phab`
14//! itself via `MOZPHAB_PHABRICATOR_API_TOKEN`, so both halves always agree on identity rather
15//! than `moz-phab` silently falling back to its own (possibly different) `~/.arcrc` lookup.
16//!
17//! Queue: `user.whoami` -> `project.search {constraints:{members:[me]}}` (my groups, unless
18//! `include_groups` is off) -> `differential.revision.search {queryKey:"active",
19//! attachments:{reviewers:true}}`, paginating on `cursor.after`. Bucketed locally (mirroring
20//! Phabricator's own `DifferentialRevisionRequiredActionResultBucket`, which Conduit doesn't
21//! expose - this part is transcribed from myqonly's real, working
22//! `addon/services/phabricator-service.mjs`):
23//! - skip if `fields.authorPHID` is me
24//! - skip if my (or my group's) reviewer entry has `status == "resigned"`
25//! - require `fields.status.value == "needs-review"`
26//! - require a reviewer entry of mine (or a group I'm in) with status in
27//!   `blocking|rejected|rejected-older|added|commented` (`accepted` is deliberately excluded -
28//!   nothing left to do)
29//!
30//! Author PHIDs are resolved to usernames with one batched `user.search
31//! {constraints:{phids:[...]}}` call over every distinct author in the page, rather than showing
32//! raw PHIDs in `rq show`/`rq path`.
33//!
34//! Every per-revision Conduit lookup after the initial queue fetch is batched across *all*
35//! actionable revisions rather than issued once per revision - a queue of N revisions used to cost
36//! roughly `N + N * stack_depth` extra calls (one `diffusion.repository.search` per revision, plus
37//! a `edge.search`/`differential.revision.search` pair per stack hop per revision), which is easy
38//! to trip Phabricator's rate limit on. Now:
39//! - Repos: one `diffusion.repository.search {constraints:{phids:[...]}}` over every distinct
40//!   `repositoryPHID` among actionable revisions (`repo_refs_for`).
41//! - Stacks: `resolve_stacks` walks every actionable revision's parent chain in lockstep, one
42//!   `edge.search` + `differential.revision.search` pair *per depth level* covering every stack
43//!   still in flight, instead of per revision. A 20-revision queue with 3-deep stacks now costs
44//!   ~1 (repos) + 3*2 (stack levels) calls instead of ~20 + 20*(1+2*3).
45//! - Diffstats: `diff_stats_for` resolves every actionable revision's `diffPHID` through one
46//!   batched `differential.diff.search` call, then fetches each resulting diff's raw text via
47//!   `differential.getrawdiff` - unlike the above, this one *is* one call per revision, since
48//!   `getrawdiff` has no batch form; see `diff_stats_for`'s own doc comment for why (and for why
49//!   it's `getrawdiff`, not the more obvious-looking `differential.querydiffs`).
50//!
51//! `fetch_queue` fetches each `Review`'s diffstat itself (via `diff_stats_for`, above) rather than
52//! `rq show`'s TUI fetching it lazily on expand, so the TUI never blocks on Conduit.
53//!
54//! `version` is the comma-joined *`dateModified`* of every revision in the stack (walked via
55//! `edge.search`, same as before), not diff ids - `moz-phab` re-resolves the live diff/base
56//! itself on every invocation regardless of what we pass it, so this only needs to answer "has
57//! anything about this stack changed since we last synced," and `dateModified` answers that with
58//! zero extra Conduit calls beyond the stack walk we're already doing (`resolve_stacks`' own
59//! `differential.revision.search` calls already return it).
60//!
61//! # Checkout
62//!
63//! `checkout_spec` returns `Checkout::ExternalCommand { program: "moz-phab", args: ["patch",
64//! "D<id>", "--apply-to", "base", "--yes", "--name", <key>], env }`. `moz-phab patch` handles
65//! everything the earlier implementation did by hand, and does it better:
66//! - Walks the full dependency stack itself (confirmed live: patching a revision automatically
67//!   discovered and applied its parent too, unprompted).
68//! - Resolves the actual base commit itself, including a fallback our own `fields.refs`-based
69//!   base lookup never had: if the recorded base isn't a public/landed commit (e.g. it belongs
70//!   to another unlanded stack, or needs a git-cinnabar hg<->git translation), it walks forward
71//!   to the latest landed ancestor and applies there instead ("Base revision ... is not public
72//!   ... Applying the patch at ... instead" - observed live against `mozilla-central`).
73//! - `--yes` fully suppresses its interactive "patch the full stack?" prompt.
74//! - Exits 1 on failure (a real patch-doesn't-apply case was observed live), leaving the
75//!   workspace checked out at the resolved base with nothing applied - which is exactly the
76//!   `Status::ApplyFailed` "leave it for inspection" contract the `Vcs` backends already have.
77//!
78//! **Requires `repository.callsign` in `.arcconfig`, not just `phabricator.uri`** - confirmed
79//! live: `moz-phab patch` fails outright with "Failed to determine the Phabricator callsign for
80//! this repository" without it, even though static analysis of `moz-phab`'s own source suggested
81//! the callsign was only needed by `submit`. `checkout_spec` looks the callsign up itself
82//! (`diffusion.repository.search`) and writes both fields to `.git/.arcconfig` - never the repo
83//! root's own `.arcconfig`, which might be absent, tracked, or (as seen live in a real non-central
84//! repo) present but missing the callsign. `.git/.arcconfig` is checked first and never touches
85//! tracked working-tree content; every canonical repo here is colocated with a real `.git` by
86//! construction (tool-managed clones always are; jj canonical repos always are too, by our own
87//! design choice), so this path needs no per-VCS fallback.
88//!
89//! **Known limitation, not yet handled**: `moz-phab` defaults to the remote named `origin` and
90//! warns ("Multiple remotes found. Defaulting to 'origin'.") if a repo has more than one. Every
91//! tool-managed clone only ever has one remote, so this never bites the common path - but a
92//! discovered workdir checkout with multiple/nonstandard remotes (common for Mozilla developers,
93//! e.g. a `central`/`try` naming scheme instead of `origin`) may need `git.remote` set in their
94//! own `~/.moz-phab-config`, which this tool doesn't configure on their behalf.
95//!
96//! Whether `moz-phab` itself is installed and runnable is never checked by this module - only
97//! `checkout_spec`'s `ExternalCommand` actually shells out to it, and testing this module
98//! shouldn't require `moz-phab` to be on `PATH`.
99//!
100//! `fetch_status`: `differential.revision.search {constraints:{ids:[...]}}`. `status.value` of
101//! `published` (landed) or `abandoned` is `Lifecycle::Resolved`; a revision id not found in the
102//! response at all is also treated as `Resolved`, so a review that's vanished (e.g. lost access)
103//! doesn't linger forever.
104
105use 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
118/// This source's hardcoded id-namespace prefix - see [`crate::state::ReviewKey`].
119pub 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(&params);
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    /// Fetch every page of a `*.search` method, following `cursor.after`.
183    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    /// Batch stack resolution: walks every seed's parent chain in lockstep, one `edge.search` +
206    /// `differential.revision.search` pair *per depth level* covering every stack still in
207    /// flight, rather than one pair per stack. See the module docs for why this matters (it's
208    /// what keeps `fetch_queue`'s Conduit call count independent of queue size). Each seed is
209    /// keyed by its own `revision_id` in the returned map. Stops each chain at (and excludes) its
210    /// first closed ancestor - its content is already part of the base. Used only for `version`
211    /// tracking now; `moz-phab patch` does its own, more capable stack walk for the actual
212    /// checkout.
213    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            // safety valve against an unexpected cycle
226            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; // no parent edge; this stack is done
271                };
272                let Some(parent) = parents.get(parent_phid) else {
273                    continue; // parent vanished; stop here
274                };
275                if is_closed(&parent.fields.status.value) {
276                    continue; // landed/abandoned ancestor already part of the base
277                }
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    /// Diffstat for every candidate with an active diff, keyed by revision id - fetched as part
295    /// of the same `fetch_queue()` call that builds the `Review`s themselves, not lazily by the
296    /// TUI. Best-effort throughout: any failure just means affected revision(s) get no diffstat,
297    /// never a failed queue fetch.
298    ///
299    /// `differential.querydiffs` (the obvious-looking Conduit call for this - a diff's per-file
300    /// `changes` with line counts) doesn't work against Mozilla's Phabricator: it's missing from
301    /// `moz-phab`'s own `IDEMPOTENT_CONDUIT_METHODS` allowlist, and unlike every other method this
302    /// module calls, `moz-phab`'s source never uses it - confirmed by reading `mozphab/conduit.py`
303    /// rather than by hitting the API live, since this tool has no Phabricator credentials of its
304    /// own to test against. What `moz-phab patch` uses instead to get diff content is
305    /// `differential.getrawdiff {diffID}` (a single diff id, not batchable) - a raw unified diff
306    /// text, the same format `Checkout::Patches`' `Patch::diff` already is elsewhere in this
307    /// codebase - which `parse_unified_diff` below turns into the same per-file stats
308    /// `querydiffs` would have. Every candidate's `diffPHID` is still resolved to a numeric diff
309    /// id via one batched `differential.diff.search` call first (that part *is* confirmed real,
310    /// via `mozphab/conduit.py`'s `get_diffs`); only the raw-diff fetch itself is one call per
311    /// revision, run sequentially (mirrors `moz-phab patch`'s own per-diff `getrawdiff` calls,
312    /// just without its `ThreadPoolExecutor` concurrency).
313    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        // Sorted for a deterministic request body, same as `author_phids`/`repo_phids` above.
335        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; // one revision's diff failing to fetch shouldn't cost the others
359            };
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    /// Resolve author PHIDs to usernames for display (`rq show`/`rq path` show `moz-phab`-style
387    /// usernames, not raw PHIDs). Falls back to the PHID itself for any that don't resolve,
388    /// rather than failing the whole queue fetch over a display nicety.
389    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    /// One batched `diffusion.repository.search {constraints:{phids:[...]}}` over every distinct
403    /// repo among the given phids, keyed by repo phid in the result - mirrors `resolve_usernames`.
404    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    /// Look up the revision's repo and write `.git/.arcconfig` in the canonical repo so
441    /// `moz-phab` can find the right Phabricator instance/repo. See the module docs for why this
442    /// is required (a live-verified correction to what `moz-phab`'s own source suggested) and why
443    /// `.git/.arcconfig` specifically (checked first, never touches tracked content).
444    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        // Filter down to actionable revisions before doing any per-revision Conduit lookups, then
533        // batch those lookups (repos, stacks) across all of them at once - see the module docs.
534        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; // no repo attached; nothing for us to check out
541            };
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            // `stack` is bottom-first and ends with the revision itself.
574            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                // Vanished entirely (e.g. lost access) - don't track it forever.
648                None => Lifecycle::Resolved,
649            };
650            out.push((id_str.clone(), lifecycle));
651        }
652        Ok(out)
653    }
654}
655
656/// `moz-phab patch` leaves a `Differential Revision: https://.../D123` trailer in each commit
657/// message it creates; `id` is `D123`.
658fn 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
678/// The bucketing decision itself, pulled out of `fetch_queue` so it's directly unit-testable
679/// without any network mocking - this is the part transcribed from myqonly's own
680/// `_bucketRevisions`, and the highest-risk logic in this source (get it wrong and reviews
681/// either go missing or show up when there's nothing to do).
682///
683/// `mine_phids` is `[my_phid, ...my group phids]`; `group_names` maps a group phid to its
684/// display name, used only when the actionable match came from a group rather than me directly.
685fn 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
722/// `token`, then `token_cmd`, then `~/.arcrc` (`hosts["{url}/api/"].token`).
723async 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
764/// `"D12345"` -> `12345`.
765fn 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
772/// Flatten a JSON object into Conduit's bracket-notation form fields, e.g.
773/// `{"constraints": {"members": ["X"]}}` -> `[("constraints[members][0]", "X")]`.
774fn 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// `revision_id`/`revision_phid` are read by tests (asserting exactly which revisions ended up in
804// the stack) but not by `fetch_queue` itself, which only needs `date_modified` now that the
805// stack walk no longer feeds checkout building.
806#[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/// A `differential.diff.search` result - `id` is what `differential.getrawdiff` (which takes a
913/// diff id, not a PHID) needs; `phid` maps a result back to the revision that requested it, since
914/// `diff_stats_for` batches this search across every candidate's `diffPHID` at once.
915#[derive(Deserialize)]
916struct DiffItem {
917    id: u64,
918    phid: String,
919}
920
921/// Per-file `+`/`-` counts from a unified diff's raw text - the same format `Checkout::Patches`'
922/// `Patch::diff` field already is elsewhere in this codebase (applied via `git apply`/`patch
923/// -p1`), so this is a safe assumption for whatever `differential.getrawdiff` hands back. Only
924/// lines within a hunk that start with `+`/`-` count, not the `+++`/`---` file headers. A rename
925/// with no content change still gets a zero-count entry (from `diff --git` starting a new
926/// section) so it's at least listed.
927fn 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; // file headers, not hunk content
947        } 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(&params);
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        // Simulates `include_groups = false`: caller only passes `[my_phid]` as `mine_phids`.
1146        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        // D2 (the review we're checking out) depends on D1, which has already landed.
1226        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        // resolve_stacks: no parent edge for D1's stack.
1289        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    /// Regression guard for the N+1 pattern that used to trip Phabricator's rate limit: two
1330    /// actionable revisions sharing a repo and each with a one-level-deep stack must resolve with
1331    /// exactly one `diffusion.repository.search` and one `edge.search` call between them, not one
1332    /// each. `.expect(1)` fails the test (on `MockServer` teardown) if either is called twice.
1333    #[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        // Parent lookup for D2's stack.
1439        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        // No mocks registered for diff.search/getrawdiff - a revision with no `diffPHID` must
1677        // never trigger either call.
1678        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}