From 22759aa8765b4ac2774f6d55052148aee0fc344b Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:32:47 +0800 Subject: [PATCH] feat(github): add pull request level actions --- .../crates/integrations/src/github/client.rs | 5 + .../integrations/src/github/commands/pulls.rs | 539 +++++++++++++++++- src-tauri/src/commands/handler_list.inc | 4 + src/api/tauri/github/pullRequests.test.ts | 64 +++ src/api/tauri/github/pullRequests.ts | 75 ++- .../__tests__/TEST_CASES.md | 15 + .../detail/PrConversationTab.test.ts | 3 + .../detail/PrDetailPanel.test.ts | 15 + .../detail/PrDetailPanel.tsx | 57 +- .../detail/PrLevelActions.tsx | 351 ++++++++++++ .../hooks/useWorkstationPrDetail.test.ts | 93 +++ .../hooks/useWorkstationPrDetail.ts | 203 +++++++ .../TabContent/renderers/githubPrDetail.tsx | 18 +- src/shared/pr/prLevelActions.test.ts | 175 ++++++ src/shared/pr/prLevelActions.ts | 215 +++++++ .../codeEditor/workstationSelectedPrAtom.ts | 11 + 16 files changed, 1825 insertions(+), 18 deletions(-) create mode 100644 src/api/tauri/github/pullRequests.test.ts create mode 100644 src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrLevelActions.tsx create mode 100644 src/shared/pr/prLevelActions.test.ts create mode 100644 src/shared/pr/prLevelActions.ts diff --git a/src-tauri/crates/integrations/src/github/client.rs b/src-tauri/crates/integrations/src/github/client.rs index ddb9a0e38..39a1580b0 100644 --- a/src-tauri/crates/integrations/src/github/client.rs +++ b/src-tauri/crates/integrations/src/github/client.rs @@ -179,6 +179,11 @@ impl GitHubClient { self.request(Method::POST, path, Some(body)).await } + /// PUT request to the GitHub REST API with a JSON body. + pub async fn put(&self, path: &str, body: Value) -> Result { + self.request(Method::PUT, path, Some(body)).await + } + /// PATCH request to the GitHub REST API with a JSON body. pub async fn patch(&self, path: &str, body: Value) -> Result { self.request(Method::PATCH, path, Some(body)).await diff --git a/src-tauri/crates/integrations/src/github/commands/pulls.rs b/src-tauri/crates/integrations/src/github/commands/pulls.rs index 9d6c5c1d4..17dbd0d00 100644 --- a/src-tauri/crates/integrations/src/github/commands/pulls.rs +++ b/src-tauri/crates/integrations/src/github/commands/pulls.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::HashSet; use tauri::command; use super::super::client::GitHubClient; @@ -11,6 +12,51 @@ use super::shared::make_client; const GITHUB_PAGE_SIZE: usize = 100; +const ENABLE_AUTO_MERGE_MUTATION: &str = r#" +mutation EnablePullRequestAutoMerge($input: EnablePullRequestAutoMergeInput!) { + enablePullRequestAutoMerge(input: $input) { + pullRequest { id } + } +} +"#; + +const DISABLE_AUTO_MERGE_MUTATION: &str = r#" +mutation DisablePullRequestAutoMerge($input: DisablePullRequestAutoMergeInput!) { + disablePullRequestAutoMerge(input: $input) { + pullRequest { id } + } +} +"#; + +const ENQUEUE_PULL_REQUEST_MUTATION: &str = r#" +mutation EnqueuePullRequest($input: EnqueuePullRequestInput!) { + enqueuePullRequest(input: $input) { + mergeQueueEntry { id } + } +} +"#; + +const DEQUEUE_PULL_REQUEST_MUTATION: &str = r#" +mutation DequeuePullRequest($input: DequeuePullRequestInput!) { + dequeuePullRequest(input: $input) { + mergeQueueEntry { id } + } +} +"#; + +const PULL_REQUEST_MERGE_AUTOMATION_QUERY: &str = r#" +query PullRequestMergeAutomation($id: ID!) { + node(id: $id) { + ... on PullRequest { + isMergeQueueEnabled + mergeQueueEntry { id } + mergeStateStatus + reviewDecision + } + } +} +"#; + fn paged_path(base_path: &str, page: usize) -> String { let separator = if base_path.contains('?') { '&' } else { '?' }; format!("{base_path}{separator}per_page={GITHUB_PAGE_SIZE}&page={page}") @@ -204,6 +250,455 @@ fn validate_pull_request_state(state: String) -> Result { } } +fn validate_merge_method(method: &str) -> Result<&'static str, String> { + match method { + "merge" => Ok("merge"), + "squash" => Ok("squash"), + "rebase" => Ok("rebase"), + _ => Err(format!( + "Invalid pull request merge method `{method}`; expected merge, squash, or rebase" + )), + } +} + +fn graphql_merge_method(method: &str) -> Result<&'static str, String> { + match validate_merge_method(method)? { + "merge" => Ok("MERGE"), + "squash" => Ok("SQUASH"), + "rebase" => Ok("REBASE"), + _ => unreachable!("validate_merge_method returns only known methods"), + } +} + +fn graphql_error(response: &Value) -> Option { + let messages = response["errors"] + .as_array()? + .iter() + .filter_map(|error| error["message"].as_str()) + .collect::>(); + (!messages.is_empty()).then(|| messages.join("; ")) +} + +#[derive(Default)] +struct PullRequestMergeAutomationContext { + merge_queue_enabled: bool, + merge_queue_entry_id: Option, + merge_state_status: Option, + review_decision: Option, +} + +impl PullRequestMergeAutomationContext { + fn ready_for_merge_queue(&self) -> bool { + self.merge_state_status.as_deref() == Some("CLEAN") + && !matches!( + self.review_decision.as_deref(), + Some("REVIEW_REQUIRED" | "CHANGES_REQUESTED") + ) + } +} + +async fn get_pull_request_merge_automation_context( + client: &GitHubClient, + pull_request_id: &str, +) -> Result { + let response = client + .graphql( + PULL_REQUEST_MERGE_AUTOMATION_QUERY, + json!({ "id": pull_request_id }), + ) + .await?; + if let Some(error) = graphql_error(&response) { + return Err(error); + } + let pull_request = &response["data"]["node"]; + if pull_request.is_null() { + return Err("GitHub did not return pull request merge metadata".to_string()); + } + Ok(PullRequestMergeAutomationContext { + merge_queue_enabled: pull_request["isMergeQueueEnabled"] + .as_bool() + .unwrap_or(false), + merge_queue_entry_id: pull_request["mergeQueueEntry"]["id"] + .as_str() + .map(String::from), + merge_state_status: pull_request["mergeStateStatus"].as_str().map(String::from), + review_decision: pull_request["reviewDecision"].as_str().map(String::from), + }) +} + +fn normalize_reviewer_logins(reviewers: Vec) -> Result, String> { + let mut seen = HashSet::new(); + let normalized = reviewers + .into_iter() + .filter_map(|reviewer| { + let reviewer = reviewer.trim().to_string(); + if reviewer.is_empty() || !seen.insert(reviewer.to_lowercase()) { + None + } else { + Some(reviewer) + } + }) + .collect::>(); + if normalized.is_empty() { + return Err("At least one reviewer login is required".to_string()); + } + Ok(normalized) +} + +fn parse_requested_reviewers(value: &Value) -> Vec { + value["requested_reviewers"] + .as_array() + .or_else(|| value["users"].as_array()) + .map(|reviewers| reviewers.iter().map(parse_issue_user).collect()) + .unwrap_or_default() +} + +fn build_merge_payload(method: &str, expected_head_sha: Option<&str>) -> Result { + let method = validate_merge_method(method)?; + let mut payload = json!({ "merge_method": method }); + if let Some(expected_head_sha) = expected_head_sha { + payload["sha"] = json!(expected_head_sha); + } + Ok(payload) +} + +struct AutoMergeGraphqlRequest { + mutation: &'static str, + mutation_field: &'static str, + input: Value, +} + +fn build_auto_merge_graphql_request( + enabled: bool, + method: Option<&str>, + pull_request_id: &str, + expected_head_oid: &str, +) -> Result { + if enabled { + let merge_method = graphql_merge_method(method.unwrap_or("merge"))?; + Ok(AutoMergeGraphqlRequest { + mutation: ENABLE_AUTO_MERGE_MUTATION, + mutation_field: "enablePullRequestAutoMerge", + input: json!({ + "pullRequestId": pull_request_id, + "expectedHeadOid": expected_head_oid, + "mergeMethod": merge_method, + }), + }) + } else { + Ok(AutoMergeGraphqlRequest { + mutation: DISABLE_AUTO_MERGE_MUTATION, + mutation_field: "disablePullRequestAutoMerge", + input: json!({ + "pullRequestId": pull_request_id, + }), + }) + } +} + +fn build_merge_queue_graphql_request( + enabled: bool, + pull_request_id: &str, + merge_queue_entry_id: Option<&str>, + expected_head_oid: &str, +) -> Result { + if enabled { + Ok(AutoMergeGraphqlRequest { + mutation: ENQUEUE_PULL_REQUEST_MUTATION, + mutation_field: "enqueuePullRequest", + input: json!({ + "pullRequestId": pull_request_id, + "expectedHeadOid": expected_head_oid, + }), + }) + } else { + let merge_queue_entry_id = merge_queue_entry_id + .ok_or_else(|| "GitHub did not return the merge queue entry ID".to_string())?; + Ok(AutoMergeGraphqlRequest { + mutation: DEQUEUE_PULL_REQUEST_MUTATION, + mutation_field: "dequeuePullRequest", + input: json!({ "id": merge_queue_entry_id }), + }) + } +} + +#[derive(Debug, Serialize)] +pub struct PullRequestMergeResult { + pub sha: String, + pub merged: bool, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub struct PullRequestAutoMergeResult { + pub enabled: bool, +} + +#[command] +pub async fn github_merge_pr( + repo_full_name: String, + pr_number: u64, + method: String, + expected_head_sha: Option, +) -> Result { + let method = validate_merge_method(&method)?; + log::info!("[GitHub][Cmd] merge_pr repo={repo_full_name} pr={pr_number} method={method}"); + let client = make_client()?; + let payload = build_merge_payload(method, expected_head_sha.as_deref())?; + let data = client + .put( + &format!("/repos/{repo_full_name}/pulls/{pr_number}/merge"), + payload, + ) + .await?; + let message = data["message"] + .as_str() + .unwrap_or("GitHub did not merge the pull request") + .to_string(); + let merged = data["merged"].as_bool().unwrap_or(false); + if !merged { + return Err(message); + } + Ok(PullRequestMergeResult { + sha: data["sha"].as_str().unwrap_or("").to_string(), + merged, + message, + }) +} + +#[command] +pub async fn github_set_pr_auto_merge( + repo_full_name: String, + pr_number: u64, + enabled: bool, + method: Option, + expected_head_sha: Option, +) -> Result { + log::info!( + "[GitHub][Cmd] set_pr_auto_merge repo={repo_full_name} pr={pr_number} enabled={enabled}" + ); + let client = make_client()?; + let detail = client + .get(&format!("/repos/{repo_full_name}/pulls/{pr_number}")) + .await?; + if detail["state"].as_str() != Some("open") || detail["merged"].as_bool() == Some(true) { + return Err("Auto-merge is available only for open pull requests".to_string()); + } + if enabled && detail["draft"].as_bool() == Some(true) { + return Err( + "Mark this pull request ready for review before enabling auto-merge".to_string(), + ); + } + let pull_request_id = detail["node_id"] + .as_str() + .ok_or_else(|| "GitHub did not return the pull request node ID".to_string())?; + let current_head_sha = detail["head"]["sha"] + .as_str() + .ok_or_else(|| "GitHub did not return the pull request head SHA".to_string())?; + let expected_head_oid = expected_head_sha.as_deref().unwrap_or(current_head_sha); + if expected_head_oid != current_head_sha { + return Err( + "The pull request head changed; refresh before changing auto-merge".to_string(), + ); + } + + let context = match get_pull_request_merge_automation_context(&client, pull_request_id).await { + Ok(context) => context, + Err(error) if error.contains("GitHubReAuthRequired") => return Err(error), + Err(error) => { + log::warn!("[GitHub][Cmd] merge automation metadata unavailable: {error}"); + PullRequestMergeAutomationContext::default() + } + }; + let request = if context.merge_queue_enabled + && ((enabled && context.ready_for_merge_queue()) + || (!enabled && context.merge_queue_entry_id.is_some())) + { + build_merge_queue_graphql_request( + enabled, + pull_request_id, + context.merge_queue_entry_id.as_deref(), + expected_head_oid, + )? + } else { + build_auto_merge_graphql_request( + enabled, + method.as_deref(), + pull_request_id, + expected_head_oid, + )? + }; + let response = client + .graphql(request.mutation, json!({ "input": request.input })) + .await?; + if let Some(error) = graphql_error(&response) { + return Err(error); + } + if response["data"][request.mutation_field].is_null() { + return Err("GitHub did not confirm the auto-merge change".to_string()); + } + Ok(PullRequestAutoMergeResult { enabled }) +} + +#[cfg(test)] +mod pr_action_payload_tests { + use super::*; + + #[test] + fn merge_payload_uses_an_allowed_method_and_expected_head_sha() { + assert_eq!( + build_merge_payload("squash", Some("head-sha")).unwrap(), + json!({ "merge_method": "squash", "sha": "head-sha" }) + ); + assert!(build_merge_payload("octopus", None).is_err()); + } + + #[test] + fn auto_merge_payloads_match_each_mutations_input_schema() { + let enable = + build_auto_merge_graphql_request(true, Some("rebase"), "pull-request-node", "head-sha") + .unwrap(); + assert_eq!(enable.mutation_field, "enablePullRequestAutoMerge"); + assert_eq!( + enable.input, + json!({ + "pullRequestId": "pull-request-node", + "expectedHeadOid": "head-sha", + "mergeMethod": "REBASE", + }) + ); + + let disable = + build_auto_merge_graphql_request(false, None, "pull-request-node", "head-sha").unwrap(); + assert_eq!(disable.mutation_field, "disablePullRequestAutoMerge"); + assert_eq!( + disable.input, + json!({ + "pullRequestId": "pull-request-node", + }) + ); + } + + #[test] + fn merge_queue_payloads_enqueue_by_pr_and_dequeue_by_entry() { + let enqueue = + build_merge_queue_graphql_request(true, "pull-request-node", None, "head-sha").unwrap(); + assert_eq!(enqueue.mutation_field, "enqueuePullRequest"); + assert_eq!( + enqueue.input, + json!({ + "pullRequestId": "pull-request-node", + "expectedHeadOid": "head-sha", + }) + ); + + let dequeue = build_merge_queue_graphql_request( + false, + "pull-request-node", + Some("queue-entry"), + "head-sha", + ) + .unwrap(); + assert_eq!(dequeue.mutation_field, "dequeuePullRequest"); + assert_eq!(dequeue.input, json!({ "id": "queue-entry" })); + assert!( + build_merge_queue_graphql_request(false, "pull-request-node", None, "head-sha") + .is_err() + ); + } + + #[test] + fn merge_queue_requires_clean_merge_state_without_review_blockers() { + let ready = PullRequestMergeAutomationContext { + merge_state_status: Some("CLEAN".to_string()), + ..Default::default() + }; + let review_blocked = PullRequestMergeAutomationContext { + merge_state_status: Some("CLEAN".to_string()), + review_decision: Some("REVIEW_REQUIRED".to_string()), + ..Default::default() + }; + let checks_blocked = PullRequestMergeAutomationContext { + merge_state_status: Some("BLOCKED".to_string()), + ..Default::default() + }; + + assert!(ready.ready_for_merge_queue()); + assert!(!review_blocked.ready_for_merge_queue()); + assert!(!checks_blocked.ready_for_merge_queue()); + } + + #[test] + fn reviewer_logins_are_trimmed_and_deduplicated_case_insensitively() { + assert_eq!( + normalize_reviewer_logins(vec![ + " Reviewer ".to_string(), + "reviewer".to_string(), + "second".to_string(), + " ".to_string(), + ]) + .unwrap(), + vec!["Reviewer".to_string(), "second".to_string()] + ); + assert!(normalize_reviewer_logins(vec![" ".to_string()]).is_err()); + } + + #[test] + fn graphql_errors_are_preserved_for_the_frontend() { + assert_eq!( + graphql_error(&json!({ + "errors": [ + { "message": "Auto-merge is disabled" }, + { "message": "Approval is required" } + ] + })), + Some("Auto-merge is disabled; Approval is required".to_string()) + ); + } +} + +#[command] +pub async fn github_request_pr_reviewers( + repo_full_name: String, + pr_number: u64, + reviewers: Vec, +) -> Result, String> { + let reviewers = normalize_reviewer_logins(reviewers)?; + log::info!( + "[GitHub][Cmd] request_pr_reviewers repo={repo_full_name} pr={pr_number} count={}", + reviewers.len() + ); + let client = make_client()?; + let data = client + .post( + &format!("/repos/{repo_full_name}/pulls/{pr_number}/requested_reviewers"), + json!({ "reviewers": reviewers }), + ) + .await?; + Ok(parse_requested_reviewers(&data)) +} + +#[command] +pub async fn github_remove_pr_reviewers( + repo_full_name: String, + pr_number: u64, + reviewers: Vec, +) -> Result, String> { + let reviewers = normalize_reviewer_logins(reviewers)?; + log::info!( + "[GitHub][Cmd] remove_pr_reviewers repo={repo_full_name} pr={pr_number} count={}", + reviewers.len() + ); + let client = make_client()?; + let data = client + .delete_with_body( + &format!("/repos/{repo_full_name}/pulls/{pr_number}/requested_reviewers"), + json!({ "reviewers": reviewers }), + ) + .await?; + Ok(parse_requested_reviewers(&data)) +} + #[cfg(test)] mod open_pr_item_tests { use super::*; @@ -329,15 +824,45 @@ pub async fn github_get_pr(repo_full_name: String, pr_number: u64) -> Result Some(get_pull_request_merge_automation_context(&client, id).await), + None => None, + } + }; + let compare = async { + match (base_sha, head_sha) { + (Some(base_sha), Some(head_sha)) => Some( + client + .get_conditional(&format!( + "/repos/{repo_full_name}/compare/{base_sha}...{head_sha}" + )) + .await, + ), + _ => None, + } + }; + let (merge_context, compare) = tokio::join!(merge_context, compare); + + if let Some(result) = merge_context { + match result { + Ok(context) => { + detail["merge_queue_required"] = json!(context.merge_queue_enabled); + detail["is_in_merge_queue"] = json!(context.merge_queue_entry_id.is_some()); + if let Some(review_decision) = context.review_decision { + detail["review_decision"] = json!(review_decision); + } + } + Err(error) => { + log::warn!("[GitHub][Cmd] get_pr merge metadata failed: {error}"); + } + } + } + if let Some(result) = compare { + match result { Ok(compare) => { if let Some(merge_base_sha) = compare["merge_base_commit"]["sha"].as_str() { detail["merge_base_sha"] = json!(merge_base_sha); diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 4a62b153c..1199d9812 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -172,6 +172,10 @@ integrations::github::commands::github_create_pr, integrations::github::commands::github_find_pull_request, integrations::github::commands::github_list_prs, integrations::github::commands::github_update_pr_state, +integrations::github::commands::github_merge_pr, +integrations::github::commands::github_set_pr_auto_merge, +integrations::github::commands::github_request_pr_reviewers, +integrations::github::commands::github_remove_pr_reviewers, integrations::github::commands::github_get_pr, integrations::github::commands::github_list_pr_commits, integrations::github::commands::github_list_pr_files, diff --git a/src/api/tauri/github/pullRequests.test.ts b/src/api/tauri/github/pullRequests.test.ts new file mode 100644 index 000000000..e15bde950 --- /dev/null +++ b/src/api/tauri/github/pullRequests.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + mergePRLocal, + removePRReviewersLocal, + requestPRReviewersLocal, + setPRAutoMergeLocal, +} from "./pullRequests"; + +const mocks = vi.hoisted(() => ({ + invokeWithAuth: vi.fn(), +})); + +vi.mock("./client", () => ({ + invokeWithAuth: mocks.invokeWithAuth, +})); + +describe("pull request action IPC payloads", () => { + beforeEach(() => { + mocks.invokeWithAuth.mockReset().mockResolvedValue({}); + }); + + it("sends the merge method and expected head SHA", async () => { + await mergePRLocal("org/repo", 42, "squash", "head-sha"); + + expect(mocks.invokeWithAuth).toHaveBeenCalledWith("github_merge_pr", { + repoFullName: "org/repo", + prNumber: 42, + method: "squash", + expectedHeadSha: "head-sha", + }); + }); + + it("sends a complete auto-merge toggle payload", async () => { + await setPRAutoMergeLocal("org/repo", 42, true, "rebase", "head-sha"); + + expect(mocks.invokeWithAuth).toHaveBeenCalledWith( + "github_set_pr_auto_merge", + { + repoFullName: "org/repo", + prNumber: 42, + enabled: true, + method: "rebase", + expectedHeadSha: "head-sha", + } + ); + }); + + it("uses distinct commands for adding and removing requested reviewers", async () => { + await requestPRReviewersLocal("org/repo", 42, ["reviewer"]); + await removePRReviewersLocal("org/repo", 42, ["reviewer"]); + + expect(mocks.invokeWithAuth).toHaveBeenNthCalledWith( + 1, + "github_request_pr_reviewers", + { repoFullName: "org/repo", prNumber: 42, reviewers: ["reviewer"] } + ); + expect(mocks.invokeWithAuth).toHaveBeenNthCalledWith( + 2, + "github_remove_pr_reviewers", + { repoFullName: "org/repo", prNumber: 42, reviewers: ["reviewer"] } + ); + }); +}); diff --git a/src/api/tauri/github/pullRequests.ts b/src/api/tauri/github/pullRequests.ts index f8f86df7e..f9b1ad429 100644 --- a/src/api/tauri/github/pullRequests.ts +++ b/src/api/tauri/github/pullRequests.ts @@ -7,7 +7,11 @@ import { invoke } from "@tauri-apps/api/core"; import { appendPullRequestAttributionFooter } from "@src/services/git/operations/commitAttribution"; import { invokeWithAuth } from "./client"; -import type { LocalFindPRResponse, LocalPRResponse } from "./types"; +import type { + GitHubIssueUser, + LocalFindPRResponse, + LocalPRResponse, +} from "./types"; export async function createPRLocal( repoFullName: string, @@ -79,6 +83,75 @@ export async function updatePRStateLocal( }); } +export type PullRequestMergeMethod = "merge" | "squash" | "rebase"; + +export interface PullRequestMergeResult { + sha: string; + merged: boolean; + message: string; +} + +export interface PullRequestAutoMergeResult { + enabled: boolean; +} + +export async function mergePRLocal( + repoFullName: string, + prNumber: number, + method: PullRequestMergeMethod, + expectedHeadSha?: string +): Promise { + return invokeWithAuth("github_merge_pr", { + repoFullName, + prNumber, + method, + expectedHeadSha: expectedHeadSha ?? null, + }); +} + +export async function setPRAutoMergeLocal( + repoFullName: string, + prNumber: number, + enabled: boolean, + method?: PullRequestMergeMethod, + expectedHeadSha?: string +): Promise { + return invokeWithAuth( + "github_set_pr_auto_merge", + { + repoFullName, + prNumber, + enabled, + method: method ?? null, + expectedHeadSha: expectedHeadSha ?? null, + } + ); +} + +export async function requestPRReviewersLocal( + repoFullName: string, + prNumber: number, + reviewers: string[] +): Promise { + return invokeWithAuth("github_request_pr_reviewers", { + repoFullName, + prNumber, + reviewers, + }); +} + +export async function removePRReviewersLocal( + repoFullName: string, + prNumber: number, + reviewers: string[] +): Promise { + return invokeWithAuth("github_remove_pr_reviewers", { + repoFullName, + prNumber, + reviewers, + }); +} + /** * Which fetch strategy the backend used to resolve a PR head into a SHA. * Mirrors the Rust `PrBaseSource` enum (serialized camelCase). diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/__tests__/TEST_CASES.md b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/__tests__/TEST_CASES.md index dec5f7376..e3037a422 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/__tests__/TEST_CASES.md +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/__tests__/TEST_CASES.md @@ -62,6 +62,21 @@ conversation comments, submitted review bodies, and inline review threads. | 3 | Long inline review-thread reply | Expand Review comments and open a reply over 15 lines | Reply uses the same 15-line preview and always-visible expand/collapse control | | 4 | Conversation container styling | Open a PR with description, comments, and review threads | Timeline and review-thread cards use the Settings container background, rounded border, and no shadow | +## PR-level actions + +| # | Scenario | Steps | Expected Result | +| --- | ----------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Clean mergeable PR | Open an open PR with passing checks | Shared action row shows the repository's enabled merge methods; the primary action confirms and merges the current head SHA | +| 2 | Pending checks or policy gate | Open an open PR with pending checks or unmet merge policy | Direct methods are disabled and `Enable auto-merge` asks GitHub to merge after requirements pass | +| 3 | Existing auto-merge request | Open a PR whose `auto_merge` field is populated | Primary action and menu offer `Disable auto-merge` | +| 4 | Merge conflict | Open a PR with `mergeable=false` or `mergeable_state=dirty` | Direct merge is disabled with a conflict explanation; no unsafe merge request is sent | +| 5 | Reviewer management | Open Reviewers, select or remove a direct user | Searchable picker requests/removes that reviewer and refreshes authoritative PR detail | +| 6 | Close and reopen | Close an open PR, then reopen the resulting closed PR | Close requires confirmation; both mutations refresh the header, status summary, and action row | +| 7 | Whole-PR review | Enter an optional review body and click Approve or changes | Existing whole-PR review submission remains available in Conversation; request-changes requires a non-empty body | +| 8 | Shared hosts | Open the PR from Source Control, My Station, or the Chat panel | The same shared action row is present and status changes reconcile through the scoped PR atom rather than host-specific handlers | +| 9 | Visual treatment | Inspect the PR action row in light and dark themes | The row adds no background or enclosing border; controls use design-system Button and Dropdown components | +| 10 | Merge queue branch | Open a PR whose base branch requires GitHub merge queue | Direct merge methods are disabled; `Merge when ready` enables waiting or queues a ready PR, and a queued PR can be removed | + ## Accessibility - [ ] `Open on GitHub` control is a real anchor: keyboard-focusable (Tab), diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrConversationTab.test.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrConversationTab.test.ts index c7d01dd0c..5718b14bb 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrConversationTab.test.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrConversationTab.test.ts @@ -77,6 +77,9 @@ describe("PrConversationTab", () => { expect(editor?.getAttribute("data-appearance")).toBe("plain"); expect(editor?.getAttribute("data-toolbar-mode")).toBe("inline"); expect(composer?.querySelector(".flex-shrink-0")).toBeNull(); + expect(composer?.textContent).toContain("Approve"); + expect(composer?.textContent).toContain("Request changes"); + expect(composer?.textContent).toContain("Comment"); }); it("restores a controlled review draft", () => { diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrDetailPanel.test.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrDetailPanel.test.ts index 3a053ae0c..7531c140c 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrDetailPanel.test.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrDetailPanel.test.ts @@ -54,6 +54,15 @@ vi.mock("../../../hooks/useWorkstationPrDetail", () => ({ addComment: vi.fn(), submitReview: vi.fn(), replyInlineComment: vi.fn(), + mergePullRequest: vi.fn(), + setPullRequestAutoMerge: vi.fn(), + updatePullRequestState: vi.fn(), + updateRequestedReviewers: vi.fn(), + loadReviewerCandidates: vi.fn().mockResolvedValue(undefined), + reviewerCandidates: [], + loadingReviewerCandidates: false, + reviewerCandidatesError: null, + prActionPending: false, }), })); @@ -158,6 +167,12 @@ describe("PrDetailPanel tabs", () => { expect(tabs[0]?.className).toContain("rounded-t-md"); expect(tabList?.className).toContain("border-b"); expect(tabList?.className).not.toContain("border-t"); + const actions = container.querySelector("[data-testid='pr-level-actions']"); + expect(actions?.textContent).toContain("Enable auto-merge"); + expect(actions?.textContent).toContain("Reviewers"); + expect(actions?.textContent).toContain("Close pull request"); + expect(actions?.className).not.toContain("bg-"); + expect(actions?.className).not.toContain("border"); act(() => { tabs[3]?.click(); diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrDetailPanel.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrDetailPanel.tsx index 26be3ea75..f1d5addf0 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrDetailPanel.tsx +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrDetailPanel.tsx @@ -37,6 +37,7 @@ import Button from "@src/components/Button"; import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import { HEADER_ICON_SIZE } from "@src/config/workstation/tokens"; import { PanelHeader, Placeholder } from "@src/modules/shared/layouts/blocks"; +import { resolvePullRequestDetailStatus } from "@src/shared/pr/prLevelActions"; import { type PrIdentity, workstationPrScopeKey, @@ -49,6 +50,7 @@ import { PrChecksTab } from "./PrChecksTab"; import { PrCommitsTab } from "./PrCommitsTab"; import { PrConversationTab } from "./PrConversationTab"; import { PrDetailHeaderContent } from "./PrDetailHeaderContent"; +import { PrLevelActions } from "./PrLevelActions"; export { PrDetailHeaderContent } from "./PrDetailHeaderContent"; @@ -341,12 +343,33 @@ export const PrDetailPanel: React.FC = ({ [setDetailViewState] ); - const { repoFullName, addComment, submitReview, replyInlineComment } = - useWorkstationPrDetail({ - repoPath, - repoId, - pr: identity, - }); + const { + repoFullName, + addComment, + submitReview, + replyInlineComment, + mergePullRequest, + setPullRequestAutoMerge, + updatePullRequestState, + updateRequestedReviewers, + loadReviewerCandidates, + reviewerCandidates, + loadingReviewerCandidates, + reviewerCandidatesError, + prActionPending, + } = useWorkstationPrDetail({ + repoPath, + repoId, + pr: identity, + }); + + const currentIdentity = useMemo( + () => ({ + ...identity, + status: resolvePullRequestDetailStatus(state.detail, identity.status), + }), + [identity, state.detail] + ); const baseBranch = state.baseRef ?? identity.baseBranch ?? t("git.pr.baseBranch", "base"); @@ -416,10 +439,26 @@ export const PrDetailPanel: React.FC = ({ ) } > - + ) : null} + + {/* GitHub-style PR navigation */}
= ({ = ({ /> } detail={state.detail} - identity={identity} + identity={currentIdentity} conversation={state.conversation} reviews={state.reviews} reviewComments={state.reviewComments} diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrLevelActions.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrLevelActions.tsx new file mode 100644 index 000000000..1bb5d8f87 --- /dev/null +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrLevelActions.tsx @@ -0,0 +1,351 @@ +import { + CircleDot, + GitMerge, + GitPullRequestClosed, + UserRound, +} from "lucide-react"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { + GitHubChecksSummary, + GitHubIssueUser, + PullRequestMergeMethod, +} from "@src/api/tauri/github"; +import Avatar from "@src/components/Avatar"; +import Button from "@src/components/Button"; +import Dropdown from "@src/components/Dropdown"; +import { DropdownItem, DropdownPanel } from "@src/components/Dropdown/exports"; +import { + DROPDOWN_CLASSES, + DROPDOWN_ITEM, + DROPDOWN_WIDTHS, +} from "@src/components/Dropdown/tokens"; +import Message from "@src/components/Message"; +import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; +import { + presentPullRequestActions, + readRequestedReviewers, +} from "@src/shared/pr/prLevelActions"; +import type { PrIdentity } from "@src/store/workstation/codeEditor/workstationSelectedPrAtom"; +import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; + +interface PrLevelActionsProps { + identity: PrIdentity; + detail: Record | null; + checks: GitHubChecksSummary | null; + disabled: boolean; + pending: boolean; + reviewerCandidates: GitHubIssueUser[]; + loadingReviewerCandidates: boolean; + reviewerCandidatesError: string | null; + onLoadReviewerCandidates: () => Promise; + onMerge: (method: PullRequestMergeMethod) => Promise; + onSetAutoMerge: ( + enabled: boolean, + method: PullRequestMergeMethod + ) => Promise; + onStateChange: (state: "open" | "closed") => Promise; + onRequestedReviewersChange: (reviewers: string[]) => Promise; +} + +export const PrLevelActions: React.FC = ({ + identity, + detail, + checks, + disabled, + pending, + reviewerCandidates, + loadingReviewerCandidates, + reviewerCandidatesError, + onLoadReviewerCandidates, + onMerge, + onSetAutoMerge, + onStateChange, + onRequestedReviewersChange, +}) => { + const { t } = useTranslation("common"); + const [mergeMenuVisible, setMergeMenuVisible] = useState(false); + const [reviewerMenuVisible, setReviewerMenuVisible] = useState(false); + const presentation = presentPullRequestActions({ + detail, + fallbackStatus: identity.status, + checks, + }); + const requestedReviewers = readRequestedReviewers(detail); + const requestedReviewerLogins = requestedReviewers.map( + (reviewer) => reviewer.login + ); + const interactionDisabled = disabled || pending; + const reviewerOptions = (() => { + const unique = new Map(); + for (const reviewer of [...requestedReviewers, ...reviewerCandidates]) { + unique.set(reviewer.login.toLowerCase(), reviewer); + } + return [...unique.values()].map((reviewer) => ({ + value: reviewer.login, + label: ( + + + {reviewer.login.charAt(0).toUpperCase()} + + {reviewer.login} + + ), + triggerLabel: reviewer.login, + })); + })(); + + const reportAction = async ( + action: () => Promise, + successMessage: string + ): Promise => { + try { + await action(); + Message.success(successMessage); + } catch (error) { + Message.error(error instanceof Error ? error.message : String(error)); + } + }; + + const merge = async (method: PullRequestMergeMethod): Promise => { + setMergeMenuVisible(false); + const confirmed = await confirmDestructiveAction({ + title: t("git.pr.actions.confirmMergeTitle", "Merge pull request?"), + message: t( + "git.pr.actions.confirmMergeMessage", + "GitHub will merge the current pull request head into the base branch." + ), + okLabel: t("git.pr.actions.merge", "Merge"), + cancelLabel: t("actions.cancel", "Cancel"), + }); + if (!confirmed) return; + await reportAction( + () => onMerge(method), + t("git.pr.actions.mergeSuccess", "Pull request merged") + ); + }; + + const toggleAutoMerge = async (): Promise => { + const action = presentation.autoMergeAction; + if (!action) return; + setMergeMenuVisible(false); + const enabled = action.kind === "enable"; + await reportAction( + () => onSetAutoMerge(enabled, presentation.defaultMethod), + action.label === "Merge when ready" + ? t("git.pr.actions.mergeRequested", "Merge requested") + : action.label === "Remove from merge queue" + ? t("git.pr.actions.removedFromQueue", "Removed from merge queue") + : enabled + ? t("git.pr.actions.autoMergeEnabled", "Auto-merge enabled") + : t("git.pr.actions.autoMergeDisabled", "Auto-merge disabled") + ); + }; + + const runPrimaryMergeAction = (): void => { + if (presentation.autoMergeAction?.kind === "disable") { + void toggleAutoMerge(); + } else if (presentation.directMergeAvailable) { + void merge(presentation.defaultMethod); + } else if (presentation.autoMergeAction?.kind === "enable") { + void toggleAutoMerge(); + } + }; + + const nextState = presentation.status === "closed" ? "open" : "closed"; + const canChangeState = presentation.status !== "merged"; + const changeState = async (): Promise => { + if (nextState === "closed") { + const confirmed = await confirmDestructiveAction({ + title: t("git.pr.actions.confirmCloseTitle", "Close pull request?"), + message: t( + "git.pr.actions.confirmCloseMessage", + "The pull request will remain available and can be reopened later." + ), + okLabel: t("git.pr.actions.close", "Close pull request"), + cancelLabel: t("actions.cancel", "Cancel"), + }); + if (!confirmed) return; + } + await reportAction( + () => onStateChange(nextState), + nextState === "closed" + ? t("git.pr.actions.closeSuccess", "Pull request closed") + : t("git.pr.actions.reopenSuccess", "Pull request reopened") + ); + }; + const mergePanel = ( + +
+ {presentation.autoMergeAction ? ( + <> + } + disabled={interactionDisabled} + onClick={() => void toggleAutoMerge()} + dataTestId="pr-auto-merge-action" + > + {t( + `git.pr.actions.${presentation.autoMergeAction.kind}AutoMerge`, + presentation.autoMergeAction.label + )} + +
+ + ) : null} + {presentation.methods.map(({ method, label }) => ( + } + disabled={interactionDisabled || !presentation.directMergeAvailable} + onClick={() => void merge(method)} + dataTestId={`pr-merge-${method}`} + > + {t(`git.pr.actions.${method}`, label)} + + ))} +
+ + ); + + const primaryDisabled = + interactionDisabled || + (!presentation.directMergeAvailable && !presentation.autoMergeAction); + + return ( +
+ + + { + setReviewerMenuVisible(visible); + if (visible) void onLoadReviewerCandidates(); + }} + getPopupContainer={() => document.body} + avoidViewportOverflow + className={`${DROPDOWN_CLASSES.panelAnimated} ${DROPDOWN_WIDTHS.fileTreeClass}`} + onSelect={(value) => { + const next = Array.isArray(value) + ? value.map(String) + : [String(value)]; + setReviewerMenuVisible(false); + void reportAction( + () => onRequestedReviewersChange(next), + t("git.pr.actions.reviewersUpdated", "Reviewers updated") + ); + }} + > + + + + {canChangeState ? ( + + ) : null} +
+ ); +}; + +PrLevelActions.displayName = "PrLevelActions"; diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationPrDetail.test.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationPrDetail.test.ts index b02a37bab..550a36c9c 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationPrDetail.test.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationPrDetail.test.ts @@ -54,7 +54,13 @@ const apiMocks = vi.hoisted(() => ({ listPRFilesLocal: vi.fn(), listPrReviewCommentsLocal: vi.fn(), listPrReviewsLocal: vi.fn(), + listRepoAssigneesLocal: vi.fn(), + mergePRLocal: vi.fn(), + removePRReviewersLocal: vi.fn(), + requestPRReviewersLocal: vi.fn(), replyPrReviewCommentLocal: vi.fn(), + setPRAutoMergeLocal: vi.fn(), + updatePRStateLocal: vi.fn(), })); vi.mock("@src/api/http/git/remotes", () => ({ @@ -72,7 +78,13 @@ vi.mock("@src/api/tauri/github", () => ({ listPRFilesLocal: apiMocks.listPRFilesLocal, listPrReviewCommentsLocal: apiMocks.listPrReviewCommentsLocal, listPrReviewsLocal: apiMocks.listPrReviewsLocal, + listRepoAssigneesLocal: apiMocks.listRepoAssigneesLocal, + mergePRLocal: apiMocks.mergePRLocal, + removePRReviewersLocal: apiMocks.removePRReviewersLocal, + requestPRReviewersLocal: apiMocks.requestPRReviewersLocal, replyPrReviewCommentLocal: apiMocks.replyPrReviewCommentLocal, + setPRAutoMergeLocal: apiMocks.setPRAutoMergeLocal, + updatePRStateLocal: apiMocks.updatePRStateLocal, })); const REPO_PATH = "C:\\repo"; @@ -161,7 +173,17 @@ describe("useWorkstationPrDetail cache mutations", () => { apiMocks.listPrReviewCommentsLocal.mockResolvedValue([]); apiMocks.listPRCommitsLocal.mockResolvedValue([]); apiMocks.listPRFilesLocal.mockResolvedValue([]); + apiMocks.listRepoAssigneesLocal.mockResolvedValue([]); apiMocks.createIssueCommentLocal.mockResolvedValue(COMMENT); + apiMocks.mergePRLocal.mockResolvedValue({ + sha: "merged-sha", + merged: true, + message: "merged", + }); + apiMocks.setPRAutoMergeLocal.mockResolvedValue({ enabled: true }); + apiMocks.updatePRStateLocal.mockResolvedValue({}); + apiMocks.requestPRReviewersLocal.mockResolvedValue([]); + apiMocks.removePRReviewersLocal.mockResolvedValue([]); // Only invoked when a bundle's headSha is truthy — most tests here use // the null-headSha default and never touch this, but any test opting // into a real headSha needs it to resolve (a bare `vi.fn()` returns @@ -520,4 +542,75 @@ describe("useWorkstationPrDetail cache mutations", () => { expect.objectContaining({ commitId: "sha-new" }) ); }); + + it("publishes one shared dispatcher for PR-level merge, auto-merge, state, and reviewer mutations", async () => { + const ACTION_PR: PrIdentity = { ...PR, number: 111_105 }; + const scopeKey = workstationPrScopeKey( + REPO_ID, + REPO_PATH, + ACTION_PR.number + ); + apiMocks.getPRLocal.mockResolvedValue({ + state: "open", + head: { sha: "expected-head" }, + base: { ref: "develop" }, + user: { login: "author" }, + requested_reviewers: [{ login: "old-reviewer", avatar_url: "" }], + }); + + await act(async () => { + root?.render( + React.createElement( + Provider, + { store }, + React.createElement(Harness, { pr: ACTION_PR }) + ) + ); + }); + await waitForStore( + store, + () => + store.get(workstationPrDetailCallbackAtomFamily(scopeKey)) + .mergePullRequest !== null + ); + const callbacks = store.get( + workstationPrDetailCallbackAtomFamily(scopeKey) + ); + + await act(async () => { + await callbacks.mergePullRequest?.("squash"); + await callbacks.setPullRequestAutoMerge?.(true, "rebase"); + await callbacks.updatePullRequestState?.("closed"); + await callbacks.updateRequestedReviewers?.(["new-reviewer"]); + }); + + expect(apiMocks.mergePRLocal).toHaveBeenCalledWith( + REPO_FULL_NAME, + ACTION_PR.number, + "squash", + "expected-head" + ); + expect(apiMocks.setPRAutoMergeLocal).toHaveBeenCalledWith( + REPO_FULL_NAME, + ACTION_PR.number, + true, + "rebase", + "expected-head" + ); + expect(apiMocks.updatePRStateLocal).toHaveBeenCalledWith( + REPO_FULL_NAME, + ACTION_PR.number, + "closed" + ); + expect(apiMocks.requestPRReviewersLocal).toHaveBeenCalledWith( + REPO_FULL_NAME, + ACTION_PR.number, + ["new-reviewer"] + ); + expect(apiMocks.removePRReviewersLocal).toHaveBeenCalledWith( + REPO_FULL_NAME, + ACTION_PR.number, + ["old-reviewer"] + ); + }); }); diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationPrDetail.ts b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationPrDetail.ts index 9e7afcc4b..49536bc4e 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationPrDetail.ts +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/useWorkstationPrDetail.ts @@ -18,7 +18,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getGitRemotes } from "@src/api/http/git/remotes"; import { type GitHubChecksSummary, + type GitHubIssueUser, type PrReviewEvent, + type PullRequestMergeMethod, createIssueCommentLocal, createPrReviewCommentLocal, createPrReviewLocal, @@ -29,7 +31,13 @@ import { listPRFilesLocal, listPrReviewCommentsLocal, listPrReviewsLocal, + listRepoAssigneesLocal, + mergePRLocal, + removePRReviewersLocal, replyPrReviewCommentLocal, + requestPRReviewersLocal, + setPRAutoMergeLocal, + updatePRStateLocal, } from "@src/api/tauri/github"; import { type CachedPrDetail, @@ -40,6 +48,7 @@ import { updateCachedPrDetail, } from "@src/services/git/githubListCache"; import { parseGithubRepoFullName } from "@src/services/git/operations/createPullRequest"; +import { readRequestedReviewers } from "@src/shared/pr/prLevelActions"; import { type PrIdentity, initialSelectedPrState, @@ -188,6 +197,19 @@ export function useWorkstationPrDetail({ // Freshest PR head SHA, kept in a ref so inline-comment creation can read it // without re-subscribing its callback on every atom write. const latestHeadShaRef = useRef(null); + const latestRequestedReviewersRef = useRef([]); + const latestAuthorLoginRef = useRef(null); + const prActionPendingRef = useRef(false); + const reviewerCandidatesAttemptedRef = useRef(false); + const [prActionPending, setPrActionPending] = useState(false); + const [reviewerCandidates, setReviewerCandidates] = useState< + GitHubIssueUser[] + >([]); + const [loadingReviewerCandidates, setLoadingReviewerCandidates] = + useState(false); + const [reviewerCandidatesError, setReviewerCandidatesError] = useState< + string | null + >(null); // ── Resolve owner/repo from the origin remote ───────────────────────────── const [repoFullName, setRepoFullName] = useState(null); @@ -215,6 +237,13 @@ export function useWorkstationPrDetail({ }; }, [repoPath, repoId]); + useEffect(() => { + reviewerCandidatesAttemptedRef.current = false; + setReviewerCandidates([]); + setLoadingReviewerCandidates(false); + setReviewerCandidatesError(null); + }, [repoFullName]); + // Per-PR request-id counters — see `bumpRequestId` above for why this is a // Map keyed by PR rather than a single instance-wide counter. const requestIdsRef = useRef(new Map()); @@ -222,6 +251,13 @@ export function useWorkstationPrDetail({ const applyBundle = useCallback( (identity: PrIdentity, bundle: PrDetailBundle) => { latestHeadShaRef.current = bundle.headSha; + latestRequestedReviewersRef.current = readRequestedReviewers( + bundle.detail + ); + latestAuthorLoginRef.current = readString(bundle.detail, [ + "user", + "login", + ]); setSelectedPr((prev) => ({ ...prev, identity, @@ -481,6 +517,143 @@ export function useWorkstationPrDetail({ [repoFullName, pr, setSelectedPr, loadDetail] ); + const runPrMutation = useCallback( + async (mutation: () => Promise): Promise => { + if (!repoFullName || !pr) { + throw new Error("GitHub repository context is unavailable"); + } + if (prActionPendingRef.current) { + throw new Error("Another pull request action is still running"); + } + prActionPendingRef.current = true; + setPrActionPending(true); + setSelectedPr((current) => ({ ...current, error: null })); + try { + await mutation(); + loadDetail(pr, { reconcile: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setSelectedPr((current) => ({ ...current, error: message })); + loadDetail(pr, { reconcile: true }); + throw error; + } finally { + prActionPendingRef.current = false; + setPrActionPending(false); + } + }, + [repoFullName, pr, setSelectedPr, loadDetail] + ); + + const mergePullRequest = useCallback( + async (method: PullRequestMergeMethod): Promise => { + if (!repoFullName || !pr) { + throw new Error("GitHub repository context is unavailable"); + } + await runPrMutation(() => + mergePRLocal( + repoFullName, + pr.number, + method, + latestHeadShaRef.current ?? undefined + ) + ); + }, + [repoFullName, pr, runPrMutation] + ); + + const setPullRequestAutoMerge = useCallback( + async (enabled: boolean, method: PullRequestMergeMethod): Promise => { + if (!repoFullName || !pr) { + throw new Error("GitHub repository context is unavailable"); + } + await runPrMutation(() => + setPRAutoMergeLocal( + repoFullName, + pr.number, + enabled, + method, + latestHeadShaRef.current ?? undefined + ) + ); + }, + [repoFullName, pr, runPrMutation] + ); + + const updatePullRequestState = useCallback( + async (state: "open" | "closed"): Promise => { + if (!repoFullName || !pr) { + throw new Error("GitHub repository context is unavailable"); + } + await runPrMutation(() => + updatePRStateLocal(repoFullName, pr.number, state) + ); + }, + [repoFullName, pr, runPrMutation] + ); + + const updateRequestedReviewers = useCallback( + async (reviewers: string[]): Promise => { + if (!repoFullName || !pr) { + throw new Error("GitHub repository context is unavailable"); + } + const current = new Map( + latestRequestedReviewersRef.current.map((reviewer) => [ + reviewer.login.toLowerCase(), + reviewer.login, + ]) + ); + const next = new Map( + reviewers.map((reviewer) => [reviewer.toLowerCase(), reviewer]) + ); + const added = [...next] + .filter(([normalized]) => !current.has(normalized)) + .map(([, login]) => login); + const removed = [...current] + .filter(([normalized]) => !next.has(normalized)) + .map(([, login]) => login); + if (added.length === 0 && removed.length === 0) return; + + await runPrMutation(async () => { + if (added.length > 0) { + await requestPRReviewersLocal(repoFullName, pr.number, added); + } + if (removed.length > 0) { + await removePRReviewersLocal(repoFullName, pr.number, removed); + } + latestRequestedReviewersRef.current = reviewers.map((login) => { + const candidate = reviewerCandidates.find( + (reviewer) => reviewer.login.toLowerCase() === login.toLowerCase() + ); + return candidate ?? { login, avatar_url: "" }; + }); + }); + }, + [repoFullName, pr, reviewerCandidates, runPrMutation] + ); + + const loadReviewerCandidates = useCallback(async (): Promise => { + if (!repoFullName || reviewerCandidatesAttemptedRef.current) return; + reviewerCandidatesAttemptedRef.current = true; + setLoadingReviewerCandidates(true); + setReviewerCandidatesError(null); + try { + const authorLogin = latestAuthorLoginRef.current?.toLowerCase(); + const candidates = await listRepoAssigneesLocal(repoFullName); + setReviewerCandidates( + candidates.filter( + (candidate) => candidate.login.toLowerCase() !== authorLogin + ) + ); + } catch (error) { + reviewerCandidatesAttemptedRef.current = false; + setReviewerCandidatesError( + error instanceof Error ? error.message : String(error) + ); + } finally { + setLoadingReviewerCandidates(false); + } + }, [repoFullName]); + const refresh = useCallback(() => { if (pr) loadDetail(pr, { force: true }); }, [pr, loadDetail]); @@ -492,6 +665,10 @@ export function useWorkstationPrDetail({ submitReview, addInlineComment, replyInlineComment, + mergePullRequest, + setPullRequestAutoMerge, + updatePullRequestState, + updateRequestedReviewers, refresh, }); }, [ @@ -499,6 +676,10 @@ export function useWorkstationPrDetail({ submitReview, addInlineComment, replyInlineComment, + mergePullRequest, + setPullRequestAutoMerge, + updatePullRequestState, + updateRequestedReviewers, refresh, setCallbacks, ]); @@ -515,6 +696,10 @@ export function useWorkstationPrDetail({ submitReview: null, addInlineComment: null, replyInlineComment: null, + mergePullRequest: null, + setPullRequestAutoMerge: null, + updatePullRequestState: null, + updateRequestedReviewers: null, refresh: null, }); }; @@ -527,6 +712,15 @@ export function useWorkstationPrDetail({ submitReview, addInlineComment, replyInlineComment, + mergePullRequest, + setPullRequestAutoMerge, + updatePullRequestState, + updateRequestedReviewers, + loadReviewerCandidates, + reviewerCandidates, + loadingReviewerCandidates, + reviewerCandidatesError, + prActionPending, refresh, latestHeadShaRef, }), @@ -536,6 +730,15 @@ export function useWorkstationPrDetail({ submitReview, addInlineComment, replyInlineComment, + mergePullRequest, + setPullRequestAutoMerge, + updatePullRequestState, + updateRequestedReviewers, + loadReviewerCandidates, + reviewerCandidates, + loadingReviewerCandidates, + reviewerCandidatesError, + prActionPending, refresh, ] ); diff --git a/src/modules/WorkStation/TabContent/renderers/githubPrDetail.tsx b/src/modules/WorkStation/TabContent/renderers/githubPrDetail.tsx index 9750f182f..4dde0dd0e 100644 --- a/src/modules/WorkStation/TabContent/renderers/githubPrDetail.tsx +++ b/src/modules/WorkStation/TabContent/renderers/githubPrDetail.tsx @@ -7,6 +7,7 @@ * its compact PR identity into the shared 40px tab-header strip while the PR * body fills the main pane without a second header. */ +import { useAtomValue } from "jotai"; import React, { memo, useCallback, useMemo } from "react"; import { usePublishWorkstationTabHeader } from "@src/hooks/workStation"; @@ -16,7 +17,12 @@ import { PrDetailHeaderContent, PrDetailPanel, } from "@src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrDetailPanel"; +import { resolvePullRequestDetailStatus } from "@src/shared/pr/prLevelActions"; import type { PrIdentity } from "@src/store/workstation/codeEditor/workstationSelectedPrAtom"; +import { + workstationPrScopeKey, + workstationSelectedPrAtomFamily, +} from "@src/store/workstation/codeEditor/workstationSelectedPrAtom"; import { createFileTab } from "@src/store/workstation/tabs"; import type { GitHubPrDetailTabData } from "@src/store/workstation/tabs"; @@ -26,6 +32,12 @@ const GitHubPrDetailTabRenderer: React.FC = memo( ({ tab }) => { const tabData = tab.data as unknown as GitHubPrDetailTabData; const { openTab } = useWorkStationTabs(); + const scopeKey = workstationPrScopeKey( + tabData.repoId, + tabData.repoPath, + tabData.prNumber + ); + const selectedPr = useAtomValue(workstationSelectedPrAtomFamily(scopeKey)); const handleFileSelect = useCallback( (path: string) => { @@ -43,7 +55,10 @@ const GitHubPrDetailTabRenderer: React.FC = memo( number: tabData.prNumber, title: tabData.prTitle, url: tabData.prUrl, - status: tabData.prStatus, + status: resolvePullRequestDetailStatus( + selectedPr.detail, + tabData.prStatus + ), headBranch: tabData.headBranch, baseBranch: tabData.baseBranch, }), @@ -52,6 +67,7 @@ const GitHubPrDetailTabRenderer: React.FC = memo( tabData.prTitle, tabData.prUrl, tabData.prStatus, + selectedPr.detail, tabData.headBranch, tabData.baseBranch, ] diff --git a/src/shared/pr/prLevelActions.test.ts b/src/shared/pr/prLevelActions.test.ts new file mode 100644 index 000000000..4dce0cbf4 --- /dev/null +++ b/src/shared/pr/prLevelActions.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; + +import { + presentPullRequestActions, + readRequestedReviewers, + resolvePullRequestDetailStatus, +} from "./prLevelActions"; + +describe("PR-level action presentation", () => { + it("uses repository merge settings and allows a clean PR to merge", () => { + const presentation = presentPullRequestActions({ + detail: { + state: "open", + merged: false, + draft: false, + mergeable: true, + mergeable_state: "clean", + base: { + repo: { + allow_merge_commit: false, + allow_squash_merge: true, + allow_rebase_merge: true, + allow_auto_merge: true, + }, + }, + }, + fallbackStatus: "open", + checks: { sha: "head", check_runs: [], statuses: [], state: "success" }, + }); + + expect(presentation.directMergeAvailable).toBe(true); + expect(presentation.defaultMethod).toBe("squash"); + expect(presentation.methods.map(({ method }) => method)).toEqual([ + "squash", + "rebase", + ]); + expect(presentation.autoMergeAction).toBeNull(); + }); + + it("offers merge-when-ready while checks or policy requirements block direct merge", () => { + const presentation = presentPullRequestActions({ + detail: { + state: "open", + mergeable: true, + mergeable_state: "blocked", + }, + fallbackStatus: "open", + checks: { sha: "head", check_runs: [], statuses: [], state: "pending" }, + }); + + expect(presentation.directMergeAvailable).toBe(false); + expect(presentation.label).toBe("Checks pending"); + expect(presentation.autoMergeAction).toEqual({ + kind: "enable", + label: "Enable auto-merge", + }); + }); + + it("disables direct merge for conflicts and exposes an existing auto-merge request", () => { + const presentation = presentPullRequestActions({ + detail: { + state: "open", + mergeable: false, + mergeable_state: "dirty", + auto_merge: { enabled_by: { login: "reviewer" } }, + }, + fallbackStatus: "open", + checks: null, + }); + + expect(presentation.directMergeAvailable).toBe(false); + expect(presentation.label).toBe("Resolve conflicts"); + expect(presentation.autoMergeAction).toEqual({ + kind: "disable", + label: "Disable auto-merge", + }); + }); + + it("trusts GitHub mergeability when optional checks are still running", () => { + const presentation = presentPullRequestActions({ + detail: { + state: "open", + mergeable: true, + mergeable_state: "clean", + base: { repo: { allow_auto_merge: true } }, + }, + fallbackStatus: "open", + checks: { sha: "head", check_runs: [], statuses: [], state: "pending" }, + }); + + expect(presentation.directMergeAvailable).toBe(true); + expect(presentation.label).toBe("Checks pending"); + expect(presentation.autoMergeAction).toBeNull(); + }); + + it("does not offer auto-merge for conflicts or unstable status", () => { + const conflicting = presentPullRequestActions({ + detail: { state: "open", mergeable: false, mergeable_state: "dirty" }, + fallbackStatus: "open", + checks: null, + }); + const unstable = presentPullRequestActions({ + detail: { state: "open", mergeable_state: "unstable" }, + fallbackStatus: "open", + checks: null, + }); + + expect(conflicting.autoMergeAction).toBeNull(); + expect(unstable.autoMergeAction).toBeNull(); + }); + + it("routes merge-queue branches through merge when ready", () => { + const waiting = presentPullRequestActions({ + detail: { + state: "open", + mergeable: true, + mergeable_state: "clean", + merge_queue_required: true, + }, + fallbackStatus: "open", + checks: { sha: "head", check_runs: [], statuses: [], state: "success" }, + }); + const queued = presentPullRequestActions({ + detail: { + state: "open", + merge_queue_required: true, + is_in_merge_queue: true, + }, + fallbackStatus: "open", + checks: null, + }); + + expect(waiting.directMergeAvailable).toBe(false); + expect(waiting.autoMergeAction).toEqual({ + kind: "enable", + label: "Merge when ready", + }); + expect(queued.autoMergeAction).toEqual({ + kind: "disable", + label: "Remove from merge queue", + }); + }); + + it("derives merged and draft status from authoritative PR detail", () => { + expect( + resolvePullRequestDetailStatus( + { state: "closed", merged: true, draft: false }, + "open" + ) + ).toBe("merged"); + expect( + resolvePullRequestDetailStatus( + { state: "open", merged: false, draft: true }, + "open" + ) + ).toBe("draft"); + }); + + it("reads only valid requested reviewer users", () => { + expect( + readRequestedReviewers({ + requested_reviewers: [ + { login: "reviewer", avatar_url: "https://avatars/reviewer" }, + { avatar_url: "https://avatars/missing-login" }, + null, + ], + }) + ).toEqual([ + { + login: "reviewer", + avatar_url: "https://avatars/reviewer", + }, + ]); + }); +}); diff --git a/src/shared/pr/prLevelActions.ts b/src/shared/pr/prLevelActions.ts new file mode 100644 index 000000000..490a44297 --- /dev/null +++ b/src/shared/pr/prLevelActions.ts @@ -0,0 +1,215 @@ +import type { + GitHubChecksSummary, + GitHubIssueUser, + PullRequestMergeMethod, +} from "@src/api/tauri/github"; + +import { normalizePrStatus } from "./prStatus"; + +export interface PullRequestMergeMethodOption { + method: PullRequestMergeMethod; + label: string; +} + +export interface PullRequestAutoMergeAction { + kind: "enable" | "disable"; + label: + | "Enable auto-merge" + | "Merge when ready" + | "Disable auto-merge" + | "Remove from merge queue"; +} + +export interface PullRequestActionPresentation { + status: string; + label: string; + tooltip: string; + directMergeAvailable: boolean; + methods: PullRequestMergeMethodOption[]; + defaultMethod: PullRequestMergeMethod; + autoMergeAction: PullRequestAutoMergeAction | null; +} + +const MERGE_METHODS: PullRequestMergeMethodOption[] = [ + { method: "merge", label: "Merge pull request" }, + { method: "squash", label: "Squash and merge" }, + { method: "rebase", label: "Rebase and merge" }, +]; + +function readRecord( + source: Record | null, + key: string +): Record | null { + const value = source?.[key]; + return value && typeof value === "object" + ? (value as Record) + : null; +} + +function readBoolean( + source: Record | null, + key: string +): boolean | null { + const value = source?.[key]; + return typeof value === "boolean" ? value : null; +} + +function readString( + source: Record | null, + key: string +): string | null { + const value = source?.[key]; + return typeof value === "string" ? value : null; +} + +export function resolvePullRequestDetailStatus( + detail: Record | null, + fallbackStatus: string +): string { + if (!detail) return fallbackStatus; + return normalizePrStatus({ + state: readString(detail, "state") ?? fallbackStatus, + merged: readBoolean(detail, "merged") === true, + draft: readBoolean(detail, "draft") === true, + }); +} + +export function readRequestedReviewers( + detail: Record | null +): GitHubIssueUser[] { + const value = detail?.requested_reviewers; + if (!Array.isArray(value)) return []; + return value.flatMap((reviewer) => { + if (!reviewer || typeof reviewer !== "object") return []; + const record = reviewer as Record; + const login = typeof record.login === "string" ? record.login : ""; + if (!login) return []; + return [ + { + login, + avatar_url: + typeof record.avatar_url === "string" ? record.avatar_url : "", + }, + ]; + }); +} + +function resolveMergeMethods( + detail: Record | null +): PullRequestMergeMethodOption[] { + const baseRepo = readRecord(readRecord(detail, "base"), "repo"); + const settings: Record = { + merge: readBoolean(baseRepo, "allow_merge_commit"), + squash: readBoolean(baseRepo, "allow_squash_merge"), + rebase: readBoolean(baseRepo, "allow_rebase_merge"), + }; + const hasExplicitSettings = Object.values(settings).some( + (setting) => setting !== null + ); + if (!hasExplicitSettings) return MERGE_METHODS; + const enabled = MERGE_METHODS.filter( + ({ method }) => settings[method] === true + ); + return enabled.length > 0 ? enabled : MERGE_METHODS; +} + +export function presentPullRequestActions({ + detail, + fallbackStatus, + checks, +}: { + detail: Record | null; + fallbackStatus: string; + checks: GitHubChecksSummary | null; +}): PullRequestActionPresentation { + const status = resolvePullRequestDetailStatus(detail, fallbackStatus); + const methods = resolveMergeMethods(detail); + const defaultMethod = methods[0]?.method ?? "merge"; + const autoMergeEnabled = readRecord(detail, "auto_merge") !== null; + const baseRepo = readRecord(readRecord(detail, "base"), "repo"); + const autoMergeAllowed = readBoolean(baseRepo, "allow_auto_merge") !== false; + const mergeQueueRequired = + readBoolean(detail, "merge_queue_required") === true; + const inMergeQueue = readBoolean(detail, "is_in_merge_queue") === true; + const reviewDecision = readString(detail, "review_decision")?.toUpperCase(); + const mergeable = readBoolean(detail, "mergeable"); + const mergeableState = readString(detail, "mergeable_state")?.toLowerCase(); + const hasMergeMetadata = mergeable !== null || mergeableState !== undefined; + const hasConflicts = mergeable === false || mergeableState === "dirty"; + const policyBlocked = + mergeableState === "blocked" || mergeableState === "behind"; + const unstable = mergeableState === "unstable"; + const openAndReady = status === "open"; + const directMergeAvailable = + openAndReady && + !mergeQueueRequired && + !hasConflicts && + !policyBlocked && + (mergeable === true || + mergeableState === "clean" || + (!hasMergeMetadata && checks?.state === "success")); + + let label = + methods.find((method) => method.method === defaultMethod)?.label ?? "Merge"; + let tooltip = "Merge this pull request on GitHub"; + if (status === "merged") { + label = "Merged"; + tooltip = "This pull request is already merged"; + } else if (status === "closed") { + label = "Closed"; + tooltip = "Reopen this pull request before merging"; + } else if (status === "draft") { + label = "Draft"; + tooltip = "Mark this pull request ready for review before merging"; + } else if (inMergeQueue) { + label = "In merge queue"; + tooltip = "GitHub will merge this pull request through the merge queue"; + } else if (reviewDecision === "REVIEW_REQUIRED") { + label = "Approval required"; + tooltip = "GitHub requires review approval before merging"; + } else if (reviewDecision === "CHANGES_REQUESTED") { + label = "Changes requested"; + tooltip = "Requested changes must be resolved before merging"; + } else if (hasConflicts) { + label = "Resolve conflicts"; + tooltip = "Resolve merge conflicts before merging"; + } else if (checks?.state === "failure") { + label = "Checks failed"; + tooltip = "Required checks must pass before merging"; + } else if (checks?.state === "pending") { + label = "Checks pending"; + tooltip = "Wait for required checks or enable auto-merge"; + } else if (policyBlocked) { + label = "Merge blocked"; + tooltip = "GitHub reports unmet merge requirements"; + } + + const autoMergeAction = + status !== "open" + ? null + : inMergeQueue + ? ({ kind: "disable", label: "Remove from merge queue" } as const) + : autoMergeEnabled + ? ({ kind: "disable", label: "Disable auto-merge" } as const) + : mergeQueueRequired + ? ({ kind: "enable", label: "Merge when ready" } as const) + : autoMergeAllowed && + !directMergeAvailable && + !hasConflicts && + !unstable + ? ({ + kind: "enable", + label: "Enable auto-merge", + } as const) + : null; + + return { + status, + label, + tooltip, + directMergeAvailable, + methods, + defaultMethod, + autoMergeAction, + }; +} diff --git a/src/store/workstation/codeEditor/workstationSelectedPrAtom.ts b/src/store/workstation/codeEditor/workstationSelectedPrAtom.ts index ad466fdf6..a06369c8a 100644 --- a/src/store/workstation/codeEditor/workstationSelectedPrAtom.ts +++ b/src/store/workstation/codeEditor/workstationSelectedPrAtom.ts @@ -8,6 +8,7 @@ import type { GitHubReviewComment, PrFile, PrReviewEvent, + PullRequestMergeMethod, } from "@src/api/tauri/github"; import { @@ -166,6 +167,12 @@ export interface WorkstationPrDetailCallbacks { replyInlineComment: | ((commentId: number, body: string) => Promise) | null; + mergePullRequest: ((method: PullRequestMergeMethod) => Promise) | null; + setPullRequestAutoMerge: + | ((enabled: boolean, method: PullRequestMergeMethod) => Promise) + | null; + updatePullRequestState: ((state: "open" | "closed") => Promise) | null; + updateRequestedReviewers: ((reviewers: string[]) => Promise) | null; refresh: (() => void) | null; } @@ -174,6 +181,10 @@ const initialPrDetailCallbacks: WorkstationPrDetailCallbacks = { submitReview: null, addInlineComment: null, replyInlineComment: null, + mergePullRequest: null, + setPullRequestAutoMerge: null, + updatePullRequestState: null, + updateRequestedReviewers: null, refresh: null, };