From 85e516eb67b39e4eb678140f88768bedbdc9afd2 Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:01:30 +0530 Subject: [PATCH] feat(forge): From a PR picks GitLab MRs, and can list all open PRs From a PR answered "not wired up for GitLab yet" for both the list and the paste-a-number path, so on GitLab the column was a dead end. The branch fetch already handled refs/merge-requests//head; only the two lookups were missing. GitLab now goes through `glab api`, the REST API, like the issue list, because glab's own JSON output has moved between versions: scope=created_by_me for your MRs, scope=reviews_for_me for the review list, scope=all for All open, and merge_requests/ for a pasted number. A 404 reads as "not found". A server that does not accept reviews_for_me answers 400, which hides the review section instead of putting an error under your own MRs. The column also gets two chips. For you is the default and is what the column showed before: your PRs and the review list. All open is the repo's open PRs, newest first, one page of 50, on all three forges. It is fetched on the click, so the default view costs nothing extra, and a full page says so and points at the number box. On a 1,300-PR repo the newest 50 are rarely the one you want; on a small team repo they are exactly it. On kubernetes/kubernetes (1,306 open) an unfiltered `gh pr list --limit 50` came back in under a second, about the same as the --author @me list. Azure drops --creator me for All open, in the CLI's own order, which is not verified. project_forge_prs takes `scope` (mine / review / open) in place of the `review` flag. Rust: path and parser pins for GitLab MRs, PrScope parsing, the gh open-list args and the Azure creator filter. e2e: no scope chips draw for a non-ok lookup. A populated list cannot be faked in a spec: Tauri defines __TAURI_INTERNALS__.invoke read-only, so a page-side wrapper is silently ignored. The lists and the chip switch are a manual check. --- docs/e2e-coverage.md | 2 +- docs/ui.md | 58 +++- e2e/specs/git.e2e.ts | 3 + src-tauri/src/forge.rs | 338 +++++++++++++++++++---- src-tauri/src/lib.rs | 17 +- src/components/dialogs/NewTaskDialog.tsx | 174 +++++++++--- src/lib/ipc.ts | 11 +- src/lib/types.ts | 4 + src/locales/en/dialogs.ts | 9 +- src/locales/zh-CN/dialogs.ts | 9 +- 10 files changed, 501 insertions(+), 124 deletions(-) diff --git a/docs/e2e-coverage.md b/docs/e2e-coverage.md index b7bbe57fd..da385a8cd 100644 --- a/docs/e2e-coverage.md +++ b/docs/e2e-coverage.md @@ -228,7 +228,7 @@ until `make e2e` is green and this file reflects it. | ✅ Activity monitor | The sidebar footer button opens a SECOND window (its own `activity.html` entry, found by polling the WebDriver handles: a new webview is listed before its document loads); a live agent appears under its project and task; Termic's own processes get their own group and do NOT double-count the agents' subtrees (every PTY is our child, so the app row's stop-set is the invariant); a row reports a real CPU percentage even though the harness window is permanently `document.hidden`, which is the occluded-window back-off working; Pause halts sampling and resume restarts it; re-opening focuses the existing window instead of spawning a second; closing the window drops the sampling session and a fresh one is grantable; every column header sorts (default CPU desc, a new column starts biggest-first except Name which starts A-to-Z, the previous column lets go) and the PID column renders real pids. Grouping / sorting / formatting are unit-tested in `src/lib/activityGroups.test.ts`, the sampler math + FFI in `procmon.rs` tests. On Windows the same spec runs against `procmon_windows.rs`: the stop case ends a `cmd.exe` and its child together, and the Termic row must either hold WebView2's processes or say it cannot attribute them; suspend / resume and the stale-parent-pid rule are Rust tests there | `activity.e2e.ts` | | ✅ Start from an issue (GH #21/#22) | The fixture's local bare remote resolves as NOT a forge, so `project_forge_issues` reports `unsupported-remote` rather than an empty list, and `buildIssuePrompt` composes context + deferred thread + library instructions; the route in is exercised end to end (palette row → shared project picker flagged `issue` → the SAME New Task dialog with its issue column open beside the form → the non-forge explanation → "blank task instead" drops the column and keeps the dialog). Picking a real issue needs a real forge, so the prompt auto-fill it triggers is unit-tested in `src/lib/issuePrompt.test.ts`. | `git.e2e.ts` | -| ✅ PR picker: waiting for your review | With a forge seeded for the fixture project (after the dialog's own `resolveProvider` has answered, which would otherwise overwrite it), "From a PR" opens the PR column with its intro, the fixture's non-forge remote is reported as a sentence with no `pr-row`, and no `pr-review-section` draws for a non-`ok` lookup; switching back to a new branch closes the column. The `gh` query (`review-requested:@me`, open, the shared JSON fields) and the GitHub-only empty answer for GitLab/Azure are pinned in `forge.rs` tests. A populated review list needs a real GitHub repo and is a manual check | `git.e2e.ts`, `forge.rs` | +| ✅ PR picker: For you, waiting for your review, All open | With a forge seeded for the fixture project (after the dialog's own `resolveProvider` has answered, which would otherwise overwrite it), "From a PR" opens the PR column with its intro, the fixture's non-forge remote is reported as a sentence with no `pr-row`, and neither `pr-review-section` nor the `pr-scope` chips draw for a non-`ok` lookup; switching back to a new branch closes the column. The `gh` query (`review-requested:@me`, open, the shared JSON fields), the GitLab REST paths (`scope=created_by_me` / `scope=reviews_for_me`), the GitLab MR parser (iid, fork from differing project ids, `draft` or the older `work_in_progress`) and Azure's empty answer are pinned in `forge.rs` tests. A populated list, and switching between For you and All open, need a real GitHub or GitLab repo and are a manual check | `git.e2e.ts`, `forge.rs` | | ✅ Issue picker: scope chips | With GitHub seeded for the fixture project, the issue column offers All / Assigned to you / Mentions you, starting on All; each click presses exactly that chip and refetches, and on the non-forge remote each scope still reports the remote as a sentence rather than an empty "nothing assigned to you"; leaving and re-entering the issue source starts back on All. The `gh` query per scope, the parse default and the GitHub-only empty answer are pinned in `forge.rs` tests. Real assigned/mentioned results need a GitHub repo and are a manual check | `git.e2e.ts`, `forge.rs` | | ✅ Profiles (GH #280) | The sidebar has no background image by default and takes a gradient only while `profileSidebarWash` is on (computed style). The dormant contract first: no strip renders until a profile exists, and the footer's profile button is the whole surface. Creating the first profile ADOPTS the install that already exists (both entries, one write) and the existing one owns the root data dir, so nothing moves; the strip then carries the name in clear and the popover lists every profile. Isolation is asserted through the app's own IPC rather than by driving two windows (the suite reuses one window per file): the seeded `fixture-repo` belongs to the root profile and the new one starts empty, seeded under `profiles//tasks`. A rename never moves the slug. `profile_open` really does create a second WebDriver window. Delete is REFUSED while the profile's window is open, which is why backing out of the feature needs its own door: `profiles_disable` returns the app to its pre-profiles shape keeping every byte of data, and is itself refused while more than one profile exists. Two REAL windows are open at once and asserted disjoint (the second knows its own slug, renders its own name, and sees an empty project list while the first still holds `fixture-repo`). The delete dialog is driven for real, and asserts the PAINTED border colour moves with the selection, not just `aria-checked`: `transition-colors` froze it in WKWebView (docs/gotchas.md) while every attribute assertion stayed green. `profile_close` is the door the spec uses to hand the suite back its one window, and it refuses to close the window it is called from. Launch restore is compiled out under `feature = "e2e"`, since the suite asserts on window-handle counts | `profiles.e2e.ts` | | ✅ Config sync (Settings > Sync) | A folder another machine adds (name, accent, one project) becomes a profile here on Sync now, on an install with no profiles: the report says so, the profile carries the folder's accent, the title-bar chip appears without a reload, and this window's own folder is untouched; deleting that profile here survives the next sync while the folder stays in the repo, Settings lists it under Profiles not on this machine, and Create here brings it back. Who is created, uploaded or left waiting (same name, ignored, opted out), slug-named folders, rename propagation and the opt-out are the two-machine Rust test `profiles_follow_the_repo_both_ways` and `plan_adoption`'s own. Against a local bare repo over `file://`, with the spec playing the other machine by cloning it, committing and pushing. The never-synced note shows before anything is connected. A URL git would run as a command (`ext::`) or read as an option (`--upload-pack=`) is refused before anything is cloned, and the page names the forms it takes. Connecting to an EMPTY repo pushes at once: the remote then holds README, prefs.json and the profile's settings, agent and project files, the commit reads `sync from `, and neither an agent's env value nor a local field (`root_path`, `repos_dir`, `sandbox_allowed_paths`) is anywhere in it. Sync now pulls and applies a field the other machine changed. A pulled YOLO default shows as a persistent notice and a toast, each naming the change ("changed from Default to On"), while the report only counts it, and Got it clears the notice. A project with no folder here, and a plain-folder one, wait with Locate and Skip; Skip survives another pull and Show again undoes it; the report gives them one count ("2 projects wait for a folder") and does not count them as applied. Disconnect, then connect to the repo that now holds a setup: the folder picker preselects it, Preview lists the YOLO change highlighted and the waiting projects, Cancel applies nothing, Apply does. Repo and Folder values are measured to share a left edge. Regaining window focus pulls when the last pull is more than five minutes old: the spec backdates `sync-state.json`, flips window focus, and the other machine's preview URL lands without Sync now; a second `sync_focus_pull` in that window is skipped. Never touches localStorage or the themes folder: the other machine edits project files only, because the e2e binary shares localStorage with an installed Termic | `sync.e2e.ts` | diff --git a/docs/ui.md b/docs/ui.md index c46d8645e..87e9d88e9 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1152,24 +1152,51 @@ the sandbox config get. It was inline above the form first. That put a 220px scrolling list inside a dialog you were already scrolling, and hid the effect of a pick below the fold. -### The PR picker lists two things, both scoped to you - -"From a PR" shows your own open PRs and, under them, the open PRs waiting -on your review (`gh pr list --search review-requested:@me`, so a request to -a team you are on counts, as on GitHub's own Review requests tab). Still not -a repo-wide browser: both lists are about you, so they stay short on a busy -repo, and anything else is reached by pasting its number. - -The review list is a second `project_forge_prs` call (`review: true`) fired +### The PR picker opens on you, and the repo's list is one click away + +"From a PR" opens on **For you**: your own open PRs and, under them, the +open PRs waiting on your review (`gh pr list --search review-requested:@me`, +so a request to a team you are on counts, as on GitHub's own Review +requests tab). Both lists are about you, so they stay short on a busy repo. + +**All open** is the repo's open PRs, newest first, one page of 50. It is a +chip, not the default, because on a repo with 1,300 open PRs the newest 50 +are rarely the one you came for, while on a small team repo they are +exactly it. Three things keep it cheap for the first case: + +- It is fetched on the click, never on entry. Nobody who stays on For you + pays for it. +- It is capped. A full page says so under the list ("Showing the newest + 50") and points at the number box, which is the path for anything older. +- It is the forge's own default order (`gh pr list` with no filter, GitLab + `order_by=created_at`, Azure the CLI's own order, not verified). + `--search sort:updated-desc` was measured on a 1,300-PR repo and does not + come back in updated order, so "most active first" is not on offer. + +The chips draw only for an `ok` lookup of your own list: a forge that +cannot be reached is already explained there, and All open would fail the +same way. The column starts back on For you every time it is entered, and +a slow All open answer is dropped by a sequence number if you clicked back. + +On GitLab every list is one REST call through `glab api` +(`merge_requests?scope=created_by_me`, `scope=reviews_for_me` for the MRs +where you are a reviewer, and `scope=all` for All open), and a pasted +number is `merge_requests/`. +The REST API rather than `glab mr list` because the CLI's JSON has moved +between versions, the same reason the issue list uses it. A server that +does not accept a `scope` value answers 400; for `reviews_for_me` that +reads as an empty review list, so the section hides instead of erroring +under your own MRs. + +The review list is a second `project_forge_prs` call (`scope: "review"`) fired beside the first, never before it. It renders BELOW your own PRs, because it lands on its own clock and a list that arrives late must not push down a row you are about to click. It draws only for an `ok` status with rows: a forge that cannot be reached is already explained by the first list, and saying it -twice is noise. GitHub only; GitLab has no picker list at all yet, and -Azure's reviewer filter wants an identity id rather than `@me`, so both -answer an empty list and the section stays hidden. +twice is noise. Azure's reviewer filter wants an identity id rather than +`@me`, so it answers an empty list and the section stays hidden there. -Picking a PR from either list is the same pick: fetch the head, then check +Picking a PR from any list is the same pick: fetch the head, then check it out. Firing a review at it is the prompt library's Review entry, by hand. The issue column has the matching half as chips over the list: **All**, @@ -1179,8 +1206,9 @@ flag). Chips, not extra lists: the repo's list is long, so a list appended under it would sit below the fold, and a chip only changes the list when you click it. Each click refetches with a sequence number so a slow answer for a scope you already left cannot overwrite the one you picked. The -column starts back on All every time it is entered. GitHub only, for the -same reason as the review list. +column starts back on All every time it is entered. GitHub only. GitLab's +issues API can say "assigned to me" but has no mention filter, so wiring it +means a per-forge chip set rather than the same three chips. ### The new-task dialog has TWO columns and TWO widths diff --git a/e2e/specs/git.e2e.ts b/e2e/specs/git.e2e.ts index 71e4df02f..1dbc2bad4 100644 --- a/e2e/specs/git.e2e.ts +++ b/e2e/specs/git.e2e.ts @@ -2799,6 +2799,9 @@ describe("the forge pickers: work that pulled you in", () => { // does not matter: on this remote it can only answer a non-"ok" status, // and the section draws for "ok" with rows and nothing else. expect(await browser.execute(() => !!document.querySelector('[data-testid="pr-review-section"]'))).toBe(false); + // No For you / All open chips either: the sentence already says why the + // forge cannot be reached, and All open would only fail the same way. + expect(await browser.execute(() => !!document.querySelector('[data-testid="pr-scope"]'))).toBe(false); await snap("pr-pane-no-forge.png"); }); diff --git a/src-tauri/src/forge.rs b/src-tauri/src/forge.rs index b134b3079..23d91f66a 100644 --- a/src-tauri/src/forge.rs +++ b/src-tauri/src/forge.rs @@ -1781,12 +1781,13 @@ fn parse_github_issues(v: &serde_json::Value) -> Vec { /// One pull request, enough for the picker and for composing a prompt. /// -/// Deliberately NOT a general PR search. The picker lists only PRs about the -/// signed-in user (their own, and the ones waiting on their review), because -/// "every open PR" is unusable at real scale: the -/// maintainer's day job has ~1,300 of them, and a list that long is slower to -/// fetch, slower to read and never what you wanted anyway. Anything else is -/// reached by typing its number, which is one API call rather than a page. +/// Scoped to the signed-in user BY DEFAULT: the picker opens on their own +/// PRs and the ones waiting on their review, because "every open PR" is +/// unusable at real scale. The maintainer's day job has ~1,300 of them, and +/// the newest page of that is rarely the one you came for. The repo's open +/// PRs are one click away (`PrScope::Open`), fetched only on that click and +/// capped at one page, which is what a small team repo wants. Anything +/// older is reached by typing its number, one API call rather than a page. #[derive(Serialize, Clone, Debug, PartialEq)] pub struct ForgePr { pub provider: String, @@ -1810,34 +1811,84 @@ pub struct ForgePr { const PR_JSON_FIELDS: &str = "number,title,url,body,author,headRefName,isCrossRepository,isDraft,updatedAt"; -/// The signed-in user's own open PRs. See `ForgePr` for why it is only theirs. +/// Which PRs the picker asks for. `Mine` and `Review` are about the signed-in +/// user and are what the picker shows on entry; `Open` is the repo's newest +/// open PRs, fetched only when the user asks for them (see `ForgePr`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PrScope { + Mine, + Review, + Open, +} + +impl PrScope { + /// The IPC spelling. Anything unknown is the user's own list, so an older + /// webview that sends nothing keeps today's behaviour. + pub fn parse(s: Option<&str>) -> Self { + match s { + Some("review") => PrScope::Review, + Some("open") => PrScope::Open, + _ => PrScope::Mine, + } + } +} + +pub fn pr_list(provider: &str, cwd: &Path, limit: u32, scope: PrScope) -> Result, ForgeError> { + match scope { + PrScope::Mine => pr_list_mine(provider, cwd, limit), + PrScope::Review => pr_list_review_requested(provider, cwd, limit), + PrScope::Open => pr_list_open(provider, cwd, limit), + } +} + +/// Run one `gh pr list` and parse it. Every GitHub list is the same call +/// with different filters, newest first (gh's default order). +fn github_pr_list(bin: &str, cwd: &Path, args: &[&str]) -> Result, ForgeError> { + let o = run(bin, args, Some(cwd)).map_err(|e| ForgeError::Other(e.to_string()))?; + if !o.status.success() { + return Err(classify_failure(GITHUB, &o)); + } + let v: serde_json::Value = serde_json::from_slice(&o.stdout) + .map_err(|e| ForgeError::Other(format!("gh returned unparseable JSON: {e}")))?; + Ok(parse_github_prs(&v)) +} + +/// The signed-in user's own open PRs. See `ForgePr` for why this is the +/// default list. pub fn pr_list_mine(provider: &str, cwd: &Path, limit: u32) -> Result, ForgeError> { let cli = cli_for_provider(provider); let bin = reprobe_bin(cli).ok_or(ForgeError::CliMissing(cli))?; match provider { - GITLAB => Err(ForgeError::Other( - "Picking a merge request is not wired up for GitLab yet. Paste the number instead.".into(), - )), - AZURE => azure_pr_list(&bin, cwd, limit), + GITLAB => gitlab_mr_list(&bin, cwd, GITLAB_MR_MINE, limit), + AZURE => azure_pr_list(&bin, cwd, limit, true), _ => { let n = limit.to_string(); - let o = run( - &bin, - &["pr", "list", "--author", "@me", "--state", "open", "--limit", &n, - "--json", PR_JSON_FIELDS], - Some(cwd), - ) - .map_err(|e| ForgeError::Other(e.to_string()))?; - if !o.status.success() { - return Err(classify_failure(GITHUB, &o)); - } - let v: serde_json::Value = serde_json::from_slice(&o.stdout) - .map_err(|e| ForgeError::Other(format!("gh returned unparseable JSON: {e}")))?; - Ok(parse_github_prs(&v)) + github_pr_list(&bin, cwd, &["pr", "list", "--author", "@me", "--state", "open", "--limit", &n, + "--json", PR_JSON_FIELDS]) } } } +/// `gh pr list` arguments for the repo's open PRs: no author and no search +/// filter, so the order is gh's default, newest first, which is also +/// GitHub's own default list. (`--search sort:updated-desc` was measured and +/// does not come back in updated order, so "most active first" is not on +/// offer.) Split out so the query is pinned by a test. +fn open_pr_args(limit: &str) -> [&str; 8] { + ["pr", "list", "--state", "open", "--limit", limit, "--json", PR_JSON_FIELDS] +} + +/// The repo's open PRs, one capped page, for the picker's "All open". +pub fn pr_list_open(provider: &str, cwd: &Path, limit: u32) -> Result, ForgeError> { + let cli = cli_for_provider(provider); + let bin = reprobe_bin(cli).ok_or(ForgeError::CliMissing(cli))?; + match provider { + GITLAB => gitlab_mr_list(&bin, cwd, GITLAB_MR_ALL, limit), + AZURE => azure_pr_list(&bin, cwd, limit, false), + _ => github_pr_list(&bin, cwd, &open_pr_args(&limit.to_string())), + } +} + /// `gh pr list` arguments for the open PRs waiting on the signed-in user's /// review. `review-requested:` matches a request to the user OR to a team they /// are on, which is what GitHub's own "Review requests" tab shows. Split out so @@ -1849,23 +1900,18 @@ fn review_requested_args(limit: &str) -> [&str; 10] { /// Open PRs somebody asked the signed-in user to review. The second list the /// picker shows, still scoped to the user rather than the repo, so it stays -/// small for the same reason `pr_list_mine` does. GitHub only: GitLab has no -/// picker list at all yet, and Azure's reviewer filter wants an identity id -/// rather than `@me`. Both answer an empty list, which the picker hides. +/// small for the same reason `pr_list_mine` does. GitHub and GitLab only: +/// Azure's reviewer filter wants an identity id rather than `@me`, so it +/// answers an empty list, which the picker hides. pub fn pr_list_review_requested(provider: &str, cwd: &Path, limit: u32) -> Result, ForgeError> { - if provider != GITHUB { + if provider == AZURE { return Ok(Vec::new()); } let bin = reprobe_bin(cli_for_provider(provider)).ok_or(ForgeError::CliMissing(cli_for_provider(provider)))?; - let n = limit.to_string(); - let o = run(&bin, &review_requested_args(&n), Some(cwd)) - .map_err(|e| ForgeError::Other(e.to_string()))?; - if !o.status.success() { - return Err(classify_failure(GITHUB, &o)); + if provider == GITLAB { + return gitlab_mr_list(&bin, cwd, GITLAB_MR_REVIEWS, limit); } - let v: serde_json::Value = serde_json::from_slice(&o.stdout) - .map_err(|e| ForgeError::Other(format!("gh returned unparseable JSON: {e}")))?; - Ok(parse_github_prs(&v)) + github_pr_list(&bin, cwd, &review_requested_args(&limit.to_string())) } /// One PR by number, for the "paste a number" path. The number may be typed, @@ -1875,9 +1921,7 @@ pub fn pr_by_number(provider: &str, cwd: &Path, number: u64) -> Result Vec { .collect() } -/// `az repos pr list`, scoped to the repo (an org-level list would drag in -/// PRs from every repo in the project) and to the signed-in user: the -/// extension resolves `--creator me` to the current identity client-side. -fn azure_pr_list(bin: &str, cwd: &Path, limit: u32) -> Result, ForgeError> { +/// GitLab's `scope` values for the picker lists. The first two are about the +/// signed-in user, resolved server-side, so neither needs a `glab api user` +/// round trip first. `reviews_for_me` is MRs where the user is a reviewer; +/// `all` is the repo's open MRs, for "All open". +const GITLAB_MR_MINE: &str = "created_by_me"; +const GITLAB_MR_REVIEWS: &str = "reviews_for_me"; +const GITLAB_MR_ALL: &str = "all"; + +/// The REST path for one MR list, newest first like every GitHub list. The +/// REST API, not `glab mr list`, for the same reason the issue list uses it: +/// the CLI's JSON output has moved around across versions, while the +/// endpoint has not. Split out so the query is pinned by a test without a +/// `glab` on the machine. +fn gitlab_mr_list_path(scope: &str, limit: u32) -> String { + format!("projects/:id/merge_requests?state=opened&scope={scope}&order_by=created_at&sort=desc&per_page={limit}") +} + +fn gitlab_mr_list(bin: &str, cwd: &Path, scope: &str, limit: u32) -> Result, ForgeError> { + let path = gitlab_mr_list_path(scope, limit); + let o = run(bin, &["api", &path], Some(cwd)).map_err(|e| ForgeError::Other(e.to_string()))?; + if !o.status.success() { + // A server that does not accept a scope value answers 400 ("scope + // does not have a valid value"). Only the review list falls back: + // an empty list hides its section instead of putting a red line + // under the user's own MRs, while a refused list of their own MRs is + // a real error worth showing. + if scope == GITLAB_MR_REVIEWS && stderr_of(&o).contains("400") { + return Ok(Vec::new()); + } + return Err(classify_failure(GITLAB, &o)); + } + let v: serde_json::Value = serde_json::from_slice(&o.stdout) + .map_err(|e| ForgeError::Other(format!("glab returned unparseable JSON: {e}")))?; + Ok(parse_gitlab_mrs(&v)) +} + +fn gitlab_mr_show(bin: &str, cwd: &Path, number: u64) -> Result, ForgeError> { + let path = format!("projects/:id/merge_requests/{number}"); + let o = run(bin, &["api", &path], Some(cwd)).map_err(|e| ForgeError::Other(e.to_string()))?; + if !o.status.success() { + // A wrong number is a 404, and an answer rather than a failure, the + // same as `gh pr view`'s "no pull requests found". + if stderr_of(&o).contains("404") { + return Ok(None); + } + return Err(classify_failure(GITLAB, &o)); + } + let v: serde_json::Value = serde_json::from_slice(&o.stdout) + .map_err(|e| ForgeError::Other(format!("glab returned unparseable JSON: {e}")))?; + // One object here, an array from the list. Reuse one parser. + Ok(parse_gitlab_mrs(&serde_json::Value::Array(vec![v])).into_iter().next()) +} + +fn parse_gitlab_mrs(v: &serde_json::Value) -> Vec { + v.as_array() + .unwrap_or(&Vec::new()) + .iter() + .map(|i| ForgePr { + provider: GITLAB.into(), + // `iid` is the per-project number users see; `id` is global. + number: i["iid"].as_u64().unwrap_or(0), + title: i["title"].as_str().unwrap_or("").trim().to_string(), + url: i["web_url"].as_str().unwrap_or("").to_string(), + body: i["description"].as_str().unwrap_or("").trim().to_string(), + author: i["author"]["username"].as_str().unwrap_or("").to_string(), + head_ref: i["source_branch"].as_str().unwrap_or("").to_string(), + // A fork MR's source project is not the target. Both ids are on + // every MR; a row missing one is treated as same-repo. + cross_repository: match (i["source_project_id"].as_u64(), i["target_project_id"].as_u64()) { + (Some(s), Some(t)) => s != t, + _ => false, + }, + // `work_in_progress` is the older spelling, kept by newer servers. + draft: i["draft"].as_bool().unwrap_or(false) || i["work_in_progress"].as_bool().unwrap_or(false), + updated_at: norm_time(i["updated_at"].as_str().unwrap_or("")), + }) + .filter(|p| p.number > 0) + .collect() +} + +/// `az repos pr list` arguments, always scoped to the repo (an org-level list +/// would drag in PRs from every repo in the project) and, for `mine`, to the +/// signed-in user: the extension resolves `--creator me` to the current +/// identity client-side. Without it this is "All open", in the CLI's own +/// order. Split out so the creator filter is pinned by a test. +fn azure_pr_list_args<'a>(org: &'a str, project: &'a str, repo: &'a str, top: &'a str, mine: bool) -> Vec<&'a str> { + let mut a = vec!["repos", "pr", "list", "--repository", repo, "--org", org, "--project", project]; + if mine { + a.extend(["--creator", "me"]); + } + a.extend(["--status", "active", "--top", top, "--output", "json"]); + a +} + +fn azure_pr_list(bin: &str, cwd: &Path, limit: u32, mine: bool) -> Result, ForgeError> { let (org, project, repo) = azure_scope(cwd)?; let top = limit.to_string(); - let o = run( - bin, - &[ - "repos", "pr", "list", "--repository", &repo, - "--org", &org, "--project", &project, - "--creator", "me", "--status", "active", "--top", &top, "--output", "json", - ], - Some(cwd), - ) - .map_err(|e| ForgeError::Other(e.to_string()))?; + let o = run(bin, &azure_pr_list_args(&org, &project, &repo, &top, mine), Some(cwd)) + .map_err(|e| ForgeError::Other(e.to_string()))?; if !o.status.success() { return Err(classify_failure(AZURE, &o)); } @@ -2447,14 +2574,111 @@ code.internal.acme.com configured to use ssh protocol.\n"; } #[test] - fn review_requested_is_github_only_and_empty_elsewhere() { - // GitLab and Azure answer an empty list without ever looking for a - // CLI, so the picker simply shows no second section for them. + fn review_requested_is_empty_on_azure() { + // Azure answers an empty list without ever looking for a CLI, so the + // picker simply shows no second section for it. let cwd = std::env::temp_dir(); - assert_eq!(pr_list_review_requested(GITLAB, &cwd, 10).unwrap(), Vec::new()); assert_eq!(pr_list_review_requested(AZURE, &cwd, 10).unwrap(), Vec::new()); } + #[test] + fn gitlab_mr_lists_ask_for_the_users_open_mrs() { + // The scope IS the feature, as `review-requested:@me` is on GitHub: + // resolved server-side to the signed-in user, open only, newest + // first like every gh list, one page. + assert_eq!( + gitlab_mr_list_path(GITLAB_MR_MINE, 30), + "projects/:id/merge_requests?state=opened&scope=created_by_me&order_by=created_at&sort=desc&per_page=30" + ); + assert!(gitlab_mr_list_path(GITLAB_MR_REVIEWS, 30).contains("&scope=reviews_for_me&")); + assert!(gitlab_mr_list_path(GITLAB_MR_ALL, 50).contains("&scope=all&")); + assert!(gitlab_mr_list_path(GITLAB_MR_ALL, 50).ends_with("&per_page=50")); + } + + #[test] + fn pr_scope_parsing_defaults_to_mine() { + assert_eq!(PrScope::parse(Some("review")), PrScope::Review); + assert_eq!(PrScope::parse(Some("open")), PrScope::Open); + // An older webview sends nothing, and a typo must not widen the list + // to the whole repo. + assert_eq!(PrScope::parse(None), PrScope::Mine); + assert_eq!(PrScope::parse(Some("all")), PrScope::Mine); + } + + #[test] + fn all_open_asks_gh_for_every_open_pr_unfiltered() { + // No --author and no --search: either would quietly turn "All open" + // back into a list about the user, and --search also loses gh's + // newest-first order. + let joined = open_pr_args("50").join(" "); + assert_eq!(joined, format!("pr list --state open --limit 50 --json {PR_JSON_FIELDS}")); + } + + #[test] + fn azure_creator_filter_is_only_on_the_users_list() { + let mine = azure_pr_list_args("https://dev.azure.com/acme", "web", "app", "30", true).join(" "); + assert!(mine.contains("--repository app --org https://dev.azure.com/acme --project web --creator me --status active --top 30")); + let all = azure_pr_list_args("https://dev.azure.com/acme", "web", "app", "50", false).join(" "); + assert!(!all.contains("--creator")); + // Still the repo, never the org: an org-level list is every repo's PRs. + assert!(all.contains("--repository app") && all.contains("--status active --top 50")); + } + + #[test] + fn gitlab_mr_parsing() { + // Synthetic, transcribed shape, never a paste (see CLAUDE.md on + // fixtures). Three rows: a same-repo MR, a fork MR still flagged + // with the older `work_in_progress` spelling, and a malformed one. + let v: serde_json::Value = serde_json::from_str(r#"[ + {"id": 90012, "iid": 12, "title": " Fix the login redirect ", + "web_url": "https://gitlab.example.io/acme/web/-/merge_requests/12", + "description": " needs a second pair of eyes ", "author": {"username": "alice"}, + "source_branch": "fix-login", "source_project_id": 7, "target_project_id": 7, + "draft": false, "work_in_progress": false, "updated_at": "2026-09-30T08:00:00.123Z"}, + {"id": 90013, "iid": 13, "title": "Bump deps", + "web_url": "https://gitlab.example.io/acme/web/-/merge_requests/13", + "description": null, "author": {"username": "bob"}, + "source_branch": "deps", "source_project_id": 99, "target_project_id": 7, + "work_in_progress": true, "updated_at": "2026-09-29T08:00:00+02:00"}, + {"id": 90000, "title": "malformed", "author": {"username": "x"}} + ]"#).unwrap(); + let prs = parse_gitlab_mrs(&v); + assert_eq!(prs.len(), 2); + assert_eq!(prs[0].provider, GITLAB); + // iid (what the user sees and what refs/merge-requests/ uses), + // never the global id. + assert_eq!(prs[0].number, 12); + assert_eq!(prs[0].title, "Fix the login redirect"); + assert_eq!(prs[0].body, "needs a second pair of eyes"); + assert_eq!(prs[0].author, "alice"); + assert_eq!(prs[0].head_ref, "fix-login"); + assert_eq!(prs[0].url, "https://gitlab.example.io/acme/web/-/merge_requests/12"); + assert!(!prs[0].cross_repository); + assert!(!prs[0].draft); + assert_eq!(prs[0].updated_at, "2026-09-30T08:00:00Z"); + // The fork + draft row decides the local branch name and the badge. + assert!(prs[1].cross_repository); + assert!(prs[1].draft); + assert_eq!(prs[1].body, ""); + assert_eq!(prs[1].updated_at, "2026-09-29T06:00:00Z"); + } + + #[test] + fn a_single_gitlab_mr_parses_like_a_list_of_one() { + // `merge_requests/` returns ONE object; gitlab_mr_show wraps it. + let one: serde_json::Value = serde_json::from_str(r#" + {"iid": 99, "title": "Paste me", "web_url": "https://gitlab.example.io/acme/web/-/merge_requests/99", + "description": "b", "author": {"username": "alice"}, "source_branch": "paste-me", + "source_project_id": 7, "target_project_id": 7, "draft": true, + "updated_at": "2026-09-30T08:00:00Z"} + "#).unwrap(); + let prs = parse_gitlab_mrs(&serde_json::Value::Array(vec![one])); + assert_eq!(prs.len(), 1); + assert_eq!(prs[0].number, 99); + assert_eq!(prs[0].head_ref, "paste-me"); + assert!(prs[0].draft); + } + #[test] fn rollup_mapping() { let j = |s: &str| serde_json::from_str::(s).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index af02e4b1d..c0f982e3c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13995,14 +13995,14 @@ struct PrPickList { prs: Vec, } -/// The signed-in user's own open PRs for a PROJECT, and the by-number lookup +/// One of the PR picker's lists for a PROJECT, and the by-number lookup /// beside it. Same shape as `issue_lookup_blocking`, including its habit of /// reporting "no remote" / "cli missing" as a STATUS rather than an error: the /// New Task dialog draws a sentence, never a red banner, for a repo that simply /// has no forge. -/// `review` swaps the list for the PRs waiting on the user's review (see -/// `forge::pr_list_review_requested`); the number path ignores it. -fn pr_picker_blocking(project_id: &str, limit: u32, number: Option, review: bool) -> Result { +/// `scope` picks the list (the user's own, waiting on their review, or the +/// repo's open PRs; see `forge::PrScope`); the number path ignores it. +fn pr_picker_blocking(project_id: &str, limit: u32, number: Option, scope: forge::PrScope) -> Result { let p = load_projects_all() .into_iter() .find(|p| p.id == project_id) @@ -14033,8 +14033,7 @@ fn pr_picker_blocking(project_id: &str, limit: u32, number: Option, review: // One number: `pr view`. Not found is a status, not an error, because // a mistyped number is the ordinary case on this path. Some(n) => forge::pr_by_number(provider, &cwd, n).map(|o| o.into_iter().collect::>()), - None if review => forge::pr_list_review_requested(provider, &cwd, limit), - None => forge::pr_list_mine(provider, &cwd, limit), + None => forge::pr_list(provider, &cwd, limit, scope), }; match result { Ok(prs) if prs.is_empty() && number.is_some() => { @@ -14052,10 +14051,10 @@ fn pr_picker_blocking(project_id: &str, limit: u32, number: Option, review: } #[tauri::command] -async fn project_forge_prs(project_id: String, limit: Option, number: Option, review: Option) -> Result { +async fn project_forge_prs(project_id: String, limit: Option, number: Option, scope: Option) -> Result { let limit = limit.unwrap_or(30).clamp(1, 100); - let review = review.unwrap_or(false); - tauri::async_runtime::spawn_blocking(move || pr_picker_blocking(&project_id, limit, number, review)) + let scope = forge::PrScope::parse(scope.as_deref()); + tauri::async_runtime::spawn_blocking(move || pr_picker_blocking(&project_id, limit, number, scope)) .await .map_err(|e| e.to_string())? } diff --git a/src/components/dialogs/NewTaskDialog.tsx b/src/components/dialogs/NewTaskDialog.tsx index c636aaf1d..524ab1d04 100644 --- a/src/components/dialogs/NewTaskDialog.tsx +++ b/src/components/dialogs/NewTaskDialog.tsx @@ -39,6 +39,10 @@ import { scoped } from "@/lib/profileScope"; const CLIS = ["claude", "codex", "agy", "grok", "opencode"] as const; +// One page of the repo's open PRs for "All open", the same size as the issue +// list. A repo with more says so under the list and points at the number box. +const PR_OPEN_LIMIT = 50; + // Remember the user's last-used task type + sandbox mode across opens — // most people always work one way (always worktree, always enforce), so // re-deriving from project defaults every time fights their habit. Stored @@ -431,18 +435,26 @@ export function NewTaskDialog() { const [checkoutBranch, setCheckoutBranch] = useState(""); const [checkoutRefs, setCheckoutRefs] = useState(null); const [checkoutLoading, setCheckoutLoading] = useState(false); - // Pull request mode. Deliberately not a general PR browser: the lists are - // only YOUR open PRs and the ones waiting on your review, and anything else - // is reached by typing its number. "Every - // open PR" does not scale (1,300 of them on the maintainer's work repo), and - // a list that long is slower to fetch, slower to read and rarely the one you - // came for. + // Pull request mode. Opens on lists about YOU: your open PRs and the ones + // waiting on your review. "Every open PR" is not the default because it + // does not scale (1,300 of them on the maintainer's work repo), and the + // newest page of that is rarely the one you came for. It is one click away + // ("All open"), fetched only on that click and capped at a page, and + // anything older is reached by typing its number. const [prMode, setPrMode] = useState(false); const [prLookup, setPrLookup] = useState(null); // The second list: open PRs waiting on YOUR review. Still scoped to you, so // it stays as small as the first. Fetched beside it, never blocking it. const [prReviewLookup, setPrReviewLookup] = useState(null); const [prLoading, setPrLoading] = useState(false); + // "For you" (the two lists above) or "All open". Starts on "For you" every + // time the column is entered, like the issue chips start on All. + const [prView, setPrView] = useState<"you" | "open">("you"); + const [prOpenLookup, setPrOpenLookup] = useState(null); + const [prOpenLoading, setPrOpenLoading] = useState(false); + // A slow "All open" answer must not overwrite a newer one, or land after + // the user went back to "For you": same sequence guard as the issue scopes. + const prOpenSeq = useRef(0); const [prQuery, setPrQuery] = useState(""); const [prBusy, setPrBusy] = useState(0); // Resume-args override, set at create so it applies from the FIRST spawn. @@ -704,6 +716,9 @@ export function NewTaskDialog() { setPrMode(false); setPrLookup(null); setPrReviewLookup(null); + setPrView("you"); + setPrOpenLookup(null); + setPrOpenLoading(false); setPrQuery(""); setPrLoading(false); setPrBusy(0); @@ -907,6 +922,10 @@ export function NewTaskDialog() { setCheckoutMode(false); setErr(null); setPrQuery(""); + setPrView("you"); + setPrOpenLookup(null); + setPrOpenLoading(false); + prOpenSeq.current++; setPrLoading(true); // Same stale guard as enterIssues: a lookup landing in a re-opened // dialog would show the previous project's PRs. @@ -918,7 +937,7 @@ export function NewTaskDialog() { // A failure here only hides the section: the list above already says // why the forge cannot be reached, and saying it twice is noise. setPrReviewLookup(null); - projectForgePrs(projectId, { review: true }) + projectForgePrs(projectId, { scope: "review" }) .then(l => { if (!stale()) setPrReviewLookup(l); }) .catch(() => {}); } @@ -927,10 +946,40 @@ export function NewTaskDialog() { setPrMode(false); setPrLookup(null); setPrReviewLookup(null); + setPrView("you"); + setPrOpenLookup(null); + setPrOpenLoading(false); + prOpenSeq.current++; setPrQuery(""); setErr(null); } + /** "All open" is fetched on the click, never on entry: someone who never + * asks for the repo's list pays nothing for it. Refetched on each click, + * like the issue scopes, so a PR opened a minute ago shows up. Going back + * to "For you" needs no fetch, its lists are already loaded. */ + function choosePrView(next: "you" | "open") { + if (next === prView || !projectId) return; + setPrView(next); + setErr(null); + if (next === "you") { + prOpenSeq.current++; + setPrOpenLoading(false); + return; + } + const reopened = staleFor(projectId, useUI.getState().newTaskSeed); + const seq = ++prOpenSeq.current; + const stale = () => reopened() || seq !== prOpenSeq.current; + setPrOpenLookup(null); + setPrOpenLoading(true); + projectForgePrs(projectId, { scope: "open", limit: PR_OPEN_LIMIT }) + .then(l => { if (!stale()) setPrOpenLookup(l); }) + .catch(e => { if (!stale()) setPrOpenLookup({ + provider: null, remote_url: "", status: "error", message: String(e), prs: [], + }); }) + .finally(() => { if (!stale()) setPrOpenLoading(false); }); + } + /** Where this task comes from. Derived from the mode flags rather than * stored beside them: they are what the rest of the dialog reads, and a * second source of truth would be one more thing to keep in step. @@ -2243,35 +2292,94 @@ export function NewTaskDialog() { - {prLoading ? ( -
- {t("newTask.loadingPrs")} -
- ) : prLookup && prLookup.status !== "ok" ? ( -
- {prLookup.message} -
- ) : (prLookup?.prs.length ?? 0) === 0 ? ( -
- {t("newTask.prNoneOfYours")} + {/* Chips only once the forge has answered: a lookup that failed + already says why, and an "All open" under it would only fail + the same way a second time. */} + {prLookup?.status === "ok" && ( +
+ {([ + ["you", t("newTask.prScopeYou")], + ["open", t("newTask.prScopeOpen")], + ] as const).map(([v, label]) => ( + + ))}
- ) : ( -
    - {(prLookup?.prs ?? []).map(prRow)} -
)} - {/* BELOW yours, not above: it lands on its own clock, and a list - that arrives late must not push down the rows you may be - about to click. Shown only when there is something in it. */} - {prReviewLookup?.status === "ok" && prReviewLookup.prs.length > 0 && ( -
-
- {t("newTask.prReviewTitle")} + + {prView === "open" ? ( + prOpenLoading ? ( +
+ {t("newTask.loadingOpenPrs")}
-
    - {prReviewLookup.prs.map(prRow)} -
-
+ ) : prOpenLookup && prOpenLookup.status !== "ok" ? ( +
+ {prOpenLookup.message} +
+ ) : (prOpenLookup?.prs.length ?? 0) === 0 ? ( +
+ {t("newTask.prNoneOpen")} +
+ ) : ( + <> +
    + {(prOpenLookup?.prs ?? []).map(prRow)} +
+ {/* A full page means there may be more: say so, and where + the rest are, rather than letting the list pass for all. */} + {(prOpenLookup?.prs.length ?? 0) >= PR_OPEN_LIMIT && ( +

+ {t("newTask.prOpenCapped", { count: PR_OPEN_LIMIT })} +

+ )} + + ) + ) : ( + <> + {prLoading ? ( +
+ {t("newTask.loadingPrs")} +
+ ) : prLookup && prLookup.status !== "ok" ? ( +
+ {prLookup.message} +
+ ) : (prLookup?.prs.length ?? 0) === 0 ? ( +
+ {t("newTask.prNoneOfYours")} +
+ ) : ( +
    + {(prLookup?.prs ?? []).map(prRow)} +
+ )} + {/* BELOW yours, not above: it lands on its own clock, and a list + that arrives late must not push down the rows you may be + about to click. Shown only when there is something in it. */} + {prReviewLookup?.status === "ok" && prReviewLookup.prs.length > 0 && ( +
+
+ {t("newTask.prReviewTitle")} +
+
    + {prReviewLookup.prs.map(prRow)} +
+
+ )} + )} {prBusy > 0 && (
diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index a93ac35b1..bb0390c38 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -9,7 +9,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { Project, ProjectMember, Task, CreateTaskArgs, CreateMultiArgs, CreateMultiMember, Settings, DiscoveredRepo, ImportableWorktree, CliInfo, ChangeFile, Changes, GitStatus, CheckoutResult, UpdateMode, UpdateResult, UpdateInfo, UpdateAllItem, FileEntry, Agent, RepoConfig, - SandboxMode, TaskDiffSummary, TaskDiffStat, DesktopIntegration, CliInstallStatus, McpStatus, BranchContext, PrPickList, BlameFile, GitCommit, GitCompare, GitFile, GitLogPage, GitRef, + SandboxMode, TaskDiffSummary, TaskDiffStat, DesktopIntegration, CliInstallStatus, McpStatus, BranchContext, PrPickList, PrScope, BlameFile, GitCommit, GitCompare, GitFile, GitLogPage, GitRef, ForgeCliStatus, ForgeProvider, PrLookup, MemberPrLookup, PrComment, IssueLookup, IssueScope, AgentHookStatus, HookPlan, ProfileView, ProfilesView, ProfileDeletePreview, AgentAccountsView, ExternalAppInfo, SyncStatus, SyncConnectInfo, SyncChange, SyncPrefsSnapshot, SyncRunResult, @@ -994,11 +994,12 @@ export const projectForgeIssues = (projectId: string, limit?: number, scope?: Is invoke("project_forge_issues", { projectId, limit: limit ?? null, scope: scope ?? null }); /** YOUR open PRs for a project's repo, or one PR by number. See `ForgePr` for - * why the list is only yours. `review` lists the open PRs waiting on your - * review instead (GitHub only; other forges answer an empty list). */ -export const projectForgePrs = (projectId: string, opts?: { limit?: number; number?: number; review?: boolean }) => + * why that is the default list. `scope: "review"` lists the open PRs waiting + * on your review instead (GitHub and GitLab; Azure answers an empty list), + * and `scope: "open"` the repo's open PRs, newest first. */ +export const projectForgePrs = (projectId: string, opts?: { limit?: number; number?: number; scope?: PrScope }) => invoke("project_forge_prs", { - projectId, limit: opts?.limit ?? null, number: opts?.number ?? null, review: opts?.review ?? null, + projectId, limit: opts?.limit ?? null, number: opts?.number ?? null, scope: opts?.scope ?? null, }); /** Fetch a PR's head into a local branch and get its name back, so the diff --git a/src/lib/types.ts b/src/lib/types.ts index 1b19a3070..9d567d88e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1339,6 +1339,10 @@ export interface ForgePr { updated_at: string; } +/** Which PRs `project_forge_prs` lists (forge.rs `PrScope`): the user's own, + * the ones waiting on their review, or the repo's open PRs, newest first. */ +export type PrScope = "mine" | "review" | "open"; + /** One PR-picker round-trip. `not-found` is the by-number path's "no such * PR", which is an answer rather than a failure. Mirrors `PrPickList` in * src-tauri/src/lib.rs. */ diff --git a/src/locales/en/dialogs.ts b/src/locales/en/dialogs.ts index 0c8de1023..f30693c1e 100644 --- a/src/locales/en/dialogs.ts +++ b/src/locales/en/dialogs.ts @@ -393,16 +393,21 @@ export default { sourceIssue: "From an issue", sourcePr: "From a PR", prColumnTitle: "Start from a pull request", - prColumnIntro: "Your open pull requests, and the ones waiting on your review. Anything else, paste its number or URL: on a busy repo a full list is neither fast nor useful.", + prColumnIntro: "Your open pull requests, and the ones waiting on your review. All open lists the repo's newest; for anything older, paste its number or URL.", prNumberPlaceholder: "123, #123 or a PR URL", prOpen: "Open", prNumberUnparsed: "That is not a pull request number or URL.", prReviewTitle: "Waiting for your review", - prNoneOfYours: "You have no open pull requests in this repo. Paste a number above to use somebody else's.", + prNoneOfYours: "You have no open pull requests in this repo. Pick All open, or paste a number above, to use somebody else's.", + prScopeYou: "For you", + prScopeOpen: "All open", + prNoneOpen: "No open pull requests in this repo.", + prOpenCapped: "Showing the newest {{count}}. Paste a number above for an older one.", prDraft: "draft", prFork: "fork", prFetching: "Fetching the branch…", loadingPrs: "Loading your pull requests…", + loadingOpenPrs: "Loading open pull requests…", fromWorkItem: "From an {{forge}} work item", needsCli: "(needs {{cli}})", needsSignIn: "(sign in needed)", diff --git a/src/locales/zh-CN/dialogs.ts b/src/locales/zh-CN/dialogs.ts index c9f6f6acd..bfc0b541f 100644 --- a/src/locales/zh-CN/dialogs.ts +++ b/src/locales/zh-CN/dialogs.ts @@ -392,16 +392,21 @@ export default { sourceIssue: "从 issue 创建", sourcePr: "从 PR 创建", prColumnTitle: "从拉取请求开始", - prColumnIntro: "你的未关闭拉取请求,以及等待你审查的拉取请求。其他的请粘贴编号或链接:在繁忙的仓库里,完整列表既不快也没用。", + prColumnIntro: "你的未关闭拉取请求,以及等待你审查的拉取请求。「全部未关闭」列出仓库最新的拉取请求;更早的请粘贴编号或链接。", prNumberPlaceholder: "123、#123 或 PR 链接", prOpen: "打开", prNumberUnparsed: "这不是拉取请求的编号或链接。", prReviewTitle: "等待你审查", - prNoneOfYours: "你在该仓库没有未关闭的拉取请求。可在上方粘贴编号以使用他人的 PR。", + prNoneOfYours: "你在该仓库没有未关闭的拉取请求。可选择「全部未关闭」,或在上方粘贴编号,以使用他人的 PR。", + prScopeYou: "与你相关", + prScopeOpen: "全部未关闭", + prNoneOpen: "该仓库没有未关闭的拉取请求。", + prOpenCapped: "仅显示最新的 {{count}} 个。更早的请在上方粘贴编号。", prDraft: "草稿", prFork: "复刻", prFetching: "正在获取分支…", loadingPrs: "正在加载你的拉取请求…", + loadingOpenPrs: "正在加载未关闭的拉取请求…", fromWorkItem: "从 {{forge}} work item 创建", needsCli: "(需要 {{cli}})", needsSignIn: "(需要登录)",