Compare commits
5 Commits
31203889bb
...
5c08aa0a67
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c08aa0a67 | |||
| 0202644efa | |||
| bd71d463a4 | |||
| 6a359cc351 | |||
| cce3b1e469 |
@@ -1,3 +1,9 @@
|
|||||||
|
- [x] improve: pull requests - add a tab for drafts so PRs in this state don't populate the "open" tab.
|
||||||
|
- [x] improve: pull requests - add a checkmark left of the PR entry in the PR list if the user in question (me) has already approved it.
|
||||||
|
- [x] improve: pull requests - add a `🙏` emoji left of the PR entry in the PR list if the user in question (me) has been requested as reviewer.
|
||||||
|
- also change the checkmark displayed for PRs approved by me to the `👍` emoji.
|
||||||
|
- make the symbols displayed for these states configurable.
|
||||||
|
- [x] chore: check to make sure we're mocking everything in tests, not pinging/fetching real data from real websites/APIs.
|
||||||
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
|
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
|
||||||
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
||||||
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ Vamp.configure do |config|
|
|||||||
config.diff_view_mode = "split" # or "unified"
|
config.diff_view_mode = "split" # or "unified"
|
||||||
config.diff_split_orientation = "vertical" # or "horizontal"
|
config.diff_split_orientation = "vertical" # or "horizontal"
|
||||||
config.diff_follow_working_copy = true
|
config.diff_follow_working_copy = true
|
||||||
|
config.pull_request_review_requested_symbol = "🙏"
|
||||||
|
config.pull_request_approved_symbol = "👍"
|
||||||
config.resolve_history_layout = "columns" # or "base_below"
|
config.resolve_history_layout = "columns" # or "base_below"
|
||||||
config.review_directory = nil # XDG data directory; override with an absolute or config-relative path
|
config.review_directory = nil # XDG data directory; override with an absolute or config-relative path
|
||||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
||||||
@@ -105,6 +107,8 @@ Indicators compare the current editor text (including unsaved edits) against Git
|
|||||||
|
|
||||||
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
|
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
|
||||||
|
|
||||||
|
`config.pull_request_review_requested_symbol` and `config.pull_request_approved_symbol` control the markers before PR numbers in the pull-request picker. Defaults are `"🙏"` for an outstanding personal review request and `"👍"` for your active approval, using the API-authenticated account. Requests take precedence over approvals when both apply. Each option accepts a string without control characters; multi-character and wide Unicode symbols are supported. Set either to `""` to hide that state, or both to `""` to remove the marker gutter. PR numbers and metadata stay aligned by terminal-cell width, including rows without a marker. Non-strings and strings containing tabs, newlines, or other control characters reject the configuration.
|
||||||
|
|
||||||
`config.resolve_history_layout` controls the read-only conflict inputs opened by `:resolve.history`: `"columns"` (default) places left/base/right side by side; `"base_below"` places left/right above a full-width base. Both layouts share aligned rows and synchronized vertical scrolling. Tab/Shift+Tab follows layout order (left/base/right or left/right/base); directional pane actions follow the displayed geometry. Narrow terminals show the focused source only; the base-below layout does the same when there is insufficient height. This setting does not change the editable Resolution view or ordinary diffs. Invalid values reject the configuration.
|
`config.resolve_history_layout` controls the read-only conflict inputs opened by `:resolve.history`: `"columns"` (default) places left/base/right side by side; `"base_below"` places left/right above a full-width base. Both layouts share aligned rows and synchronized vertical scrolling. Tab/Shift+Tab follows layout order (left/base/right or left/right/base); directional pane actions follow the displayed geometry. Narrow terminals show the focused source only; the base-below layout does the same when there is insufficient height. This setting does not change the editable Resolution view or ordinary diffs. Invalid values reject the configuration.
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
|
|||||||
@@ -41,6 +41,26 @@ Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/u
|
|||||||
|
|
||||||
`cargo test` runs both groups; `cargo test --lib` and `cargo test --bin vamp` select the library and CLI unit suites. Do not name a unit file `tests/unit/main.rs`: Cargo would discover it as a separate integration target.
|
`cargo test` runs both groups; `cargo test --lib` and `cargo test --bin vamp` select the library and CLI unit suites. Do not name a unit file `tests/unit/main.rs`: Cargo would discover it as a separate integration target.
|
||||||
|
|
||||||
|
### External-service isolation
|
||||||
|
|
||||||
|
Tests must not contact real websites or APIs. Forge tests inject JSON responses through `page_with`, `metadata_with`, and `resolve_with`; listing workers inject fetch closures. Repository remotes are temporary local repositories (including missing local paths for failure cases). LSP servers, Ruby plugins, and shell commands are local fixture subprocesses. The SSH alias check uses `ssh -G` with a temporary config to inspect configuration without connecting.
|
||||||
|
|
||||||
|
On macOS, verify external-service isolation with networking denied for Cargo and all descendants:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sandbox-exec -p '(version 1) (allow default) (deny network*)' \
|
||||||
|
cargo test --offline --no-fail-fast -- --test-threads=1 \
|
||||||
|
--skip shell::tests::completed_shell_does_not_leave_background_jobs_holding_output_open \
|
||||||
|
--skip shell::tests::dropping_shell_kills_and_reaps_group_with_pipe_inheriting_descendant
|
||||||
|
cargo test --offline --lib shell::tests:: -- --test-threads=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies must already be cached. `--offline` prevents Cargo downloads; it does not block networking by tests or subprocesses. The sandbox supplies that boundary, including for real Git/jj operations and fixture programs; do not replace those local integration checks with mocks.
|
||||||
|
|
||||||
|
The two skipped shell cleanup checks execute `/bin/ps`, which macOS refuses to launch even under an allow-all sandbox profile; run them outside the sandbox as shown. Serial execution avoids contention in subprocess fixtures with production deadlines.
|
||||||
|
|
||||||
|
### Coverage
|
||||||
|
|
||||||
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
||||||
- Prompt-history checks cover separate session-wide command/search entries, Unicode draft restoration, editable recalls, clamped/empty/bounded history, duplicate/empty/cancelled submissions, remapped/disabled keys, and preserved Visual/read-only diff targets. Live PTY smoke exercises arrows and raw Ctrl+k/j, restores drafts, accepts a recalled search with Enter, and checks command/search isolation and unchanged source bytes.
|
- Prompt-history checks cover separate session-wide command/search entries, Unicode draft restoration, editable recalls, clamped/empty/bounded history, duplicate/empty/cancelled submissions, remapped/disabled keys, and preserved Visual/read-only diff targets. Live PTY smoke exercises arrows and raw Ctrl+k/j, restores drafts, accepts a recalled search with Enter, and checks command/search isolation and unchanged source bytes.
|
||||||
- Command-completion checks cover forward/backward alphabetical cycling/wrap across core names, aliases and live plugin registrations, reverse-first completion and direction changes retaining the prefix, explicit execution, edits/history/cancel resets, empty/unknown/Unicode prefixes, untouched arguments, remapped/disabled keys, and preserved Visual/read-only diff targets and literal search tabs. Live PTY smoke cycles `tab.*` in both directions through wraparound, submits a completed `tab.new`, and exits with unchanged source bytes.
|
- Command-completion checks cover forward/backward alphabetical cycling/wrap across core names, aliases and live plugin registrations, reverse-first completion and direction changes retaining the prefix, explicit execution, edits/history/cancel resets, empty/unknown/Unicode prefixes, untouched arguments, remapped/disabled keys, and preserved Visual/read-only diff targets and literal search tabs. Live PTY smoke cycles `tab.*` in both directions through wraparound, submits a completed `tab.new`, and exits with unchanged source bytes.
|
||||||
|
|||||||
+13
-3
@@ -404,7 +404,17 @@ Printable input belongs to the focused query, including `:`, `_`, and `|`; Norma
|
|||||||
|
|
||||||
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
||||||
|
|
||||||
**Pull requests:** run **`:pull_requests [remote]`**, use the palette's `pull_requests` action, or bind **`config.core.pull_requests`**. The floating picker has **Open / Closed / All** tabs; Closed includes merged requests. Tab/Shift+Tab changes category, typing fuzzy-searches its number, title, author, state, and branches, and Enter immediately opens the selected request in **Branch changes** mode using its immutable head and merge base. Escape restores the source buffer. A failed list can be retried with Enter. The default remote is `origin`, or the sole configured remote; specify a name when there are multiple remotes without `origin`.
|
**Pull requests:** run **`:pull_requests [remote]`**, use the palette's `pull_requests` action, or bind **`config.core.pull_requests`**. The floating picker has **Open / Closed / Drafts / All** tabs. Open excludes drafts; Drafts contains open draft requests; Closed includes merged requests and closed drafts. Tab/Shift+Tab changes category, typing fuzzy-searches its number, title, author, state, and branches, and Enter immediately opens the selected request in **Branch changes** mode using its immutable head and merge base. Escape restores the source buffer. A failed list can be retried with Enter. The default remote is `origin`, or the sole configured remote; specify a name when there are multiple remotes without `origin`.
|
||||||
|
|
||||||
|
Each pull request's metadata shows added/deleted line counts after its state, before the branch names: `author · open · +123 -45 · topic → main`. Zero counts are shown explicitly; missing, denied, or failed statistics show `size unavailable` without preventing selection. Counts come from one batched read-only statistics query per nonempty GitHub/GitLab page ([GitHub](https://docs.github.com/en/graphql/reference/pulls), [GitLab](https://docs.gitlab.com/api/graphql/reference/#diffstatssummary)); [Gitea](https://docs.gitea.com/api/operations/repo-list-pull-requests/) supplies them directly in its list response. GitHub statistics require API authentication even for public repositories. Listing does not fetch source diffs.
|
||||||
|
|
||||||
|
Immediately before `#number`, **🙏 marks an outstanding personal review request** and **👍 marks your active approval**, separately from the `>` selection arrow. A review request takes precedence when both apply, including a re-review request after an earlier approval. “You” means the account authenticated to that forge's API—not the repository's Git author or SSH identity. Other users' and team-only requests do not count; completed or removed requests lose 🙏.
|
||||||
|
|
||||||
|
A later request for changes or dismissal removes 👍; comment-only and pending reviews do not replace an existing approval. The mark is not the PR's overall approval decision or a guarantee that you reviewed its current head; the forge controls approval resets after new commits. Customize either symbol with [`config.pull_request_review_requested_symbol` and `config.pull_request_approved_symbol`](configuration.md); an empty string hides that symbol. Multi-cell symbols remain aligned, and decorations are not part of searchable PR labels.
|
||||||
|
|
||||||
|
Review-state reads reuse the same API credentials as listing. GitHub matches the current list's [requested reviewers](https://docs.github.com/en/rest/pulls/review-requests#list-requested-reviewers-for-a-pull-request) and filters published [reviews by author and decision state](https://docs.github.com/en/graphql/reference/pulls#pullrequest). GitLab filters approvals by the authenticated approver and reads individual reviewer [interaction states](https://docs.gitlab.com/api/graphql/reference/) in separate bounded, paginated batches; unsupported or denied reviewer-state fields do not discard known approvals or size metadata. Gitea reuses [paginated review reads per PR](https://docs.gitea.com/api/operations/repo-list-pull-reviews/) for both states, so its pages can take longer to load.
|
||||||
|
|
||||||
|
Anonymous, denied, missing, or failed state lookups leave the corresponding indicator unavailable without preventing browsing. A blank is not proof that no request or approval exists. Reopen the picker to fetch current review state. No approval, review request, or other remote write is performed.
|
||||||
|
|
||||||
GitHub, GitLab merge requests, and Gitea are supported. API reads require `curl`; public repositories work anonymously. Authentication uses `GH_TOKEN`/`GITHUB_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN`/`GITHUB_ENTERPRISE_TOKEN` for GitHub Enterprise, `GITLAB_TOKEN` for GitLab, and `GITEA_TOKEN` for Gitea. With no matching environment token, GitHub/GitLab reuse `gh auth token --hostname HOST` / `glab auth token --hostname HOST` when available. Tokens are supplied to curl on stdin, not in subprocess arguments, and sent only to the HTTPS API; redirects are not followed. Git object fetching uses the configured remote's existing Git/SSH credentials separately.
|
GitHub, GitLab merge requests, and Gitea are supported. API reads require `curl`; public repositories work anonymously. Authentication uses `GH_TOKEN`/`GITHUB_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN`/`GITHUB_ENTERPRISE_TOKEN` for GitHub Enterprise, `GITLAB_TOKEN` for GitLab, and `GITEA_TOKEN` for Gitea. With no matching environment token, GitHub/GitLab reuse `gh auth token --hostname HOST` / `glab auth token --hostname HOST` when available. Tokens are supplied to curl on stdin, not in subprocess arguments, and sent only to the HTTPS API; redirects are not followed. Git object fetching uses the configured remote's existing Git/SSH credentials separately.
|
||||||
|
|
||||||
@@ -412,9 +422,9 @@ Standard GitHub/GitLab/Gitea hosts and Codeberg are detected from HTTPS or SSH r
|
|||||||
|
|
||||||
SSH aliases are resolved with `ssh -G` before provider detection and API authentication, honoring the effective `HostName` and user-specific `Match` settings. SSH ports do not become HTTPS API ports. Git fetches retain the original remote URL, alias, and SSH identity configuration. A failed API load shows its error without also claiming that the search has no matches; the empty-result message appears only after a successful load.
|
SSH aliases are resolved with `ssh -G` before provider detection and API authentication, honoring the effective `HostName` and user-specific `Match` settings. SSH ports do not become HTTPS API ports. Git fetches retain the original remote URL, alias, and SSH identity configuration. A failed API load shows its error without also claiming that the search has no matches; the empty-result message appears only after a successful load.
|
||||||
|
|
||||||
Open and Closed fetch their first pages concurrently, newest-first, and display each page immediately. Subsequent pages load in the background, with the active category dispatched first; loaded rows remain selectable. Pages request 50 entries. GitLab Closed combines separate closed and merged requests (up to 100 entries per batch). All merges the two catalogs without a third fetch stream, deduplicates requests, and sorts by creation time; background updates preserve the selected PR's identity.
|
Open (including drafts) and Closed fetch their first pages concurrently, newest-first, and display each page immediately. Open and Drafts filter the same catalog, sharing its loading, completion, and retry state without extra requests. Subsequent pages load in the background, with the active category's stream dispatched first; loaded rows remain selectable. Pages request 50 entries. GitLab Closed combines separate closed and merged requests (up to 100 entries per batch). All includes open, draft, closed, and merged requests from the two streams, deduplicates requests, and sorts by creation time; background updates preserve the selected PR's identity.
|
||||||
|
|
||||||
Search filters loaded entries locally and explicitly indicates partial results while more pages remain. A later-page error retains loaded rows; **Ctrl+r** retries failed categories (the active category, or both from All). Enter selects a loaded row; in an empty failed category it retries loading. Escape cancels outstanding work. Selection stops listing and only then retrieves that PR's historical metadata and preserved head reference, so GitLab detail reads and Gitea ref lookups do not delay the catalog.
|
Search filters loaded entries locally and explicitly indicates partial results while more pages remain. A later-page error retains loaded rows; **Ctrl+r** retries failed fetch streams (the active category's stream, or both from All). Enter selects a loaded row; in an empty failed category it retries loading. Escape cancels outstanding work. Selection stops listing and only then retrieves that PR's historical metadata and preserved head reference, so GitLab detail reads and Gitea ref lookups do not delay the catalog.
|
||||||
|
|
||||||
Fork heads are fetched through the target repository's PR refs into the existing private comparison store, without changing active refs, jj operations, or checkout files. Closed/merged requests retain historical bases and heads rather than comparing against today's branch tips. If the server no longer retains the required commits, opening the request reports an error instead of displaying a different diff. Review submission and comments are separate, not part of this picker.
|
Fork heads are fetched through the target repository's PR refs into the existing private comparison store, without changing active refs, jj operations, or checkout files. Closed/merged requests retain historical bases and heads rather than comparing against today's branch tips. If the server no longer retains the required commits, opening the request reports an error instead of displaying a different diff. Review submission and comments are separate, not part of this picker.
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ Vamp.configure do |config|
|
|||||||
config.diff_view_mode = "split" # or "unified"
|
config.diff_view_mode = "split" # or "unified"
|
||||||
config.diff_split_orientation = "vertical" # or "horizontal"
|
config.diff_split_orientation = "vertical" # or "horizontal"
|
||||||
config.diff_follow_working_copy = true # false keeps the selected Working After endpoint
|
config.diff_follow_working_copy = true # false keeps the selected Working After endpoint
|
||||||
|
config.pull_request_review_requested_symbol = "🙏"
|
||||||
|
config.pull_request_approved_symbol = "👍"
|
||||||
config.resolve_history_layout = "columns" # or "base_below" for left/right above the base
|
config.resolve_history_layout = "columns" # or "base_below" for left/right above the base
|
||||||
# config.popup.command = { anchor: :bottom_left }
|
# config.popup.command = { anchor: :bottom_left }
|
||||||
# config.popup.search = { anchor: :center }
|
# config.popup.search = { anchor: :center }
|
||||||
|
|||||||
+59
-20
@@ -24,6 +24,7 @@ pub enum Category {
|
|||||||
Tags,
|
Tags,
|
||||||
Open,
|
Open,
|
||||||
Closed,
|
Closed,
|
||||||
|
Drafts,
|
||||||
All,
|
All,
|
||||||
}
|
}
|
||||||
impl Category {
|
impl Category {
|
||||||
@@ -36,6 +37,7 @@ impl Category {
|
|||||||
Self::Tags => "Tags",
|
Self::Tags => "Tags",
|
||||||
Self::Open => "Open",
|
Self::Open => "Open",
|
||||||
Self::Closed => "Closed",
|
Self::Closed => "Closed",
|
||||||
|
Self::Drafts => "Drafts",
|
||||||
Self::All => "All",
|
Self::All => "All",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,11 +101,19 @@ pub struct Catalog {
|
|||||||
pub entries: Vec<Revision>,
|
pub entries: Vec<Revision>,
|
||||||
pub notice: Option<String>,
|
pub notice: Option<String>,
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub enum PullRequestReviewState {
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
Approved,
|
||||||
|
Requested,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PullRequestPage {
|
pub struct PullRequestPage {
|
||||||
pub kind: RepoKind,
|
pub kind: RepoKind,
|
||||||
pub remote: String,
|
pub remote: String,
|
||||||
pub entries: Vec<(Revision, String)>,
|
pub entries: Vec<(Revision, String, PullRequestReviewState)>,
|
||||||
pub has_more: bool,
|
pub has_more: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,7 +580,7 @@ pub struct DiffState {
|
|||||||
pub catalog: Option<Catalog>,
|
pub catalog: Option<Catalog>,
|
||||||
pub pull_request_remote: Option<String>,
|
pub pull_request_remote: Option<String>,
|
||||||
pull_request_categories: [PullRequestCategory; 2],
|
pull_request_categories: [PullRequestCategory; 2],
|
||||||
pull_request_order: BTreeMap<u64, (String, usize)>,
|
pull_request_order: BTreeMap<u64, (String, usize, PullRequestReviewState)>,
|
||||||
pub(crate) pull_request_retries: [u64; 2],
|
pub(crate) pull_request_retries: [u64; 2],
|
||||||
pub phase: Phase,
|
pub phase: Phase,
|
||||||
pub word_wrap: bool,
|
pub word_wrap: bool,
|
||||||
@@ -814,7 +824,12 @@ impl DiffState {
|
|||||||
|
|
||||||
pub fn categories(&self) -> &'static [Category] {
|
pub fn categories(&self) -> &'static [Category] {
|
||||||
if self.pull_request_remote.is_some() {
|
if self.pull_request_remote.is_some() {
|
||||||
&[Category::Open, Category::Closed, Category::All]
|
&[
|
||||||
|
Category::Open,
|
||||||
|
Category::Closed,
|
||||||
|
Category::Drafts,
|
||||||
|
Category::All,
|
||||||
|
]
|
||||||
} else {
|
} else {
|
||||||
self.catalog
|
self.catalog
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -927,16 +942,18 @@ impl DiffState {
|
|||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
notice: Some(format!("Pull requests from {}", page.remote)),
|
notice: Some(format!("Pull requests from {}", page.remote)),
|
||||||
});
|
});
|
||||||
for (revision, created_at) in page.entries {
|
for (revision, created_at, review_state) in page.entries {
|
||||||
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
|
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Some((date, index)) = self.pull_request_order.get_mut(number) {
|
if let Some((date, index, state)) = self.pull_request_order.get_mut(number)
|
||||||
|
{
|
||||||
*date = created_at;
|
*date = created_at;
|
||||||
|
*state = review_state;
|
||||||
catalog.entries[*index] = revision;
|
catalog.entries[*index] = revision;
|
||||||
} else {
|
} else {
|
||||||
self.pull_request_order
|
self.pull_request_order
|
||||||
.insert(*number, (created_at, catalog.entries.len()));
|
.insert(*number, (created_at, catalog.entries.len(), review_state));
|
||||||
catalog.entries.push(revision);
|
catalog.entries.push(revision);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1207,26 +1224,44 @@ impl DiffState {
|
|||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pull_request_streams(&self) -> std::ops::Range<usize> {
|
||||||
|
match self.categories().get(self.category) {
|
||||||
|
Some(Category::All) => 0..2,
|
||||||
|
Some(Category::Closed) => 1..2,
|
||||||
|
_ => 0..1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn pull_request_review_state(&self, revision: &Revision) -> PullRequestReviewState {
|
||||||
|
if let RevisionTarget::PullRequest { number, .. } = revision.target {
|
||||||
|
self.pull_request_order
|
||||||
|
.get(&number)
|
||||||
|
.map_or(PullRequestReviewState::None, |entry| entry.2)
|
||||||
|
} else {
|
||||||
|
PullRequestReviewState::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn pull_request_partial(&self) -> bool {
|
pub(crate) fn pull_request_partial(&self) -> bool {
|
||||||
self.pull_request_remote.is_some()
|
self.pull_request_remote.is_some()
|
||||||
&& self
|
&& self
|
||||||
.pull_request_categories
|
.pull_request_streams()
|
||||||
.iter()
|
.any(|index| !self.pull_request_categories[index].complete)
|
||||||
.enumerate()
|
|
||||||
.any(|(index, status)| {
|
|
||||||
(self.category == 2 || self.category == index) && !status.complete
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn pull_request_notice(&self) -> Option<String> {
|
pub(crate) fn pull_request_notice(&self) -> Option<String> {
|
||||||
self.pull_request_remote.as_ref()?;
|
self.pull_request_remote.as_ref()?;
|
||||||
|
let active = self.categories()[self.category];
|
||||||
let statuses: Vec<_> = self
|
let statuses: Vec<_> = self
|
||||||
.pull_request_categories
|
.categories()
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.copied()
|
||||||
.filter(|(index, _)| self.category == 2 || self.category == *index)
|
.filter(|category| {
|
||||||
.map(|(index, status)| {
|
*category != Category::All && (active == Category::All || *category == active)
|
||||||
let category = [Category::Open, Category::Closed][index];
|
})
|
||||||
|
.map(|category| {
|
||||||
|
let status =
|
||||||
|
&self.pull_request_categories[usize::from(category == Category::Closed)];
|
||||||
let count = self.catalog.as_ref().map_or(0, |catalog| {
|
let count = self.catalog.as_ref().map_or(0, |catalog| {
|
||||||
catalog
|
catalog
|
||||||
.entries
|
.entries
|
||||||
@@ -1254,8 +1289,9 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn retry_pull_requests(&mut self) {
|
fn retry_pull_requests(&mut self) {
|
||||||
for (index, status) in self.pull_request_categories.iter_mut().enumerate() {
|
for index in self.pull_request_streams() {
|
||||||
if (self.category == 2 || self.category == index) && status.error.take().is_some() {
|
let status = &mut self.pull_request_categories[index];
|
||||||
|
if status.error.take().is_some() {
|
||||||
self.pull_request_retries[index] += 1;
|
self.pull_request_retries[index] += 1;
|
||||||
self.loading = true;
|
self.loading = true;
|
||||||
}
|
}
|
||||||
@@ -1284,7 +1320,10 @@ impl DiffState {
|
|||||||
if entry.target == RevisionTarget::Working
|
if entry.target == RevisionTarget::Working
|
||||||
|| (entry.category != category
|
|| (entry.category != category
|
||||||
&& !(category == Category::All
|
&& !(category == Category::All
|
||||||
&& matches!(entry.category, Category::Open | Category::Closed)))
|
&& matches!(
|
||||||
|
entry.category,
|
||||||
|
Category::Open | Category::Closed | Category::Drafts
|
||||||
|
)))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2284,6 +2284,8 @@ pub struct Editor {
|
|||||||
diff_return_buffer: Option<usize>,
|
diff_return_buffer: Option<usize>,
|
||||||
diff_context_options: (usize, usize),
|
diff_context_options: (usize, usize),
|
||||||
diff_display_options: (ViewMode, SplitAxis),
|
diff_display_options: (ViewMode, SplitAxis),
|
||||||
|
pub(crate) pull_request_review_requested_symbol: String,
|
||||||
|
pub(crate) pull_request_approved_symbol: String,
|
||||||
next_diff_request: u64,
|
next_diff_request: u64,
|
||||||
outstanding_diff_request: Option<u64>,
|
outstanding_diff_request: Option<u64>,
|
||||||
pending_lsp_navigation: Option<PendingLspNavigation>,
|
pending_lsp_navigation: Option<PendingLspNavigation>,
|
||||||
@@ -2424,6 +2426,8 @@ impl Editor {
|
|||||||
diff_return_buffer: None,
|
diff_return_buffer: None,
|
||||||
diff_context_options: (10, 15),
|
diff_context_options: (10, 15),
|
||||||
diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
|
diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
|
||||||
|
pull_request_review_requested_symbol: "🙏".into(),
|
||||||
|
pull_request_approved_symbol: "👍".into(),
|
||||||
next_diff_request: 0,
|
next_diff_request: 0,
|
||||||
outstanding_diff_request: None,
|
outstanding_diff_request: None,
|
||||||
pending_lsp_navigation: None,
|
pending_lsp_navigation: None,
|
||||||
|
|||||||
+463
-13
@@ -13,9 +13,18 @@ pub(crate) struct PullRequestSummary {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub closed: bool,
|
pub closed: bool,
|
||||||
|
pub draft: bool,
|
||||||
|
pub approved: bool,
|
||||||
|
pub review_requested: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PullRequestMetadata {
|
||||||
|
size: Option<(u64, u64)>,
|
||||||
|
approved: bool,
|
||||||
|
review_requested: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct PullRequestPage {
|
pub(crate) struct PullRequestPage {
|
||||||
pub entries: Vec<PullRequestSummary>,
|
pub entries: Vec<PullRequestSummary>,
|
||||||
pub has_more: bool,
|
pub has_more: bool,
|
||||||
@@ -42,6 +51,7 @@ struct Forge {
|
|||||||
provider: Provider,
|
provider: Provider,
|
||||||
host: String,
|
host: String,
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
|
repository: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode(text: &str) -> String {
|
fn encode(text: &str) -> String {
|
||||||
@@ -237,6 +247,7 @@ impl Forge {
|
|||||||
provider,
|
provider,
|
||||||
host: authority,
|
host: authority,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
repository: path.to_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,14 +319,18 @@ impl Forge {
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get(
|
fn request(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
body: Option<&str>,
|
||||||
token: Option<&str>,
|
token: Option<&str>,
|
||||||
root: &Path,
|
root: &Path,
|
||||||
cancelled: &AtomicBool,
|
cancelled: &AtomicBool,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
let mut headers = String::from("Accept: application/json\n");
|
let mut headers = String::from("Accept: application/json\n");
|
||||||
|
if body.is_some() {
|
||||||
|
headers.push_str("Content-Type: application/json\n");
|
||||||
|
}
|
||||||
if let Some(token) = token {
|
if let Some(token) = token {
|
||||||
let prefix = match self.provider {
|
let prefix = match self.provider {
|
||||||
Provider::Github => "Authorization: Bearer",
|
Provider::Github => "Authorization: Bearer",
|
||||||
@@ -347,6 +362,9 @@ impl Forge {
|
|||||||
"--url",
|
"--url",
|
||||||
url,
|
url,
|
||||||
]);
|
]);
|
||||||
|
if let Some(body) = body {
|
||||||
|
command.args(["--data-binary", body]);
|
||||||
|
}
|
||||||
let bytes = checked(run(command, headers.into_bytes(), cancelled)?).map_err(
|
let bytes = checked(run(command, headers.into_bytes(), cancelled)?).map_err(
|
||||||
|_| "Forge API request failed; check curl, network access, and authentication",
|
|_| "Forge API request failed; check curl, network access, and authentication",
|
||||||
)?;
|
)?;
|
||||||
@@ -374,7 +392,7 @@ impl Forge {
|
|||||||
&self,
|
&self,
|
||||||
closed: bool,
|
closed: bool,
|
||||||
page: u32,
|
page: u32,
|
||||||
mut get: impl FnMut(&str) -> Result<Value, String>,
|
mut request: impl FnMut(&str, Option<&str>) -> Result<Value, String>,
|
||||||
) -> Result<PullRequestPage, String> {
|
) -> Result<PullRequestPage, String> {
|
||||||
if page == 0 {
|
if page == 0 {
|
||||||
return Err("Forge page numbers start at 1".into());
|
return Err("Forge page numbers start at 1".into());
|
||||||
@@ -394,17 +412,29 @@ impl Forge {
|
|||||||
// Gitea's default sort is descending creation time; "oldest" reverses it.
|
// Gitea's default sort is descending creation time; "oldest" reverses it.
|
||||||
Provider::Gitea => "limit=50",
|
Provider::Gitea => "limit=50",
|
||||||
};
|
};
|
||||||
let values = get(&format!(
|
let values = request(
|
||||||
"{}?state={state}&{query}&page={page}",
|
&format!("{}?state={state}&{query}&page={page}", self.endpoint),
|
||||||
self.endpoint
|
None,
|
||||||
))?;
|
)?;
|
||||||
let values = values
|
let values = values
|
||||||
.as_array()
|
.as_array()
|
||||||
.ok_or("Forge API returned a non-array pull request list")?;
|
.ok_or("Forge API returned a non-array pull request list")?;
|
||||||
// Servers may cap page sizes. Only an empty page proves exhaustion.
|
// Servers may cap page sizes. Only an empty page proves exhaustion.
|
||||||
has_more |= !values.is_empty();
|
has_more |= !values.is_empty();
|
||||||
|
let metadata = self.metadata_with(values, &mut request);
|
||||||
for value in values {
|
for value in values {
|
||||||
let entry = summary(self.provider, value)?;
|
let id = number(
|
||||||
|
value,
|
||||||
|
if self.provider == Provider::Gitlab {
|
||||||
|
"iid"
|
||||||
|
} else {
|
||||||
|
"number"
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let details = metadata.get(&id);
|
||||||
|
let mut entry = summary(self.provider, value, details.and_then(|d| d.size))?;
|
||||||
|
entry.approved = details.is_some_and(|d| d.approved);
|
||||||
|
entry.review_requested = details.is_some_and(|d| d.review_requested);
|
||||||
if entry.closed != closed {
|
if entry.closed != closed {
|
||||||
return Err("Forge API returned a pull request in the wrong category".into());
|
return Err("Forge API returned a pull request in the wrong category".into());
|
||||||
}
|
}
|
||||||
@@ -422,6 +452,407 @@ impl Forge {
|
|||||||
Ok(PullRequestPage { entries, has_more })
|
Ok(PullRequestPage { entries, has_more })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Batch GitHub/GitLab metadata; Gitea exposes reviews only per pull request.
|
||||||
|
fn metadata_with(
|
||||||
|
&self,
|
||||||
|
values: &[Value],
|
||||||
|
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
|
||||||
|
) -> BTreeMap<u64, PullRequestMetadata> {
|
||||||
|
if values.is_empty() {
|
||||||
|
return BTreeMap::new();
|
||||||
|
}
|
||||||
|
let api = self
|
||||||
|
.endpoint
|
||||||
|
.split_once(if self.provider == Provider::Gitlab {
|
||||||
|
"/projects/"
|
||||||
|
} else {
|
||||||
|
"/repos/"
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
let viewer = request(&format!("{api}/user"), None).ok();
|
||||||
|
let username = viewer.as_ref().and_then(|viewer| {
|
||||||
|
viewer
|
||||||
|
.get(if self.provider == Provider::Gitlab {
|
||||||
|
"username"
|
||||||
|
} else {
|
||||||
|
"login"
|
||||||
|
})
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
});
|
||||||
|
let mut metadata: BTreeMap<_, _> = values
|
||||||
|
.iter()
|
||||||
|
.filter_map(|value| {
|
||||||
|
let id = number(
|
||||||
|
value,
|
||||||
|
if self.provider == Provider::Gitlab {
|
||||||
|
"iid"
|
||||||
|
} else {
|
||||||
|
"number"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
Some((
|
||||||
|
id,
|
||||||
|
PullRequestMetadata {
|
||||||
|
size: value
|
||||||
|
.get("additions")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.zip(value.get("deletions").and_then(Value::as_u64)),
|
||||||
|
approved: false,
|
||||||
|
// GitHub's list contains only outstanding direct requests, not teams.
|
||||||
|
review_requested: self.provider == Provider::Github
|
||||||
|
&& username.is_some_and(|username| {
|
||||||
|
value
|
||||||
|
.get("requested_reviewers")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|reviewers| {
|
||||||
|
reviewers.iter().any(|reviewer| {
|
||||||
|
reviewer
|
||||||
|
.get("login")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|login| {
|
||||||
|
login.eq_ignore_ascii_case(username)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if self.provider == Provider::Gitea {
|
||||||
|
if let Some(viewer) = viewer.as_ref().and_then(|viewer| number(viewer, "id").ok()) {
|
||||||
|
for (&id, details) in &mut metadata {
|
||||||
|
// ponytail: Gitea has no batch reviews API; per-PR reads until it does.
|
||||||
|
// List requested_reviewers also includes completed reviews.
|
||||||
|
(details.approved, details.review_requested) = self
|
||||||
|
.gitea_reviews_with(id, viewer, request)
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
if self.provider == Provider::Gitlab {
|
||||||
|
if let Some(username) = username {
|
||||||
|
self.gitlab_requests_with(values, username, &mut metadata, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reviews = if self.provider == Provider::Github {
|
||||||
|
username.map_or_else(String::new, |username| {
|
||||||
|
format!(
|
||||||
|
"reviews(author:{},states:[APPROVED,CHANGES_REQUESTED,DISMISSED],last:1){{nodes{{state author{{login}}}}}}",
|
||||||
|
serde_json::to_string(username).unwrap()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let mut fields = String::new();
|
||||||
|
for value in values {
|
||||||
|
let Some(id) = value
|
||||||
|
.get(if self.provider == Provider::Gitlab {
|
||||||
|
"iid"
|
||||||
|
} else {
|
||||||
|
"number"
|
||||||
|
})
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
use std::fmt::Write;
|
||||||
|
match self.provider {
|
||||||
|
Provider::Github => write!(
|
||||||
|
fields,
|
||||||
|
"p{id}:pullRequest(number:{id}){{additions deletions {reviews}}}"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Provider::Gitlab => write!(
|
||||||
|
fields,
|
||||||
|
"p{id}:mergeRequest(iid:\"{id}\"){{diffStatsSummary{{additions deletions}}}}"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Provider::Gitea => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let approval_args = if self.provider == Provider::Gitlab {
|
||||||
|
username.map(|username| {
|
||||||
|
let ids: Vec<_> = values
|
||||||
|
.iter()
|
||||||
|
.filter_map(|value| value.get("iid")?.as_u64().map(|id| id.to_string()))
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"iids:{},approvedBy:[{}],first:{}",
|
||||||
|
serde_json::to_string(&ids).unwrap(),
|
||||||
|
serde_json::to_string(username).unwrap(),
|
||||||
|
ids.len()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(args) = &approval_args {
|
||||||
|
use std::fmt::Write;
|
||||||
|
write!(
|
||||||
|
fields,
|
||||||
|
"approved:mergeRequests({args}){{nodes{{iid}} pageInfo{{hasNextPage endCursor}}}}"
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let (url, query) = if self.provider == Provider::Github {
|
||||||
|
let (owner, name) = self.repository.split_once('/').unwrap();
|
||||||
|
let api = self.endpoint.split_once("/repos/").unwrap().0;
|
||||||
|
(
|
||||||
|
format!("{}/graphql", api.strip_suffix("/v3").unwrap_or(api)),
|
||||||
|
format!(
|
||||||
|
"query{{repository(owner:{},name:{}){{{fields}}}}}",
|
||||||
|
serde_json::to_string(owner).unwrap(),
|
||||||
|
serde_json::to_string(name).unwrap()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
format!("https://{}/api/graphql", self.host),
|
||||||
|
format!(
|
||||||
|
"query{{project(fullPath:{}){{{fields}}}}}",
|
||||||
|
serde_json::to_string(&self.repository).unwrap()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let body = serde_json::json!({ "query": query }).to_string();
|
||||||
|
// Missing/denied metadata must not discard independently known list data.
|
||||||
|
let Ok(response) = request(&url, Some(&body)) else {
|
||||||
|
return metadata;
|
||||||
|
};
|
||||||
|
let container = if self.provider == Provider::Github {
|
||||||
|
"/data/repository"
|
||||||
|
} else {
|
||||||
|
"/data/project"
|
||||||
|
};
|
||||||
|
let Some(entries) = response.pointer(container).and_then(Value::as_object) else {
|
||||||
|
return metadata;
|
||||||
|
};
|
||||||
|
for (alias, value) in entries {
|
||||||
|
let Some(details) = alias
|
||||||
|
.strip_prefix('p')
|
||||||
|
.and_then(|id| id.parse::<u64>().ok())
|
||||||
|
.and_then(|id| metadata.get_mut(&id))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let stats = if self.provider == Provider::Gitlab {
|
||||||
|
value.get("diffStatsSummary")
|
||||||
|
} else {
|
||||||
|
Some(value)
|
||||||
|
};
|
||||||
|
details.size = stats
|
||||||
|
.and_then(|stats| {
|
||||||
|
stats
|
||||||
|
.get("additions")?
|
||||||
|
.as_u64()
|
||||||
|
.zip(stats.get("deletions")?.as_u64())
|
||||||
|
})
|
||||||
|
.or(details.size);
|
||||||
|
details.approved = self.provider == Provider::Github
|
||||||
|
&& username.is_some_and(|username| {
|
||||||
|
value
|
||||||
|
.pointer("/reviews/nodes")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|reviews| {
|
||||||
|
reviews.len() == 1
|
||||||
|
&& reviews[0]["state"] == "APPROVED"
|
||||||
|
&& reviews[0]
|
||||||
|
.pointer("/author/login")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|author| author.eq_ignore_ascii_case(username))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(args) = approval_args {
|
||||||
|
let mut response = response;
|
||||||
|
let mut cursor = None::<String>;
|
||||||
|
loop {
|
||||||
|
let Some(approvals) = response.pointer("/data/project/approved") else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if let Some(nodes) = approvals.get("nodes").and_then(Value::as_array) {
|
||||||
|
for node in nodes {
|
||||||
|
if let Some(details) = node
|
||||||
|
.get("iid")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|id| id.parse::<u64>().ok())
|
||||||
|
.and_then(|id| metadata.get_mut(&id))
|
||||||
|
{
|
||||||
|
details.approved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if approvals.pointer("/pageInfo/hasNextPage") != Some(&Value::Bool(true)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(after) = approvals
|
||||||
|
.pointer("/pageInfo/endCursor")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|after| !after.is_empty() && Some(*after) != cursor.as_deref())
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
cursor = Some(after.to_owned());
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"query": format!(
|
||||||
|
"query{{project(fullPath:{}){{approved:mergeRequests({args},after:{}){{nodes{{iid}} pageInfo{{hasNextPage endCursor}}}}}}}}",
|
||||||
|
serde_json::to_string(&self.repository).unwrap(),
|
||||||
|
serde_json::to_string(after).unwrap()
|
||||||
|
)
|
||||||
|
}).to_string();
|
||||||
|
match request(&url, Some(&body)) {
|
||||||
|
Ok(next) => response = next,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gitlab_requests_with(
|
||||||
|
&self,
|
||||||
|
values: &[Value],
|
||||||
|
username: &str,
|
||||||
|
metadata: &mut BTreeMap<u64, PullRequestMetadata>,
|
||||||
|
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
|
||||||
|
) {
|
||||||
|
let mut pending: BTreeMap<_, _> = values
|
||||||
|
.iter()
|
||||||
|
.filter_map(|value| {
|
||||||
|
let reviewers = value.get("reviewers")?.as_array()?;
|
||||||
|
if !reviewers.iter().any(|reviewer| {
|
||||||
|
reviewer.get("username").and_then(Value::as_str) == Some(username)
|
||||||
|
}) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((number(value, "iid").ok()?, None::<String>))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Keep optional reviewer-state reads separate: unsupported/denied fields
|
||||||
|
// must not invalidate the independent statistics and approval query.
|
||||||
|
while !pending.is_empty() {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let mut fields = String::new();
|
||||||
|
// Ten aliases stay below GitLab's default query-complexity limit.
|
||||||
|
let batch: BTreeMap<_, _> = (0..10).filter_map(|_| pending.pop_first()).collect();
|
||||||
|
for (id, cursor) in &batch {
|
||||||
|
let after = cursor.as_ref().map_or_else(String::new, |cursor| {
|
||||||
|
format!(",after:{}", serde_json::to_string(cursor).unwrap())
|
||||||
|
});
|
||||||
|
write!(fields, "p{id}:mergeRequest(iid:\"{id}\"){{reviewers(first:20{after}){{nodes{{username mergeRequestInteraction{{reviewState}}}} pageInfo{{hasNextPage endCursor}}}}}}").unwrap();
|
||||||
|
}
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"query": format!("query{{project(fullPath:{}){{{fields}}}}}",
|
||||||
|
serde_json::to_string(&self.repository).unwrap())
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let Ok(response) = request(&format!("https://{}/api/graphql", self.host), Some(&body))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (id, cursor) in batch {
|
||||||
|
let Some(reviewers) = response.pointer(&format!("/data/project/p{id}/reviewers"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let own = reviewers
|
||||||
|
.get("nodes")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|nodes| {
|
||||||
|
nodes.iter().find(|node| {
|
||||||
|
node.get("username").and_then(Value::as_str) == Some(username)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if let Some(own) = own {
|
||||||
|
if let Some(details) = metadata.get_mut(&id) {
|
||||||
|
details.review_requested = matches!(
|
||||||
|
own.pointer("/mergeRequestInteraction/reviewState")
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("UNREVIEWED" | "REVIEW_STARTED")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if reviewers.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(true)) {
|
||||||
|
if let Some(after) = reviewers
|
||||||
|
.pointer("/pageInfo/endCursor")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|after| !after.is_empty() && Some(*after) != cursor.as_deref())
|
||||||
|
{
|
||||||
|
pending.insert(id, Some(after.to_owned()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gitea_reviews_with(
|
||||||
|
&self,
|
||||||
|
id: u64,
|
||||||
|
viewer: u64,
|
||||||
|
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
|
||||||
|
) -> Result<(bool, bool), String> {
|
||||||
|
let mut latest = None::<(u64, bool)>;
|
||||||
|
let mut requested = None::<u64>;
|
||||||
|
let mut submitted = 0;
|
||||||
|
for page in 1.. {
|
||||||
|
let response = request(
|
||||||
|
&format!("{}/{id}/reviews?limit=50&page={page}", self.endpoint),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let reviews = response
|
||||||
|
.as_array()
|
||||||
|
.ok_or("Forge API returned a non-array review list")?;
|
||||||
|
if reviews.is_empty() {
|
||||||
|
return Ok((
|
||||||
|
latest.is_some_and(|(_, approved)| approved),
|
||||||
|
requested.is_some_and(|id| id > submitted),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for review in reviews {
|
||||||
|
if review.pointer("/user/id").and_then(Value::as_u64) != Some(viewer)
|
||||||
|
|| review.get("team").is_some_and(|team| !team.is_null())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let state = field(review, "state")?;
|
||||||
|
match state {
|
||||||
|
"PENDING" => continue,
|
||||||
|
"REQUEST_REVIEW" => {
|
||||||
|
if review.get("dismissed").and_then(Value::as_bool) == Some(false) {
|
||||||
|
if let Some(id) = review.get("id").and_then(Value::as_u64) {
|
||||||
|
requested = Some(requested.map_or(id, |previous| previous.max(id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
"COMMENT" => {
|
||||||
|
if let Some(id) = review.get("id").and_then(Value::as_u64) {
|
||||||
|
submitted = submitted.max(id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
"APPROVED" | "REQUEST_CHANGES" => {}
|
||||||
|
_ => return Err("Forge API returned invalid review state".into()),
|
||||||
|
}
|
||||||
|
let id = number(review, "id")?;
|
||||||
|
submitted = submitted.max(id);
|
||||||
|
let dismissed = review
|
||||||
|
.get("dismissed")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.ok_or("Forge API returned invalid dismissed flag")?;
|
||||||
|
if latest.is_none_or(|(previous, _)| id > previous) {
|
||||||
|
latest = Some((id, state == "APPROVED" && !dismissed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_with(
|
fn resolve_with(
|
||||||
&self,
|
&self,
|
||||||
id: u64,
|
id: u64,
|
||||||
@@ -567,17 +998,33 @@ fn created_at(value: &Value) -> Result<String, String> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summary(provider: Provider, value: &Value) -> Result<PullRequestSummary, String> {
|
fn summary(
|
||||||
|
provider: Provider,
|
||||||
|
value: &Value,
|
||||||
|
size: Option<(u64, u64)>,
|
||||||
|
) -> Result<PullRequestSummary, String> {
|
||||||
let gitlab = provider == Provider::Gitlab;
|
let gitlab = provider == Provider::Gitlab;
|
||||||
let (closed, merged) = status(provider, value)?;
|
let (closed, merged) = status(provider, value)?;
|
||||||
|
let draft = match value.get("draft") {
|
||||||
|
None => false,
|
||||||
|
Some(value) => value
|
||||||
|
.as_bool()
|
||||||
|
.ok_or("Forge API returned invalid draft flag")?,
|
||||||
|
};
|
||||||
let author = author(provider, value)?;
|
let author = author(provider, value)?;
|
||||||
let state = if merged {
|
let state = if merged {
|
||||||
"merged"
|
"merged"
|
||||||
} else if closed {
|
} else if closed {
|
||||||
"closed"
|
"closed"
|
||||||
|
} else if draft {
|
||||||
|
"draft"
|
||||||
} else {
|
} else {
|
||||||
"open"
|
"open"
|
||||||
};
|
};
|
||||||
|
let size = size.map_or_else(
|
||||||
|
|| "size unavailable".into(),
|
||||||
|
|(added, deleted)| format!("+{added} -{deleted}"),
|
||||||
|
);
|
||||||
let (base, head) = if gitlab {
|
let (base, head) = if gitlab {
|
||||||
(
|
(
|
||||||
value.get("target_branch").and_then(Value::as_str),
|
value.get("target_branch").and_then(Value::as_str),
|
||||||
@@ -601,10 +1048,13 @@ fn summary(provider: Provider, value: &Value) -> Result<PullRequestSummary, Stri
|
|||||||
number: number(value, if gitlab { "iid" } else { "number" })?,
|
number: number(value, if gitlab { "iid" } else { "number" })?,
|
||||||
title: field(value, "title")?.to_owned(),
|
title: field(value, "title")?.to_owned(),
|
||||||
description: match (base, head) {
|
description: match (base, head) {
|
||||||
(Some(base), Some(head)) => format!("{author} · {state} · {head} → {base}"),
|
(Some(base), Some(head)) => format!("{author} · {state} · {size} · {head} → {base}"),
|
||||||
_ => format!("{author} · {state}"),
|
_ => format!("{author} · {state} · {size}"),
|
||||||
},
|
},
|
||||||
closed,
|
closed,
|
||||||
|
draft,
|
||||||
|
approved: false,
|
||||||
|
review_requested: false,
|
||||||
created_at: created_at(value)?,
|
created_at: created_at(value)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -788,8 +1238,8 @@ pub(crate) fn page(
|
|||||||
ssh_hostname(command, destination, port, cancelled)
|
ssh_hostname(command, destination, port, cancelled)
|
||||||
})?;
|
})?;
|
||||||
let token = forge.token(root, cancelled)?;
|
let token = forge.token(root, cancelled)?;
|
||||||
forge.page_with(closed, page, |url| {
|
forge.page_with(closed, page, |url, body| {
|
||||||
forge.get(url, token.as_deref(), root, cancelled)
|
forge.request(url, body, token.as_deref(), root, cancelled)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,7 +1257,7 @@ pub(crate) fn resolve(
|
|||||||
})?;
|
})?;
|
||||||
let token = forge.token(root, cancelled)?;
|
let token = forge.token(root, cancelled)?;
|
||||||
forge.resolve_with(number, |url| {
|
forge.resolve_with(number, |url| {
|
||||||
forge.get(url, token.as_deref(), root, cancelled)
|
forge.request(url, None, token.as_deref(), root, cancelled)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -105,8 +105,10 @@ Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the
|
|||||||
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new
|
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new
|
||||||
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
|
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
|
||||||
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
|
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
|
||||||
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
|
||||||
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
||||||
|
🙏 marks a personal review request; 👍 marks your active approval (API account)
|
||||||
|
Symbols: config.pull_request_review_requested_symbol / config.pull_request_approved_symbol
|
||||||
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
|
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
|
||||||
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
|
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
|
||||||
config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base)
|
config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base)
|
||||||
|
|||||||
+10
-1
@@ -1,7 +1,7 @@
|
|||||||
//! Read-only active-repository discovery and isolated revision comparison.
|
//! Read-only active-repository discovery and isolated revision comparison.
|
||||||
use crate::diff::{
|
use crate::diff::{
|
||||||
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
|
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
|
||||||
PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
|
PullRequestPage, PullRequestReviewState, RepoKind, Revision, RevisionTarget, line_hunks,
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
@@ -1635,11 +1635,20 @@ impl Repository {
|
|||||||
description: pull.description,
|
description: pull.description,
|
||||||
category: if pull.closed {
|
category: if pull.closed {
|
||||||
Category::Closed
|
Category::Closed
|
||||||
|
} else if pull.draft {
|
||||||
|
Category::Drafts
|
||||||
} else {
|
} else {
|
||||||
Category::Open
|
Category::Open
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pull.created_at,
|
pull.created_at,
|
||||||
|
if pull.review_requested {
|
||||||
|
PullRequestReviewState::Requested
|
||||||
|
} else if pull.approved {
|
||||||
|
PullRequestReviewState::Approved
|
||||||
|
} else {
|
||||||
|
PullRequestReviewState::None
|
||||||
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|||||||
+24
@@ -58,6 +58,8 @@ pub struct Config {
|
|||||||
pub diff_view_mode: crate::diff::ViewMode,
|
pub diff_view_mode: crate::diff::ViewMode,
|
||||||
pub diff_split_orientation: crate::panes::SplitAxis,
|
pub diff_split_orientation: crate::panes::SplitAxis,
|
||||||
pub diff_follow_working_copy: bool,
|
pub diff_follow_working_copy: bool,
|
||||||
|
pub pull_request_review_requested_symbol: String,
|
||||||
|
pub pull_request_approved_symbol: String,
|
||||||
pub resolve_history_layout: crate::editor::ResolverLayout,
|
pub resolve_history_layout: crate::editor::ResolverLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +114,8 @@ impl Default for Config {
|
|||||||
diff_view_mode: crate::diff::ViewMode::Split,
|
diff_view_mode: crate::diff::ViewMode::Split,
|
||||||
diff_split_orientation: crate::panes::SplitAxis::Vertical,
|
diff_split_orientation: crate::panes::SplitAxis::Vertical,
|
||||||
diff_follow_working_copy: true,
|
diff_follow_working_copy: true,
|
||||||
|
pull_request_review_requested_symbol: "🙏".into(),
|
||||||
|
pull_request_approved_symbol: "👍".into(),
|
||||||
resolve_history_layout: crate::editor::ResolverLayout::Columns,
|
resolve_history_layout: crate::editor::ResolverLayout::Columns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,6 +157,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
diff_view_mode,
|
diff_view_mode,
|
||||||
diff_split_orientation,
|
diff_split_orientation,
|
||||||
diff_follow_working_copy,
|
diff_follow_working_copy,
|
||||||
|
pull_request_review_requested_symbol,
|
||||||
|
pull_request_approved_symbol,
|
||||||
resolve_history_layout,
|
resolve_history_layout,
|
||||||
..
|
..
|
||||||
} = *response;
|
} = *response;
|
||||||
@@ -195,6 +201,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
config.diff_view_mode = diff_view_mode;
|
config.diff_view_mode = diff_view_mode;
|
||||||
config.diff_split_orientation = diff_split_orientation;
|
config.diff_split_orientation = diff_split_orientation;
|
||||||
config.diff_follow_working_copy = diff_follow_working_copy;
|
config.diff_follow_working_copy = diff_follow_working_copy;
|
||||||
|
config.pull_request_review_requested_symbol = pull_request_review_requested_symbol;
|
||||||
|
config.pull_request_approved_symbol = pull_request_approved_symbol;
|
||||||
config.resolve_history_layout = resolve_history_layout;
|
config.resolve_history_layout = resolve_history_layout;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -556,9 +564,25 @@ struct ConfigFrame {
|
|||||||
diff_view_mode: crate::diff::ViewMode,
|
diff_view_mode: crate::diff::ViewMode,
|
||||||
diff_split_orientation: crate::panes::SplitAxis,
|
diff_split_orientation: crate::panes::SplitAxis,
|
||||||
diff_follow_working_copy: bool,
|
diff_follow_working_copy: bool,
|
||||||
|
#[serde(deserialize_with = "pull_request_symbol")]
|
||||||
|
pull_request_review_requested_symbol: String,
|
||||||
|
#[serde(deserialize_with = "pull_request_symbol")]
|
||||||
|
pull_request_approved_symbol: String,
|
||||||
resolve_history_layout: crate::editor::ResolverLayout,
|
resolve_history_layout: crate::editor::ResolverLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pull_request_symbol<'de, D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<String, D::Error> {
|
||||||
|
let symbol = String::deserialize(deserializer)?;
|
||||||
|
if symbol.chars().any(char::is_control) {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"pull request symbols must not contain control characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(symbol)
|
||||||
|
}
|
||||||
|
|
||||||
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
|
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
|
||||||
deserializer: D,
|
deserializer: D,
|
||||||
) -> Result<BTreeMap<char, char>, D::Error> {
|
) -> Result<BTreeMap<char, char>, D::Error> {
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ module Vamp
|
|||||||
@diff_view_mode = "split"
|
@diff_view_mode = "split"
|
||||||
@diff_split_orientation = "vertical"
|
@diff_split_orientation = "vertical"
|
||||||
@diff_follow_working_copy = true
|
@diff_follow_working_copy = true
|
||||||
|
@pull_request_review_requested_symbol = "🙏"
|
||||||
|
@pull_request_approved_symbol = "👍"
|
||||||
@resolve_history_layout = "columns"
|
@resolve_history_layout = "columns"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -327,6 +329,18 @@ module Vamp
|
|||||||
@diff_follow_working_copy = enabled
|
@diff_follow_working_copy = enabled
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param symbol [String] empty to hide the review-request indicator
|
||||||
|
# @return [String]
|
||||||
|
def pull_request_review_requested_symbol=(symbol)
|
||||||
|
@pull_request_review_requested_symbol = pull_request_symbol!(symbol, "pull_request_review_requested_symbol")
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param symbol [String] empty to hide the approval indicator
|
||||||
|
# @return [String]
|
||||||
|
def pull_request_approved_symbol=(symbol)
|
||||||
|
@pull_request_approved_symbol = pull_request_symbol!(symbol, "pull_request_approved_symbol")
|
||||||
|
end
|
||||||
|
|
||||||
# @param layout [String] "columns" or "base_below"
|
# @param layout [String] "columns" or "base_below"
|
||||||
# @return [String]
|
# @return [String]
|
||||||
def resolve_history_layout=(layout)
|
def resolve_history_layout=(layout)
|
||||||
@@ -382,10 +396,19 @@ module Vamp
|
|||||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
||||||
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation,
|
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation,
|
||||||
"diff_follow_working_copy" => @diff_follow_working_copy,
|
"diff_follow_working_copy" => @diff_follow_working_copy,
|
||||||
|
"pull_request_review_requested_symbol" => @pull_request_review_requested_symbol,
|
||||||
|
"pull_request_approved_symbol" => @pull_request_approved_symbol,
|
||||||
"resolve_history_layout" => @resolve_history_layout }
|
"resolve_history_layout" => @resolve_history_layout }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def pull_request_symbol!(symbol, label)
|
||||||
|
symbol = Vamp.string!(symbol, label)
|
||||||
|
raise ArgumentError, "#{label} must not contain control characters" if symbol.match?(/[[:cntrl:]]/)
|
||||||
|
|
||||||
|
symbol
|
||||||
|
end
|
||||||
# @param spaces [Integer, nil]
|
# @param spaces [Integer, nil]
|
||||||
# @return [Integer, nil]
|
# @return [Integer, nil]
|
||||||
def tab_spaces!(spaces)
|
def tab_spaces!(spaces)
|
||||||
|
|||||||
@@ -542,6 +542,8 @@ impl EditorSession {
|
|||||||
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
|
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
|
||||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||||
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
||||||
|
editor.pull_request_review_requested_symbol = config.pull_request_review_requested_symbol;
|
||||||
|
editor.pull_request_approved_symbol = config.pull_request_approved_symbol;
|
||||||
editor.resolve_history_layout = config.resolve_history_layout;
|
editor.resolve_history_layout = config.resolve_history_layout;
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels)
|
editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels)
|
||||||
|
|||||||
+31
-3
@@ -3536,6 +3536,18 @@ fn draw_diff(
|
|||||||
panels.picker_scroll.distance,
|
panels.picker_scroll.distance,
|
||||||
);
|
);
|
||||||
let mut lines = Vec::with_capacity(count * row_height);
|
let mut lines = Vec::with_capacity(count * row_height);
|
||||||
|
let (requested_symbol, approved_symbol) = editor.map_or(("🙏", "👍"), |editor| {
|
||||||
|
(
|
||||||
|
editor.pull_request_review_requested_symbol.as_str(),
|
||||||
|
editor.pull_request_approved_symbol.as_str(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let symbol_width = if pull_requests {
|
||||||
|
requested_symbol.width().max(approved_symbol.width())
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let decoration_width = symbol_width + usize::from(symbol_width > 0);
|
||||||
for (index, revision) in entries
|
for (index, revision) in entries
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.skip(first)
|
.skip(first)
|
||||||
@@ -3549,17 +3561,33 @@ fn draw_diff(
|
|||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
let symbol = if pull_requests {
|
||||||
|
match diff.pull_request_review_state(revision) {
|
||||||
|
crate::diff::PullRequestReviewState::Requested => requested_symbol,
|
||||||
|
crate::diff::PullRequestReviewState::Approved => approved_symbol,
|
||||||
|
crate::diff::PullRequestReviewState::None => "",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
let label = format!(
|
let label = format!(
|
||||||
"{} {}",
|
"{} {symbol}{:padding$}{}",
|
||||||
if index == diff.selected { ">" } else { " " },
|
if index == diff.selected { ">" } else { " " },
|
||||||
display_text(&revision.label)
|
"",
|
||||||
|
display_text(&revision.label),
|
||||||
|
padding = decoration_width.saturating_sub(symbol.width()),
|
||||||
);
|
);
|
||||||
let width = usize::from(results.width);
|
let width = usize::from(results.width);
|
||||||
if row_height == 2 {
|
if row_height == 2 {
|
||||||
lines.push(Line::styled(visible_line(&label, 0, width), style));
|
lines.push(Line::styled(visible_line(&label, 0, width), style));
|
||||||
lines.push(Line::styled(
|
lines.push(Line::styled(
|
||||||
visible_line(
|
visible_line(
|
||||||
&format!(" {}", display_text(&revision.description)),
|
&format!(
|
||||||
|
"{:indent$}{}",
|
||||||
|
"",
|
||||||
|
display_text(&revision.description),
|
||||||
|
indent = 2 + decoration_width,
|
||||||
|
),
|
||||||
0,
|
0,
|
||||||
width,
|
width,
|
||||||
),
|
),
|
||||||
|
|||||||
+126
-1
@@ -433,6 +433,7 @@ fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcom
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
date.into(),
|
date.into(),
|
||||||
|
PullRequestReviewState::None,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -440,12 +441,62 @@ fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_request_review_refresh_preserves_identity_sorting_and_raw_search_labels() {
|
||||||
|
let mut state = DiffState::pull_requests("origin".into());
|
||||||
|
state.take_request();
|
||||||
|
state.apply_result(
|
||||||
|
1,
|
||||||
|
Ok(pull_page(
|
||||||
|
false,
|
||||||
|
&[(1, "First", "1"), (2, "Second", "2")],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
state.handle(Input::End);
|
||||||
|
for review_state in [
|
||||||
|
PullRequestReviewState::Approved,
|
||||||
|
PullRequestReviewState::Requested,
|
||||||
|
PullRequestReviewState::None,
|
||||||
|
] {
|
||||||
|
let mut page = pull_page(false, &[(1, "First updated", "3")], true);
|
||||||
|
if let Outcome::PullRequestPage {
|
||||||
|
result: Ok(page), ..
|
||||||
|
} = &mut page
|
||||||
|
{
|
||||||
|
page.entries[0].2 = review_state;
|
||||||
|
}
|
||||||
|
state.apply_result(1, Ok(page));
|
||||||
|
let selected = state.entries().nth(state.selected).unwrap();
|
||||||
|
assert_eq!(selected.label, "#1 First updated");
|
||||||
|
assert_eq!(state.pull_request_review_state(selected), review_state);
|
||||||
|
assert_eq!(state.entries().count(), 2);
|
||||||
|
assert_eq!(state.entries().next().unwrap().target, selected.target);
|
||||||
|
}
|
||||||
|
state.handle(Input::Char('🙏'));
|
||||||
|
assert_eq!(state.entries().count(), 0);
|
||||||
|
state.handle(Input::Backspace);
|
||||||
|
for ch in "#1 First updated".chars() {
|
||||||
|
state.handle(Input::Char(ch));
|
||||||
|
}
|
||||||
|
assert_eq!(state.entries().count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
state.pull_request_review_state(&catalog().entries[0]),
|
||||||
|
PullRequestReviewState::None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() {
|
fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() {
|
||||||
let mut state = DiffState::pull_requests("origin".into());
|
let mut state = DiffState::pull_requests("origin".into());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.categories(),
|
state.categories(),
|
||||||
[Category::Open, Category::Closed, Category::All]
|
[
|
||||||
|
Category::Open,
|
||||||
|
Category::Closed,
|
||||||
|
Category::Drafts,
|
||||||
|
Category::All
|
||||||
|
]
|
||||||
);
|
);
|
||||||
state.take_request();
|
state.take_request();
|
||||||
state.handle(Input::Char('\t'));
|
state.handle(Input::Char('\t'));
|
||||||
@@ -464,6 +515,7 @@ fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion()
|
|||||||
assert!(state.pull_request_partial());
|
assert!(state.pull_request_partial());
|
||||||
assert_eq!(state.entries().next().unwrap().label, "#2 Fix Ünicode");
|
assert_eq!(state.entries().next().unwrap().label, "#2 Fix Ünicode");
|
||||||
state.handle(Input::Char('\t'));
|
state.handle(Input::Char('\t'));
|
||||||
|
state.handle(Input::Char('\t'));
|
||||||
state.apply_result(
|
state.apply_result(
|
||||||
1,
|
1,
|
||||||
Ok(pull_page(
|
Ok(pull_page(
|
||||||
@@ -498,6 +550,79 @@ fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion()
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_request_drafts_filter_independently_and_share_open_loading_and_retry() {
|
||||||
|
let mut state = DiffState::pull_requests("origin".into());
|
||||||
|
state.take_request();
|
||||||
|
let mut page = pull_page(
|
||||||
|
false,
|
||||||
|
&[
|
||||||
|
(1, "Ready", "2024-01-01T00:00:00Z"),
|
||||||
|
(2, "Draft Ünicode", "2024-01-02T00:00:00Z"),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if let Outcome::PullRequestPage {
|
||||||
|
result: Ok(page), ..
|
||||||
|
} = &mut page
|
||||||
|
{
|
||||||
|
page.entries[1].0.category = Category::Drafts;
|
||||||
|
}
|
||||||
|
state.apply_result(1, Ok(page));
|
||||||
|
state.apply_result(
|
||||||
|
1,
|
||||||
|
Ok(pull_page(
|
||||||
|
true,
|
||||||
|
&[(3, "Closed", "2024-01-03T00:00:00Z")],
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let labels = |state: &DiffState| {
|
||||||
|
state
|
||||||
|
.entries()
|
||||||
|
.map(|entry| entry.label.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(labels(&state), ["#1 Ready"]);
|
||||||
|
state.handle(Input::BackTab);
|
||||||
|
assert_eq!(
|
||||||
|
labels(&state),
|
||||||
|
["#3 Closed", "#2 Draft Ünicode", "#1 Ready"]
|
||||||
|
);
|
||||||
|
state.handle(Input::BackTab);
|
||||||
|
assert_eq!(labels(&state), ["#2 Draft Ünicode"]);
|
||||||
|
assert!(state.pull_request_partial());
|
||||||
|
state.apply_result(
|
||||||
|
1,
|
||||||
|
Ok(Outcome::PullRequestPage {
|
||||||
|
closed: false,
|
||||||
|
result: Err("open stream unavailable".into()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
state
|
||||||
|
.pull_request_notice()
|
||||||
|
.unwrap()
|
||||||
|
.contains("open stream unavailable")
|
||||||
|
);
|
||||||
|
state.handle(Input::Ctrl('r'));
|
||||||
|
assert_eq!(state.pull_request_retries, [1, 0]);
|
||||||
|
state.apply_result(1, Ok(pull_page(false, &[], false)));
|
||||||
|
assert!(!state.pull_request_partial());
|
||||||
|
for ch in "Ünicode Alice".chars() {
|
||||||
|
state.handle(Input::Char(ch));
|
||||||
|
}
|
||||||
|
assert_eq!(labels(&state), ["#2 Draft Ünicode"]);
|
||||||
|
state.handle(Input::Enter);
|
||||||
|
assert!(matches!(
|
||||||
|
state.take_request(),
|
||||||
|
Some(Operation::ResolvePullRequest(Revision {
|
||||||
|
target: RevisionTarget::PullRequest { number: 2, .. },
|
||||||
|
..
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_request_all_streams_sort_deduplicate_and_preserve_selection_identity() {
|
fn pull_request_all_streams_sort_deduplicate_and_preserve_selection_identity() {
|
||||||
let mut state = DiffState::pull_requests("origin".into());
|
let mut state = DiffState::pull_requests("origin".into());
|
||||||
|
|||||||
+640
-9
@@ -7,6 +7,623 @@ fn parse(url: &str, provider: Option<&str>) -> Result<Forge, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_personal_approvals_use_the_viewer_and_last_decisive_review() {
|
||||||
|
let forge = parse("https://github.com/owner/repo", None).unwrap();
|
||||||
|
let values: Vec<_> = (1..=8).map(|id| json!({"number": id})).collect();
|
||||||
|
let metadata = forge.metadata_with(&values, &mut |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
assert_eq!(url, "https://api.github.com/user");
|
||||||
|
return Ok(json!({"login": "viewer"}));
|
||||||
|
}
|
||||||
|
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
|
||||||
|
let query = body["query"].as_str().unwrap();
|
||||||
|
assert!(query.contains("author:\"viewer\""));
|
||||||
|
assert!(query.contains("states:[APPROVED,CHANGES_REQUESTED,DISMISSED],last:1"));
|
||||||
|
assert!(!query.contains("mutation"));
|
||||||
|
Ok(json!({"data":{"repository":{
|
||||||
|
"p1":{"additions":3,"deletions":4,"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"VIEWER"}}]}},
|
||||||
|
"p2":{"reviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"viewer"}}]}},
|
||||||
|
"p3":{"reviews":{"nodes":[{"state":"DISMISSED","author":{"login":"viewer"}}]}},
|
||||||
|
"p4":{"reviews":null},
|
||||||
|
"p5":{"reviews":{"nodes":[{"state":"COMMENTED","author":{"login":"viewer"}}]}},
|
||||||
|
"p6":{"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"someone-else"}}]}},
|
||||||
|
"p7":{"reviews":{"nodes":[{"state":"PENDING","author":{"login":"viewer"}}]}},
|
||||||
|
"p8":{"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}
|
||||||
|
}}}))
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
metadata
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, details)| details.approved.then_some(*id))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[1, 8]
|
||||||
|
);
|
||||||
|
assert_eq!(metadata[&1].size, Some((3, 4)));
|
||||||
|
assert_eq!(metadata[&8].size, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitlab_personal_approvals_filter_by_viewer_and_follow_connection_pages() {
|
||||||
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
||||||
|
let values = [json!({"iid":7}), json!({"iid":8}), json!({"iid":9})];
|
||||||
|
let metadata = forge.metadata_with(&values, &mut |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
assert_eq!(url, "https://gitlab.com/api/v4/user");
|
||||||
|
return Ok(json!({"username":"viewer"}));
|
||||||
|
}
|
||||||
|
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
|
||||||
|
let query = body["query"].as_str().unwrap();
|
||||||
|
assert!(query.contains("approvedBy:[\"viewer\"]"));
|
||||||
|
assert!(query.contains("iids:[\"7\",\"8\",\"9\"]"));
|
||||||
|
assert!(!query.contains("mutation"));
|
||||||
|
if query.contains("after:\"next-page\"") {
|
||||||
|
return Ok(json!({"data":{"project":{"approved":{
|
||||||
|
"nodes":[{"iid":"8"}],
|
||||||
|
"pageInfo":{"hasNextPage":false,"endCursor":null}
|
||||||
|
}}}}));
|
||||||
|
}
|
||||||
|
Ok(json!({"data":{"project":{
|
||||||
|
"p7":{"diffStatsSummary":{"additions":5,"deletions":6}},
|
||||||
|
"p8":{"diffStatsSummary":null},
|
||||||
|
"p9":{"diffStatsSummary":{"additions":0,"deletions":0}},
|
||||||
|
"approved":{
|
||||||
|
"nodes":[{"iid":"7"},{"iid":"999"}],
|
||||||
|
"pageInfo":{"hasNextPage":true,"endCursor":"next-page"}
|
||||||
|
}
|
||||||
|
}}}))
|
||||||
|
});
|
||||||
|
assert!(metadata[&7].approved && metadata[&8].approved);
|
||||||
|
assert!(!metadata[&9].approved);
|
||||||
|
assert!(!metadata.contains_key(&999));
|
||||||
|
assert_eq!(metadata[&7].size, Some((5, 6)));
|
||||||
|
assert_eq!(metadata[&8].size, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_personal_approvals_ignore_other_users_and_honor_later_decisions_and_failures() {
|
||||||
|
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
|
||||||
|
let own = |id, state: &str, dismissed| json!({"id":id,"user":{"id":42},"state":state,"dismissed":dismissed});
|
||||||
|
let approved = own(10, "APPROVED", false);
|
||||||
|
for (pages, expected) in [
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
Ok(json!([approved.clone()])),
|
||||||
|
Ok(json!([
|
||||||
|
own(30, "COMMENT", false),
|
||||||
|
own(31, "PENDING", false),
|
||||||
|
own(32, "REQUEST_REVIEW", false)
|
||||||
|
])),
|
||||||
|
Ok(json!([])),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
Ok(json!([approved.clone()])),
|
||||||
|
Ok(json!([own(20, "REQUEST_CHANGES", false)])),
|
||||||
|
Ok(json!([])),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
Ok(json!([approved.clone()])),
|
||||||
|
Ok(json!([own(20, "APPROVED", true)])),
|
||||||
|
Ok(json!([])),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
Ok(json!([{"id":10,"user":{"id":7},"state":"APPROVED","dismissed":false}])),
|
||||||
|
Ok(json!([])),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
Ok(json!([approved.clone()])),
|
||||||
|
Err("later review page failed".into()),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![Ok(json!([{"id":10,"user":{"id":42},"state":"APPROVED"}]))],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// Select by review identity, not response order; a later approval replaces a rejection.
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
Ok(json!([
|
||||||
|
own(20, "APPROVED", false),
|
||||||
|
own(10, "REQUEST_CHANGES", false)
|
||||||
|
])),
|
||||||
|
Ok(json!([])),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut pages = pages.into_iter();
|
||||||
|
let metadata = forge.metadata_with(
|
||||||
|
&[json!({"number":7,"additions":1,"deletions":2})],
|
||||||
|
&mut |url, body| {
|
||||||
|
assert!(body.is_none());
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
assert_eq!(url, "https://codeberg.org/api/v1/user");
|
||||||
|
return Ok(json!({"id":42}));
|
||||||
|
}
|
||||||
|
assert!(url.starts_with(
|
||||||
|
"https://codeberg.org/api/v1/repos/owner/repo/pulls/7/reviews?limit=50&page="
|
||||||
|
));
|
||||||
|
pages.next().expect("unexpected review page")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(metadata[&7].approved, expected);
|
||||||
|
assert_eq!(metadata[&7].size, Some((1, 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unavailable_viewer_or_metadata_never_fabricates_personal_approval() {
|
||||||
|
for (url, provider) in [
|
||||||
|
("https://github.com/owner/repo", Provider::Github),
|
||||||
|
("https://gitlab.com/group/repo", Provider::Gitlab),
|
||||||
|
("https://codeberg.org/owner/repo", Provider::Gitea),
|
||||||
|
] {
|
||||||
|
let forge = parse(url, None).unwrap();
|
||||||
|
for unavailable_viewer in [true, false] {
|
||||||
|
let metadata = forge.metadata_with(
|
||||||
|
&[json!({"number":7,"iid":7,"additions":0,"deletions":0,
|
||||||
|
"requested_reviewers":[{"login":"viewer","id":42}],
|
||||||
|
"reviewers":[{"username":"viewer"}]})],
|
||||||
|
&mut |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return if unavailable_viewer {
|
||||||
|
Err("no API credentials".into())
|
||||||
|
} else {
|
||||||
|
Ok(json!({"login":"viewer","username":"viewer","id":42}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if provider == Provider::Gitea {
|
||||||
|
assert!(
|
||||||
|
!unavailable_viewer,
|
||||||
|
"anonymous listings must not fetch reviews"
|
||||||
|
);
|
||||||
|
assert!(body.is_none());
|
||||||
|
}
|
||||||
|
Err("metadata denied".into())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(metadata.values().all(|details| !details.approved));
|
||||||
|
assert!(metadata.values().all(|details| details.review_requested
|
||||||
|
== (provider == Provider::Github && !unavailable_viewer)));
|
||||||
|
if provider == Provider::Gitea {
|
||||||
|
assert_eq!(metadata[&7].size, Some((0, 0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_outstanding_requests_use_list_users_independently_of_review_metadata() {
|
||||||
|
let forge = parse("https://github.com/owner/repo", None).unwrap();
|
||||||
|
for (reviewers, expected) in [
|
||||||
|
(json!([{"login":"VIEWER"}]), true), // Direct or re-requested after approval.
|
||||||
|
(json!([]), false), // Completed or removed requests are absent.
|
||||||
|
(json!([{"login":"another-user"}]), false),
|
||||||
|
(Value::Null, false),
|
||||||
|
] {
|
||||||
|
for denied in [false, true] {
|
||||||
|
let metadata = forge.metadata_with(
|
||||||
|
&[json!({"number":7,"requested_reviewers":reviewers,
|
||||||
|
"requested_teams":[{"slug":"viewer"}],"additions":3,"deletions":4})],
|
||||||
|
&mut |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Ok(json!({"login":"viewer"}));
|
||||||
|
}
|
||||||
|
assert!(body.is_some());
|
||||||
|
if denied {
|
||||||
|
return Err("GraphQL denied".into());
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
json!({"data":{"repository":{"p7":{"additions":3,"deletions":4,
|
||||||
|
"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}}}}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(metadata[&7].review_requested, expected);
|
||||||
|
assert_eq!(metadata[&7].approved, !denied);
|
||||||
|
assert_eq!(metadata[&7].size, Some((3, 4)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitlab_outstanding_requests_require_personal_unfinished_review_and_follow_pages() {
|
||||||
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
||||||
|
let mut values: Vec<_> = (1..=10)
|
||||||
|
.map(|id| json!({"iid":id,"reviewers":[{"username":"viewer"}]}))
|
||||||
|
.collect();
|
||||||
|
values[8]["reviewers"] = json!([]); // Removed reviewer.
|
||||||
|
values[9]["reviewers"] = json!([{"username":"another-user"}]);
|
||||||
|
let mut request_reads = 0;
|
||||||
|
let metadata = forge.metadata_with(&values, &mut |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Ok(json!({"username":"viewer"}));
|
||||||
|
}
|
||||||
|
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
|
||||||
|
let query = body["query"].as_str().unwrap();
|
||||||
|
assert!(!query.contains("mutation"));
|
||||||
|
if !query.contains("reviewState") {
|
||||||
|
return Ok(json!({"data":{"project":{
|
||||||
|
"p1":{"diffStatsSummary":{"additions":5,"deletions":6}},
|
||||||
|
"approved":{"nodes":[{"iid":"1"},{"iid":"4"}],"pageInfo":{"hasNextPage":false}}
|
||||||
|
}}}));
|
||||||
|
}
|
||||||
|
request_reads += 1;
|
||||||
|
assert!(query.contains("reviewers(first:20"));
|
||||||
|
assert!(!query.contains("p9:") && !query.contains("p10:"));
|
||||||
|
if query.contains("after:\"next\"") {
|
||||||
|
return Ok(json!({"data":{"project":{"p2":{"reviewers":{
|
||||||
|
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"REVIEW_STARTED"}}],
|
||||||
|
"pageInfo":{"hasNextPage":false}
|
||||||
|
}}}}}));
|
||||||
|
}
|
||||||
|
let mut entries = serde_json::Map::new();
|
||||||
|
for (id, state) in [(1, "UNREVIEWED"), (3, "REVIEWED"), (4, "APPROVED"),
|
||||||
|
(5, "REQUESTED_CHANGES"), (6, "UNAPPROVED"), (7, ""), (8, "UNKNOWN")] {
|
||||||
|
entries.insert(format!("p{id}"), json!({"reviewers":{
|
||||||
|
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":state}}],
|
||||||
|
"pageInfo":{"hasNextPage":false}
|
||||||
|
}}));
|
||||||
|
}
|
||||||
|
entries.insert("p2".into(), json!({"reviewers":{
|
||||||
|
"nodes":[{"username":"another-user","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
|
||||||
|
"pageInfo":{"hasNextPage":true,"endCursor":"next"}
|
||||||
|
}}));
|
||||||
|
Ok(json!({"data":{"project":entries}}))
|
||||||
|
});
|
||||||
|
assert_eq!(request_reads, 2);
|
||||||
|
assert_eq!(
|
||||||
|
metadata
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, details)| details.review_requested.then_some(*id))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[1, 2]
|
||||||
|
);
|
||||||
|
assert!(metadata[&1].approved && metadata[&4].approved);
|
||||||
|
assert_eq!(metadata[&1].size, Some((5, 6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitlab_request_failures_preserve_approval_and_statistics() {
|
||||||
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
||||||
|
for denied in [
|
||||||
|
Err("review states denied".into()),
|
||||||
|
Ok(json!({"data":{"project":{
|
||||||
|
"p7":{"reviewers":null}
|
||||||
|
}}})),
|
||||||
|
Ok(json!({"data":{"project":{"p7":{"reviewers":{"nodes":[
|
||||||
|
{"username":"viewer","mergeRequestInteraction":{"reviewState":null}}
|
||||||
|
]}}}}})),
|
||||||
|
] {
|
||||||
|
let metadata = forge.metadata_with(
|
||||||
|
&[json!({"iid":7,"reviewers":[{"username":"viewer"}]})],
|
||||||
|
&mut |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Ok(json!({"username":"viewer"}));
|
||||||
|
}
|
||||||
|
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
|
||||||
|
if body["query"].as_str().unwrap().contains("reviewState") {
|
||||||
|
return denied.clone();
|
||||||
|
}
|
||||||
|
Ok(json!({"data":{"project":{
|
||||||
|
"p7":{"diffStatsSummary":{"additions":0,"deletions":1}},
|
||||||
|
"approved":{"nodes":[{"iid":"7"}],"pageInfo":{"hasNextPage":false}}
|
||||||
|
}}}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!metadata[&7].review_requested);
|
||||||
|
assert!(metadata[&7].approved);
|
||||||
|
assert_eq!(metadata[&7].size, Some((0, 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitlab_request_batches_cover_fifty_rows_without_exceeding_complexity_limit() {
|
||||||
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
||||||
|
let values: Vec<_> = (1..=50)
|
||||||
|
.map(|id| {
|
||||||
|
json!({
|
||||||
|
"iid":id,"reviewers":[{"username":"viewer"}]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut batches = 0;
|
||||||
|
let metadata = forge.metadata_with(&values, &mut |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Ok(json!({"username":"viewer"}));
|
||||||
|
}
|
||||||
|
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
|
||||||
|
let query = body["query"].as_str().unwrap();
|
||||||
|
if !query.contains("reviewState") {
|
||||||
|
return Err("independent metadata unavailable".into());
|
||||||
|
}
|
||||||
|
batches += 1;
|
||||||
|
assert_eq!(query.matches("reviewers(first:20)").count(), 10);
|
||||||
|
let entries: serde_json::Map<_, _> = (1..=50)
|
||||||
|
.filter(|id| query.contains(&format!("p{id}:mergeRequest")))
|
||||||
|
.map(|id| (format!("p{id}"), json!({"reviewers":{
|
||||||
|
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
|
||||||
|
"pageInfo":{"hasNextPage":false}
|
||||||
|
}}))).collect();
|
||||||
|
Ok(json!({"data":{"project":entries}}))
|
||||||
|
});
|
||||||
|
assert_eq!(batches, 5);
|
||||||
|
assert_eq!(metadata.len(), 50);
|
||||||
|
assert!(metadata.values().all(|details| details.review_requested));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_requests_ignore_list_assignments_and_use_current_direct_review_rows() {
|
||||||
|
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
|
||||||
|
let own = |id, state: &str| {
|
||||||
|
json!({
|
||||||
|
"id":id,"user":{"id":42},"team":null,"state":state,"dismissed":false
|
||||||
|
})
|
||||||
|
};
|
||||||
|
for (reviews, requested, approved) in [
|
||||||
|
(json!([own(10, "REQUEST_REVIEW")]), true, false),
|
||||||
|
(json!([own(10, "APPROVED")]), false, true), // Completed or removed request row.
|
||||||
|
(json!([own(10, "COMMENT")]), false, false),
|
||||||
|
(json!([own(10, "REQUEST_CHANGES")]), false, false),
|
||||||
|
(
|
||||||
|
json!([own(10, "APPROVED"), own(20, "REQUEST_REVIEW")]),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!([
|
||||||
|
own(20, "REQUEST_REVIEW"),
|
||||||
|
own(10, "APPROVED"),
|
||||||
|
own(30, "PENDING")
|
||||||
|
]),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!([own(10, "REQUEST_REVIEW"), own(20, "COMMENT")]),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!([own(10, "REQUEST_REVIEW"), own(20, "APPROVED")]),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!([{"id":10,"user":{"id":7},"state":"REQUEST_REVIEW","dismissed":false}]),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!([{"id":10,"user":{"id":42},"team":{"id":1},"state":"REQUEST_REVIEW","dismissed":false}]),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!([{"id":20,"user":{"id":42},"state":"REQUEST_REVIEW"},
|
||||||
|
own(10, "APPROVED")]),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(json!([]), false, false),
|
||||||
|
] {
|
||||||
|
let mut pages = vec![reviews, json!([])].into_iter();
|
||||||
|
let metadata = forge.metadata_with(
|
||||||
|
&[json!({"number":7,"additions":1,"deletions":2,"requested_reviewers":[{"id":42}]})],
|
||||||
|
&mut |url, body| {
|
||||||
|
assert!(body.is_none());
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Ok(json!({"id":42}));
|
||||||
|
}
|
||||||
|
pages.next().ok_or_else(|| "unexpected page".into())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(metadata[&7].review_requested, requested);
|
||||||
|
assert_eq!(metadata[&7].approved, approved);
|
||||||
|
assert_eq!(metadata[&7].size, Some((1, 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_incomplete_request_pages_do_not_fabricate_requests_or_lose_list_statistics() {
|
||||||
|
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
|
||||||
|
let metadata = forge.metadata_with(
|
||||||
|
&[json!({"number":7,"additions":0,"deletions":2})],
|
||||||
|
&mut |url, _| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Ok(json!({"id":42}));
|
||||||
|
}
|
||||||
|
if url.ends_with("page=1") {
|
||||||
|
return Ok(json!([{"id":10,"user":{"id":42},"team":null,
|
||||||
|
"state":"REQUEST_REVIEW","dismissed":false}]));
|
||||||
|
}
|
||||||
|
Err("remaining reviews denied".into())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(!metadata[&7].review_requested);
|
||||||
|
assert_eq!(metadata[&7].size, Some((0, 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summaries_keep_outstanding_requests_and_approvals_independent() {
|
||||||
|
for (url, provider) in [
|
||||||
|
("https://github.com/owner/repo", Provider::Github),
|
||||||
|
("https://gitlab.com/owner/repo", Provider::Gitlab),
|
||||||
|
("https://codeberg.org/owner/repo", Provider::Gitea),
|
||||||
|
] {
|
||||||
|
let forge = parse(url, None).unwrap();
|
||||||
|
let page = forge.page_with(false, 1, |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Ok(json!({"id":42,"login":"viewer","username":"viewer"}));
|
||||||
|
}
|
||||||
|
if let Some(body) = body {
|
||||||
|
let body: Value = serde_json::from_str(body).unwrap();
|
||||||
|
if body["query"].as_str().unwrap().contains("reviewState") {
|
||||||
|
return Ok(json!({"data":{"project":{"p7":{"reviewers":{
|
||||||
|
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
|
||||||
|
"pageInfo":{"hasNextPage":false}
|
||||||
|
}}}}}));
|
||||||
|
}
|
||||||
|
return if provider == Provider::Github {
|
||||||
|
Ok(json!({"data":{"repository":{"p7":{"additions":1,"deletions":2,
|
||||||
|
"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}}}}))
|
||||||
|
} else {
|
||||||
|
Ok(json!({"data":{"project":{
|
||||||
|
"p7":{"diffStatsSummary":{"additions":1,"deletions":2}},
|
||||||
|
"approved":{"nodes":[{"iid":"7"}],"pageInfo":{"hasNextPage":false}}
|
||||||
|
}}}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if url.contains("/7/reviews?") {
|
||||||
|
return Ok(if url.ends_with("page=1") {
|
||||||
|
json!([
|
||||||
|
{"id":10,"user":{"id":42},"state":"APPROVED","dismissed":false},
|
||||||
|
{"id":20,"user":{"id":42},"state":"REQUEST_REVIEW","dismissed":false}
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
json!([])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(json!([{"number":7,"iid":7,"title":"Request again","created_at":"2024-01-01T00:00:00Z",
|
||||||
|
"state":if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||||
|
"merged":false,"merged_at":null,"user":{"login":"author"},"author":{"username":"author"},
|
||||||
|
"requested_reviewers":[{"login":"viewer","id":42}],"reviewers":[{"username":"viewer"}],
|
||||||
|
"additions":1,"deletions":2}]))
|
||||||
|
}).unwrap();
|
||||||
|
assert_eq!(page.entries.len(), 1);
|
||||||
|
assert!(page.entries[0].approved && page.entries[0].review_requested);
|
||||||
|
assert!(page.entries[0].description.contains("+1 -2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_summaries_keep_open_closed_and_merged_states_distinct() {
|
||||||
|
for provider in [Provider::Github, Provider::Gitlab, Provider::Gitea] {
|
||||||
|
for (closed, merged, expected) in [
|
||||||
|
(false, false, "draft"),
|
||||||
|
(true, false, "closed"),
|
||||||
|
(true, true, "merged"),
|
||||||
|
] {
|
||||||
|
let mut value = json!({
|
||||||
|
"number": 7, "iid": 7, "title": "Unfinished work", "draft": true,
|
||||||
|
"state": if merged && provider == Provider::Gitlab { "merged" }
|
||||||
|
else if closed { "closed" }
|
||||||
|
else if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||||
|
"merged": merged,
|
||||||
|
"merged_at": if merged { Some("2024-01-02T00:00:00Z") } else { None },
|
||||||
|
"user": null, "author": null, "created_at": "2024-01-01T00:00:00Z"
|
||||||
|
});
|
||||||
|
let entry = summary(provider, &value, Some((1, 2))).unwrap();
|
||||||
|
assert!(entry.draft);
|
||||||
|
assert_eq!(entry.closed, closed);
|
||||||
|
assert_eq!(
|
||||||
|
entry.description,
|
||||||
|
format!("deleted user · {expected} · +1 -2")
|
||||||
|
);
|
||||||
|
if !closed {
|
||||||
|
value["draft"] = json!(false);
|
||||||
|
assert!(!summary(provider, &value, None).unwrap().draft);
|
||||||
|
value.as_object_mut().unwrap().remove("draft");
|
||||||
|
assert!(!summary(provider, &value, None).unwrap().draft);
|
||||||
|
value["draft"] = json!("true");
|
||||||
|
assert!(summary(provider, &value, None).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_request_sizes_include_zero_and_preserve_unknown_statistics() {
|
||||||
|
for (url, provider) in [
|
||||||
|
("https://github.com/owner/repo", Provider::Github),
|
||||||
|
("https://github.enterprise/owner/repo", Provider::Github),
|
||||||
|
("https://gitlab.com/group/repo", Provider::Gitlab),
|
||||||
|
("https://codeberg.org/owner/repo", Provider::Gitea),
|
||||||
|
] {
|
||||||
|
let forge = parse(
|
||||||
|
url,
|
||||||
|
Some(match provider {
|
||||||
|
Provider::Github => "github",
|
||||||
|
Provider::Gitlab => "gitlab",
|
||||||
|
Provider::Gitea => "gitea",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let page = forge
|
||||||
|
.page_with(false, 1, |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Err("unauthenticated fixture".into());
|
||||||
|
}
|
||||||
|
if let Some(body) = body {
|
||||||
|
let query: Value = serde_json::from_str(body).unwrap();
|
||||||
|
let query = query["query"].as_str().unwrap();
|
||||||
|
assert!(query.starts_with("query{"));
|
||||||
|
assert!(!query.contains("mutation"));
|
||||||
|
if provider == Provider::Github {
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
if forge.host == "github.com" {
|
||||||
|
"https://api.github.com/graphql"
|
||||||
|
} else {
|
||||||
|
"https://github.enterprise/api/graphql"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(query.contains("p7:pullRequest(number:7)"));
|
||||||
|
return Ok(json!({"data":{"repository":{
|
||||||
|
"p7":{"additions":123,"deletions":45},
|
||||||
|
"p8":{"additions":0,"deletions":0},
|
||||||
|
"p9":null
|
||||||
|
}}}));
|
||||||
|
}
|
||||||
|
assert_eq!(url, "https://gitlab.com/api/graphql");
|
||||||
|
assert!(query.contains("p7:mergeRequest(iid:\"7\")"));
|
||||||
|
return Ok(json!({"data":{"project":{
|
||||||
|
"p7":{"diffStatsSummary":{"additions":123,"deletions":45}},
|
||||||
|
"p8":{"diffStatsSummary":{"additions":0,"deletions":0}},
|
||||||
|
"p9":{"diffStatsSummary":null}
|
||||||
|
}}}));
|
||||||
|
}
|
||||||
|
Ok(Value::Array((7..=9).map(|id| json!({
|
||||||
|
"merged_at":null, "merged":false,
|
||||||
|
"number":id, "iid":id, "title":"Sized PR",
|
||||||
|
"state":if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||||
|
"user":{"login":"author"}, "author":{"username":"author"},
|
||||||
|
"created_at":"2024-01-01T00:00:00Z",
|
||||||
|
"base":{"ref":"main"}, "head":{"ref":"topic"},
|
||||||
|
"target_branch":"main", "source_branch":"topic",
|
||||||
|
"additions":match id { 7 => Some(123), 8 => Some(0), _ => None },
|
||||||
|
"deletions":match id { 7 => Some(45), 8 => Some(0), _ => None }
|
||||||
|
})).collect()))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let description = |id| {
|
||||||
|
&page
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.number == id)
|
||||||
|
.unwrap()
|
||||||
|
.description
|
||||||
|
};
|
||||||
|
assert!(description(7).contains("open · +123 -45 · topic → main"));
|
||||||
|
assert!(description(8).contains("open · +0 -0 · topic → main"));
|
||||||
|
assert!(description(9).contains("size unavailable"));
|
||||||
|
assert!(!description(9).contains("+0 -0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
||||||
for (provider, url, query) in [
|
for (provider, url, query) in [
|
||||||
@@ -27,9 +644,13 @@ fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
|||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let forge = parse(url, None).unwrap();
|
let forge = parse(url, None).unwrap();
|
||||||
let mut calls = 0;
|
let page = forge.page_with(false, 3, |url, body| {
|
||||||
let page = forge.page_with(false, 3, |url| {
|
if url.ends_with("/user") {
|
||||||
calls += 1;
|
return Err("unauthenticated fixture".into());
|
||||||
|
}
|
||||||
|
if body.is_some() {
|
||||||
|
return Err("statistics unavailable".into());
|
||||||
|
}
|
||||||
assert_eq!(url, format!("{}?{query}", forge.endpoint));
|
assert_eq!(url, format!("{}?{query}", forge.endpoint));
|
||||||
Ok(json!([
|
Ok(json!([
|
||||||
{"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
{"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||||
@@ -39,22 +660,20 @@ fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
|||||||
"created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
|
"created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
|
||||||
]))
|
]))
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
assert_eq!(calls, 1);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
page.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
||||||
[2, 1]
|
[2, 1]
|
||||||
);
|
);
|
||||||
assert_eq!(page.entries[0].description, "author · open");
|
|
||||||
assert!(page.entries[1].description.contains("deleted user"));
|
assert!(page.entries[1].description.contains("deleted user"));
|
||||||
assert!(
|
assert!(
|
||||||
page.has_more,
|
page.has_more,
|
||||||
"A short, server-capped page is not exhaustion"
|
"A short, server-capped page is not exhaustion"
|
||||||
);
|
);
|
||||||
let empty = forge.page_with(false, 4, |_| Ok(json!([]))).unwrap();
|
let empty = forge.page_with(false, 4, |_, _| Ok(json!([]))).unwrap();
|
||||||
assert!(empty.entries.is_empty() && !empty.has_more);
|
assert!(empty.entries.is_empty() && !empty.has_more);
|
||||||
assert!(
|
assert!(
|
||||||
forge
|
forge
|
||||||
.page_with(false, 0, |_| panic!("Page zero must not request"))
|
.page_with(false, 0, |_, _| panic!("Page zero must not request"))
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,7 +686,13 @@ fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
|
|||||||
for page in 1..=3 {
|
for page in 1..=3 {
|
||||||
let mut states = Vec::new();
|
let mut states = Vec::new();
|
||||||
let result = forge
|
let result = forge
|
||||||
.page_with(true, page, |url| {
|
.page_with(true, page, |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Err("unauthenticated fixture".into());
|
||||||
|
}
|
||||||
|
if body.is_some() {
|
||||||
|
return Err("statistics unavailable".into());
|
||||||
|
}
|
||||||
assert!(url.ends_with(&format!("&page={page}")));
|
assert!(url.ends_with(&format!("&page={page}")));
|
||||||
let state = if url.contains("?state=closed&") {
|
let state = if url.contains("?state=closed&") {
|
||||||
"closed"
|
"closed"
|
||||||
@@ -100,7 +725,13 @@ fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
|
|||||||
}
|
}
|
||||||
all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at));
|
all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
assert_eq!(all.iter().map(|p| p.number).collect::<Vec<_>>(), [3, 2, 1]);
|
assert_eq!(all.iter().map(|p| p.number).collect::<Vec<_>>(), [3, 2, 1]);
|
||||||
let raced = forge.page_with(true, 1, |url| {
|
let raced = forge.page_with(true, 1, |url, body| {
|
||||||
|
if url.ends_with("/user") {
|
||||||
|
return Err("unauthenticated fixture".into());
|
||||||
|
}
|
||||||
|
if body.is_some() {
|
||||||
|
return Err("statistics unavailable".into());
|
||||||
|
}
|
||||||
Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" },
|
Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" },
|
||||||
"author": null, "created_at": "2024-01-01T00:00:00Z"}]))
|
"author": null, "created_at": "2024-01-01T00:00:00Z"}]))
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|||||||
+83
-4
@@ -106,7 +106,7 @@ fn review_directory_resolves_config_paths_and_keeps_session_startup_lazy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn review_directory_wire_field_is_required_nullable_and_validated() {
|
fn review_directory_and_review_symbol_wire_fields_are_required_and_validated() {
|
||||||
let mut frame = serde_json::json!({
|
let mut frame = serde_json::json!({
|
||||||
"version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [],
|
"version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [],
|
||||||
"review_directory": null, "leader": "space",
|
"review_directory": null, "leader": "space",
|
||||||
@@ -119,6 +119,7 @@ fn review_directory_wire_field_is_required_nullable_and_validated() {
|
|||||||
"diff_context_lines": 10, "diff_expand_lines": 15,
|
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
"resolve_history_layout": "columns",
|
"resolve_history_layout": "columns",
|
||||||
|
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
|
||||||
"diff_follow_working_copy": true
|
"diff_follow_working_copy": true
|
||||||
});
|
});
|
||||||
for value in [serde_json::Value::Null, serde_json::json!("reviews")] {
|
for value in [serde_json::Value::Null, serde_json::json!("reviews")] {
|
||||||
@@ -136,10 +137,83 @@ fn review_directory_wire_field_is_required_nullable_and_validated() {
|
|||||||
frame["review_directory"] = value;
|
frame["review_directory"] = value;
|
||||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||||
}
|
}
|
||||||
|
frame["review_directory"] = serde_json::Value::Null;
|
||||||
|
for field in [
|
||||||
|
"pull_request_review_requested_symbol",
|
||||||
|
"pull_request_approved_symbol",
|
||||||
|
] {
|
||||||
|
for value in ["", "👩🚀 R", "ok"] {
|
||||||
|
frame[field] = serde_json::json!(value);
|
||||||
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
serde_json::json!(null),
|
||||||
|
serde_json::json!(false),
|
||||||
|
serde_json::json!(7),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!("\0"),
|
||||||
|
serde_json::json!("\n"),
|
||||||
|
serde_json::json!("\t"),
|
||||||
|
serde_json::json!("\u{7f}"),
|
||||||
|
serde_json::json!("\u{85}"),
|
||||||
|
] {
|
||||||
|
frame[field] = value;
|
||||||
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||||
|
}
|
||||||
|
frame.as_object_mut().unwrap().remove(field);
|
||||||
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||||
|
frame[field] = serde_json::json!("");
|
||||||
|
}
|
||||||
frame.as_object_mut().unwrap().remove("review_directory");
|
frame.as_object_mut().unwrap().remove("review_directory");
|
||||||
assert!(serde_json::from_value::<Response>(frame).is_err());
|
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_request_symbols_load_defaults_custom_and_empty_and_reject_invalid_ruby_values() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
|
||||||
|
let config = load_config(&path).unwrap();
|
||||||
|
assert_eq!(config.pull_request_review_requested_symbol, "🙏");
|
||||||
|
assert_eq!(config.pull_request_approved_symbol, "👍");
|
||||||
|
for symbol in ["", "👩🚀 R", "ok"] {
|
||||||
|
let value = serde_json::to_string(symbol).unwrap();
|
||||||
|
let path = fixture.file("config.rb", &format!(
|
||||||
|
"Vamp.configure {{ |c| c.pull_request_review_requested_symbol = {value}; c.pull_request_approved_symbol = {value} }}"
|
||||||
|
));
|
||||||
|
let config = load_config(&path).unwrap();
|
||||||
|
assert_eq!(config.pull_request_review_requested_symbol, symbol);
|
||||||
|
assert_eq!(config.pull_request_approved_symbol, symbol);
|
||||||
|
}
|
||||||
|
for field in [
|
||||||
|
"pull_request_review_requested_symbol",
|
||||||
|
"pull_request_approved_symbol",
|
||||||
|
] {
|
||||||
|
for value in [
|
||||||
|
"nil",
|
||||||
|
"false",
|
||||||
|
"7",
|
||||||
|
"[]",
|
||||||
|
"{}",
|
||||||
|
":ok",
|
||||||
|
r#""\n""#,
|
||||||
|
r#""\t""#,
|
||||||
|
r#""\0""#,
|
||||||
|
r#""\u007f""#,
|
||||||
|
r#""\u0085""#,
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |c| c.{field} = {value} }}"),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
load_config(&path).unwrap_err().contains(field),
|
||||||
|
"{field}: {value}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
|
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -544,9 +618,9 @@ fn config_leader_defaults_custom_keys_validation_and_required_wire_field() {
|
|||||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||||
}
|
}
|
||||||
for frame in [
|
for frame in [
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
|
||||||
] {
|
] {
|
||||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||||
}
|
}
|
||||||
@@ -632,6 +706,7 @@ end
|
|||||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
"resolve_history_layout": "columns",
|
"resolve_history_layout": "columns",
|
||||||
|
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
|
||||||
"diff_follow_working_copy": true
|
"diff_follow_working_copy": true
|
||||||
});
|
});
|
||||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||||||
@@ -773,6 +848,7 @@ end"#,
|
|||||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
"resolve_history_layout": "columns",
|
"resolve_history_layout": "columns",
|
||||||
|
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
|
||||||
"diff_follow_working_copy": true
|
"diff_follow_working_copy": true
|
||||||
});
|
});
|
||||||
for value in ["all", "leading"] {
|
for value in ["all", "leading"] {
|
||||||
@@ -1036,6 +1112,7 @@ fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
|
|||||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
|
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
|
||||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
"resolve_history_layout": "columns",
|
"resolve_history_layout": "columns",
|
||||||
|
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
|
||||||
"diff_follow_working_copy": true
|
"diff_follow_working_copy": true
|
||||||
});
|
});
|
||||||
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
||||||
@@ -1148,6 +1225,7 @@ fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() {
|
|||||||
"diff_context_lines": 10, "diff_expand_lines": 15,
|
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
"resolve_history_layout": "columns",
|
"resolve_history_layout": "columns",
|
||||||
|
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
|
||||||
"diff_follow_working_copy": true, "scroll_distance": 0
|
"diff_follow_working_copy": true, "scroll_distance": 0
|
||||||
});
|
});
|
||||||
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
|
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
|
||||||
@@ -1582,6 +1660,7 @@ fn lsp_wire_configuration_is_resolved_after_decoding() {
|
|||||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||||
"resolve_history_layout": "columns",
|
"resolve_history_layout": "columns",
|
||||||
|
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
|
||||||
"diff_follow_working_copy": true
|
"diff_follow_working_copy": true
|
||||||
});
|
});
|
||||||
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
|
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
|
||||||
|
|||||||
@@ -1481,6 +1481,23 @@ end
|
|||||||
assert_eq!(session.editor().lines(), &["a!bc"]);
|
assert_eq!(session.editor().lines(), &["a!bc"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_pull_request_symbols_reach_editor() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.file("buffer.txt", "abc");
|
||||||
|
let session = fixture.open(
|
||||||
|
r#"Vamp.configure do |config|
|
||||||
|
config.pull_request_review_requested_symbol = "👩🚀 review"
|
||||||
|
config.pull_request_approved_symbol = ""
|
||||||
|
end"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session.editor().pull_request_review_requested_symbol,
|
||||||
|
"👩🚀 review"
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor().pull_request_approved_symbol, "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn diff_display_preferences_survive_reopening() {
|
fn diff_display_preferences_survive_reopening() {
|
||||||
use crate::{diff::ViewMode, panes::SplitAxis};
|
use crate::{diff::ViewMode, panes::SplitAxis};
|
||||||
|
|||||||
+125
-1
@@ -3508,9 +3508,14 @@ fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
|
|||||||
},
|
},
|
||||||
label: format!("#{index:02} Pull request"),
|
label: format!("#{index:02} Pull request"),
|
||||||
description: "open · main ← topic".into(),
|
description: "open · main ← topic".into(),
|
||||||
category: Category::Open,
|
category: if index == 1 {
|
||||||
|
Category::Drafts
|
||||||
|
} else {
|
||||||
|
Category::Open
|
||||||
|
},
|
||||||
},
|
},
|
||||||
format!("2026-01-{:02}T00:00:00Z", 20 - index),
|
format!("2026-01-{:02}T00:00:00Z", 20 - index),
|
||||||
|
crate::diff::PullRequestReviewState::None,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -3548,6 +3553,125 @@ fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
|
|||||||
);
|
);
|
||||||
render_diff(&mut terminal, &diff, &mut panels);
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
assert!(screen_text(&terminal).contains("No matching pull requests"));
|
assert!(screen_text(&terminal).contains("No matching pull requests"));
|
||||||
|
diff.handle(Input::Char('\t'));
|
||||||
|
render_diff(&mut terminal, &diff, &mut panels);
|
||||||
|
let text = screen_text(&terminal);
|
||||||
|
assert!(text.contains("> #01 Pull request"));
|
||||||
|
assert!(!text.contains("#00 Pull request") && !text.contains("#19 Pull request"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn pull_request_review_symbols_use_terminal_cells_and_keep_the_selection_arrow_independent() {
|
||||||
|
use crate::diff::{
|
||||||
|
Category, Outcome, PullRequestPage, PullRequestReviewState, RepoKind, Revision,
|
||||||
|
RevisionTarget,
|
||||||
|
};
|
||||||
|
let mut diff = DiffState::pull_requests("origin".into());
|
||||||
|
diff.take_request();
|
||||||
|
diff.apply_result(
|
||||||
|
1,
|
||||||
|
Ok(Outcome::PullRequestPage {
|
||||||
|
closed: false,
|
||||||
|
result: Ok(PullRequestPage {
|
||||||
|
kind: RepoKind::Git,
|
||||||
|
remote: "origin".into(),
|
||||||
|
has_more: false,
|
||||||
|
entries: [
|
||||||
|
PullRequestReviewState::Requested,
|
||||||
|
PullRequestReviewState::Approved,
|
||||||
|
PullRequestReviewState::None,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, state)| {
|
||||||
|
(
|
||||||
|
Revision {
|
||||||
|
target: RevisionTarget::PullRequest {
|
||||||
|
remote: "origin".into(),
|
||||||
|
number: index as u64,
|
||||||
|
},
|
||||||
|
label: format!("#{index} title"),
|
||||||
|
description: format!("metadata{index}"),
|
||||||
|
category: Category::Open,
|
||||||
|
},
|
||||||
|
(3 - index).to_string(),
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (requested, approved, with_editor) in [
|
||||||
|
("🙏", "👍", false),
|
||||||
|
("🙏", "👍", true),
|
||||||
|
("👩🚀 R", "界", true),
|
||||||
|
("ask", "ok", true),
|
||||||
|
("", "👍", true),
|
||||||
|
("🙏", "", true),
|
||||||
|
("", "", true),
|
||||||
|
] {
|
||||||
|
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||||
|
editor.pull_request_review_requested_symbol = requested.into();
|
||||||
|
editor.pull_request_approved_symbol = approved.into();
|
||||||
|
let slot = requested.width().max(approved.width());
|
||||||
|
let slot = slot + usize::from(slot > 0);
|
||||||
|
for width in [100, 44] {
|
||||||
|
let mut terminal =
|
||||||
|
Terminal::new(ratatui::backend::TestBackend::new(width, 24)).unwrap();
|
||||||
|
let mut panels = WheelPanels::default();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
panels.begin_frame();
|
||||||
|
draw_diff(
|
||||||
|
frame,
|
||||||
|
&diff,
|
||||||
|
frame.area(),
|
||||||
|
"↑/↓",
|
||||||
|
"h/j/k/l",
|
||||||
|
&mut panels,
|
||||||
|
with_editor.then_some(&editor),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
let mut number_column = None;
|
||||||
|
for (index, symbol) in [requested, approved, ""].into_iter().enumerate() {
|
||||||
|
let (x, y) = (0..24)
|
||||||
|
.find_map(|y| {
|
||||||
|
(0..width).find_map(|x| {
|
||||||
|
(buffer[(x, y)].symbol() == "#"
|
||||||
|
&& buffer[(x + 1, y)].symbol() == index.to_string())
|
||||||
|
.then_some((x, y))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.expect("all review states render");
|
||||||
|
assert_eq!(*number_column.get_or_insert(x), x);
|
||||||
|
let arrow_x = x - (2 + slot) as u16;
|
||||||
|
assert_eq!(
|
||||||
|
buffer[(arrow_x, y)].symbol(),
|
||||||
|
if index == 0 { ">" } else { " " }
|
||||||
|
);
|
||||||
|
if !symbol.is_empty() {
|
||||||
|
assert_eq!(
|
||||||
|
buffer[(arrow_x + 2, y)].symbol(),
|
||||||
|
symbol.graphemes(true).next().unwrap()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for blank in arrow_x + 2..x {
|
||||||
|
assert_eq!(buffer[(blank, y)].symbol(), " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if width == 100 {
|
||||||
|
assert_eq!(
|
||||||
|
buffer[(x, y + 1)].symbol(),
|
||||||
|
"m",
|
||||||
|
"metadata aligns with #number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(!screen_text(&terminal).contains('✓'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user