Skip to content

feat(diff): abstract PR hosting behind a provider trait; add Azure DevOps#162

Open
Joehoel wants to merge 11 commits into
jnsahaj:mainfrom
Joehoel:feat/pr-provider-abstraction
Open

feat(diff): abstract PR hosting behind a provider trait; add Azure DevOps#162
Joehoel wants to merge 11 commits into
jnsahaj:mainfrom
Joehoel:feat/pr-provider-abstraction

Conversation

@Joehoel

@Joehoel Joehoel commented Jun 5, 2026

Copy link
Copy Markdown

lumen diff --pr now works with Azure DevOps, not just GitHub. This is the provider-trait abstraction proposed in #118: the GitHub plumbing moved behind a PrProvider trait, so adding a forge (GitLab, Gitea) is one module plus a registry line. GitHub behaviour is unchanged. GitLab itself is a small follow-up.

Azure auth is just az login (we mint a bearer token via az account get-access-token), or a PAT in AZURE_DEVOPS_EXT_PAT; no azure-devops extension needed. Diffs use the iteration-changes api endpoint, the same merge-base view the web UI shows.

CleanShot.2026-06-09.at.20.25.06.mp4

eojoel added 11 commits June 5, 2026 22:33
…vOps

The "lumen diff --pr" PR review flow was hardcoded to GitHub and the gh
CLI throughout. Introduce a PrProvider trait (src/command/diff/pr_provider.rs)
so other forges can be supported, and move the existing GitHub logic behind a
GitHubProvider impl.

Add an AzureProvider backed by the Azure DevOps REST API
(src/command/diff/azure.rs):
- parses dev.azure.com and *.visualstudio.com PR URLs and remotes (HTTPS + SSH)
- fetches PR metadata, the iteration change list, and blob content over REST
- diffs against the merge base (iteration changes with $compareTo=0), i.e. the
  same three-dot view the Azure web UI shows, instead of a tip-to-tip diff
- fetches blobs concurrently over pooled HTTP (reusing the existing async
  reqwest + tokio; no new dependencies)
- supports --detect-pr and open-in-browser ('o')

Why REST and not the az CLI: az has no first-class command to list a PR's
changed files or fetch file content; the only CLI route is the generic
`az devops invoke` passthrough, which spawns a Python process per call
(2 per file) and is unusably slow for an interactive diff. REST also lets us
drop the azure-devops extension requirement entirely.

Auth resolves to a PAT (ADO_PAT / AZURE_DEVOPS_EXT_PAT) via HTTP Basic, or a
bearer token from `az account get-access-token` (core az, no extension).

Per-file viewed-state sync stays a GitHub-only capability via a default no-op
on the trait (Azure DevOps has no equivalent API).

The provider is selected from the PR URL, falling back to the git origin
remote, so bare PR numbers route to the right forge. Adds unit tests for
URL/remote parsing, provider detection, and Azure change-entry parsing.

Refs jnsahaj#118
- blob_text returns Result so a failed fetch can't silently empty a side
  and flip a file's status to Added/Deleted; the load fails instead.
- drop the unreachable HTTP 203 branch in auth_hint.
- hoist a single percent_encode helper into git.rs, used by both the
  Azure REST client and the PR web-URL builder.
Reorganise the PR-provider abstraction so adding a forge is one module plus
one registry entry, and provider-specific code stops leaking across files:

- pr_provider/ becomes a directory module: github.rs (all the gh-CLI logic,
  moved out of diff/mod.rs and git.rs and de-prefixed) and azure/ (mod.rs for
  URL/remote parsing + the PrProvider impl, client.rs for the REST client).
- Replace ProviderKind + handler() match + per-method dispatch with a
  &'static [&dyn PrProvider] registry; selection iterates it. Store the
  provider as &'static dyn PrProvider directly on PrInfo (trait gains : Sync
  so it can cross the viewed-sync threads), dropping the enum entirely.
- git.rs is now generic VCS only; mod.rs no longer holds github_* functions.

Behaviour-preserving; errors are still String and PrInfo still carries the
Azure Option fields (addressed in follow-up commits).
The PrProvider trait and its impls now return a thiserror PrError whose
variant is the kind — Auth / NotFound / InvalidRef / Other — so the UI can
react to the failure (e.g. prompt for credentials on Auth) rather than only
display a string. Classification happens where it's reliable:

- Azure REST: HTTP 401/403 -> Auth, 404 -> NotFound; missing/!ok az creds
  -> Auth.
- Parse failures -> InvalidRef; "no PR for branch" -> NotFound.

A From<String>/From<&str> impl lets the remaining opaque CLI/transport
errors fall through to Other via `?`, so internal helpers stay terse.
PrInfo carried node_id (GitHub-only) plus project/org_url (Azure-only) as
Option fields that every provider saw as None for the others. Replace them
with a single ProviderData enum whose variant holds exactly the fields its
forge needs, so each provider pattern-matches its own data (no spurious None,
and adding a forge is a new variant rather than two more Options). Common
fields (refs, repo owners) stay on PrInfo.
A supports_viewed_sync() -> bool can silently drift from the fetch/set impls
(override one, forget the other). Replace the three methods with a single
viewed_sync() -> Option<&dyn ViewedSync>: having the impl *is* the capability,
so it can't disagree with itself. GitHub returns Some(self) and implements
ViewedSync; Azure inherits the None default. The fetch/set facades route
through the Option, no-op when absent.
Stop hand-navigating serde_json::Value (and, on the GitHub side, scanning
raw JSON with str::find) and deserialize into small typed structs instead —
the compiler now checks the wire shape and the structs document it.

- azure/client.rs: a generic get::<T: DeserializeOwned> replaces get_json;
  IterationList, ChangesPage/RawChange/RawItem, PrDetail, PrList, and
  TokenResponse model each endpoint. RawChange::into_change keeps the
  folder-skip / rename-fallback logic (fallible, so a method returning
  Option rather than a From impl).
- github.rs: GraphQl<T> + RepoNode<T> envelopes with #[serde(rename_all =
  camelCase)] replace extract_json_string / extract_nested_login and the
  viewerViewedState string scan, which were brittle to whitespace/key order.
- Add parse tests locking the GraphQL wire contract (incl. deleted head fork).

Unknown fields are ignored by default, preserving the prior "read only what
we need" tolerance. Behaviour-preserving; 148 tests pass.
ADO_PAT is a made-up alias nothing sets; AZURE_DEVOPS_EXT_PAT is the
conventional Azure DevOps PAT var (read by the az devops extension), and a
plain az login is enough on its own via the bearer-token fallback. Reorder
the PAT precedence to check the conventional var first, and point the docs,
README, and error messages at az login / AZURE_DEVOPS_EXT_PAT instead of the
alias.
      The // ---- divider banners and step-narrating comments (// Try to parse
      as a URL first, etc.) don't match the rest of the codebase, which uses
      neither. Keep only the why-comments.
@Joehoel Joehoel marked this pull request as ready for review June 9, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants