Merge pull request 'Create PRs' (#40) from review-center into main

This commit was merged in pull request #40.
This commit is contained in:
2026-09-08 19:41:12 +00:00
6 changed files with 206 additions and 2 deletions
+4 -1
View File
@@ -5,11 +5,14 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::time::Duration;
mod creation;
pub use creation::create_integration_review_request;
const PAGE_SIZE: usize = 100;
const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35);
const AZURE_PROJECT_WORKERS: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IntegrationRepository {
pub id: String,
+105
View File
@@ -0,0 +1,105 @@
use super::*;
fn creation_payload(provider: &str, source: &str, target: &str, title: &str, description: &str) -> Result<serde_json::Value, String> {
if title.trim().is_empty() || source.trim().is_empty() || target.trim().is_empty() {
return Err("Title, source branch and target branch are required.".into());
}
if source == target { return Err("Source and target branch must be different.".into()); }
Ok(match provider {
"github" | "gitea" => serde_json::json!({"head":source,"base":target,"title":title,"body":description}),
"gitlab" | "gitlab-self-hosted" => serde_json::json!({"source_branch":source,"target_branch":target,"title":title,"description":description}),
"azure-devops" => serde_json::json!({"sourceRefName":format!("refs/heads/{source}"),"targetRefName":format!("refs/heads/{target}"),"title":title,"description":description}),
_ => return Err("Unsupported integration provider.".into()),
})
}
fn creation_endpoint(provider: &str, base: &str, repository: &IntegrationRepository) -> Result<reqwest::Url, String> {
let base = if provider == "github" { github_api_base_url(base)? } else { normalized_base_url(base)? };
let mut url = reqwest::Url::parse(&format!("{base}/")).map_err(|e| e.to_string())?;
{
let mut path = url.path_segments_mut().map_err(|_| "Invalid integration URL.")?;
path.pop_if_empty();
match provider {
"github" | "gitea" => {
let parts: Vec<_> = repository.full_name.split('/').collect();
if parts.len() != 2 || parts.iter().any(|part| part.is_empty() || *part == "." || *part == "..") {
return Err("Invalid repository name.".into());
}
if provider == "gitea" { path.extend(["api", "v1"]); }
path.push("repos").extend(parts).push("pulls");
}
"gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects", &repository.id,"merge_requests"]); }
"azure-devops" => { path.extend(["_apis","git","repositories", &repository.id,"pullrequests"]); }
_ => return Err("Unsupported integration provider.".into()),
}
}
if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version", "7.1"); }
Ok(url)
}
#[tauri::command]
pub async fn create_integration_review_request(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository, source_branch: String, target_branch: String, title: String, description: String) -> Result<IntegrationReviewRequest, String> {
tauri::async_runtime::spawn_blocking(move || {
if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); }
let source = source_branch.trim().strip_prefix("refs/heads/").unwrap_or(source_branch.trim());
let target = target_branch.trim().strip_prefix("refs/heads/").unwrap_or(target_branch.trim());
let payload = creation_payload(&provider, source, target, title.trim(), &description)?;
let endpoint = creation_endpoint(&provider, &base_url, &repository)?;
let client = client()?;
let request = client.post(endpoint).header(USER_AGENT, "Gitty").header(ACCEPT, "application/json");
let request = match provider.as_str() {
"github" => request.bearer_auth(&token),
"gitea" => request.header("Authorization", format!("token {token}")),
"gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", &token),
"azure-devops" => request.basic_auth(if username.is_empty() { "gitty" } else { &username }, Some(&token)),
_ => unreachable!(),
};
// Never automatically retry creation: a lost response can still mean the PR was created.
let response = request.json(&payload).send().map_err(|_| "The creation response could not be received. Check the original repository before trying again.".to_string())?;
if !response.status().is_success() { return Err(response_error(response, &provider)); }
let mut value: serde_json::Value = response.json().map_err(|_| "The request was created, but its response could not be read. Refresh the Review Center before trying again.".to_string())?;
let mut review = match provider.as_str() {
"github" => {
value["pull_request"] = serde_json::json!({});
value["repository_url"] = serde_json::json!(format!("{}/repos/{}", github_api_base_url(&base_url)?, repository.full_name));
parse_github_review(&value).ok_or("Could not read the created PR.")?
}
"gitea" => {
value["pull_request"] = value.clone();
value["repository"] = serde_json::json!({"id":repository.id.parse::<u64>().unwrap_or_default(),"full_name":repository.full_name});
parse_gitea_review(&value).ok_or("Could not read the created PR.")?
}
"gitlab" | "gitlab-self-hosted" => parse_gitlab_review(&value, &provider),
"azure-devops" => parse_azure_review(&value, &repository),
_ => unreachable!(),
};
review.repository_name = repository.full_name;
review.source_branch = source.to_string();
review.target_branch = target.to_string();
Ok(review)
}).await.map_err(|e| format!("Could not create review request: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creation_payloads_and_validation() {
for provider in ["github", "gitea", "gitlab", "gitlab-self-hosted", "azure-devops"] {
assert!(creation_payload(provider, "main", "main", "Test", "").is_err());
assert!(creation_payload(provider, "topic", "main", " ", "").is_err());
}
assert_eq!(creation_payload("github", "feature/test", "main", "Test", "Body").unwrap()["head"], "feature/test");
assert_eq!(creation_payload("gitea", "topic", "main", "Test", "Body").unwrap()["body"], "Body");
assert_eq!(creation_payload("gitlab-self-hosted", "topic", "main", "Test", "").unwrap()["source_branch"], "topic");
assert_eq!(creation_payload("azure-devops", "topic", "main", "Test", "").unwrap()["targetRefName"], "refs/heads/main");
}
#[test]
fn creation_urls_preserve_prefixes_and_encode_paths() {
let repository: IntegrationRepository = serde_json::from_value(serde_json::json!({"id":"17","name":"repo","fullName":"owner/repo","description":"","cloneUrl":"","sshUrl":"","webUrl":"","updatedAt":"","private":false})).unwrap();
assert_eq!(creation_endpoint("github", "https://github.com", &repository).unwrap().as_str(), "https://api.github.com/repos/owner/repo/pulls");
assert_eq!(creation_endpoint("gitea", "https://git.example/sub", &repository).unwrap().path(), "/sub/api/v1/repos/owner/repo/pulls");
assert_eq!(creation_endpoint("gitlab", "https://git.example", &repository).unwrap().path(), "/api/v4/projects/17/merge_requests");
assert_eq!(creation_endpoint("azure-devops", "https://dev.azure.com/org", &repository).unwrap().as_str(), "https://dev.azure.com/org/_apis/git/repositories/17/pullrequests?api-version=7.1");
}
}
+2 -1
View File
@@ -34,7 +34,7 @@ use git::{
unstage_files, untrack_paths, update_remote,
};
use integrations::{
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
create_integration_review_request, add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
run_integration_review_action,
};
use std::path::{Path, PathBuf};
@@ -437,6 +437,7 @@ async fn main() {
cred_load,
cred_save,
cred_delete,
create_integration_review_request,
list_integration_repositories,
list_integration_review_requests,
get_integration_review_details,
@@ -0,0 +1,73 @@
<script lang="ts">
import { onMount } from "svelte";
import { GitPullRequest, X, LoaderCircle, ArrowRight } from "@lucide/svelte";
import { createIntegrationReviewRequest, listIntegrationRepositories } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
let { source, de, loadCredential, onClose, onCreated }: {
source: GitIntegrationSource; de: boolean;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void;
} = $props();
let dialog: HTMLDialogElement;
let repositories = $state<GitIntegrationRepository[]>([]);
let repositoryId = $state("");
let sourceBranch = $state("");
let targetBranch = $state("");
let title = $state("");
let description = $state("");
let loading = $state(true);
let busy = $state(false);
let error = $state("");
const mr = $derived(source.provider.startsWith("gitlab"));
const heading = $derived(de ? (mr ? "Merge Request erstellen" : "Pull Request erstellen") : (mr ? "Create merge request" : "Create pull request"));
const normalizeBranch = (branch: string) => branch.trim().replace(/^refs\/heads\//, "");
const sameBranch = $derived(!!sourceBranch.trim() && normalizeBranch(sourceBranch) === normalizeBranch(targetBranch));
const valid = $derived(repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch);
onMount(() => { dialog.showModal(); void loadRepositories(); });
async function loadRepositories() {
loading = true; error = "";
try {
repositories = (await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId)).sort((a,b) => a.fullName.localeCompare(b.fullName));
if (repositories.length === 1) repositoryId = repositories[0].id;
} catch (cause) { error = String(cause); }
finally { loading = false; }
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || !valid) return;
const repository = repositories.find(item => item.id === repositoryId);
if (!repository) return;
busy = true; error = "";
try {
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
const request = await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
onCreated(request);
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); }
finally { busy = false; }
}
</script>
<dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}>
<form onsubmit={submit}>
<header><div class="heading-icon"><GitPullRequest size={19} /></div><div><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
<div class="body">
{#if error}<div class="error" role="alert">{error}{#if !repositories.length && !loading}<button type="button" onclick={loadRepositories}>{de ? "Erneut laden" : "Retry"}</button>{/if}</div>{/if}
<label>Repository<select bind:value={repositoryId} disabled={loading || busy} required><option value="" disabled>{loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")}</option>{#each repositories as repository}<option value={repository.id}>{repository.fullName}</option>{/each}</select></label>
{#if !loading && !error && !repositories.length}<p>{de ? "Keine Repositories für diese Integration gefunden." : "No repositories found for this integration."}</p>{/if}
<div class="branches"><label>{de ? "Quellbranch" : "Source branch"}<input bind:value={sourceBranch} disabled={busy} placeholder="feature/my-change" required autocomplete="off" /></label><ArrowRight size={16}/><label>{de ? "Zielbranch" : "Target branch"}<input bind:value={targetBranch} disabled={busy} placeholder="main" required autocomplete="off" /></label></div>
{#if sameBranch}<p class="validation">{de ? "Quell- und Zielbranch müssen unterschiedlich sein." : "Source and target branches must be different."}</p>{/if}
<p class="hint">{de ? "Beide Branches müssen bereits in diesem Repository gepusht sein." : "Both branches must already be pushed to this repository."}</p>
<label>{de ? "Titel" : "Title"}<input bind:value={title} disabled={busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Beschreibe deine Änderungen … (Markdown unterstützt)" : "Describe your changes… (Markdown supported)"}></textarea></label>
</div>
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : heading}</button></footer>
</form>
</dialog>
<style>
dialog{margin:auto;width:min(600px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--color-surface);color:var(--color-ink);box-shadow:0 24px 90px #0005;font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:#0007;backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 24px;border-bottom:1px solid var(--color-border-subtle)}h2{margin:0;font-size:16px;font-weight:650}p{margin:5px 0 0;color:var(--color-ink-muted)}.heading-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:color-mix(in srgb,var(--color-accent) 12%,transparent);color:var(--color-accent)}button,input,select,textarea{font:inherit}button{display:inline-flex;justify-content:center;align-items:center;gap:8px;border:1px solid var(--color-border-subtle);border-radius:7px;padding:9px 13px;background:var(--color-surface);color:var(--color-ink);cursor:pointer}button:disabled{opacity:.5;cursor:default}.close{margin-left:auto;border:0;padding:6px}.body{display:grid;gap:17px;padding:24px}label{display:grid;gap:8px;font-weight:600;min-width:0}input,select,textarea{box-sizing:border-box;width:100%;padding:10px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--app-bg);color:var(--color-ink);font-weight:400}select{height:38px;padding:7px 11px;line-height:22px}input:focus,select:focus,textarea:focus{outline:2px solid var(--color-accent);outline-offset:1px}textarea{resize:vertical;min-height:110px;line-height:1.6}.branches{display:grid;grid-template-columns:minmax(0,1fr) 16px minmax(0,1fr);gap:12px;align-items:end}.branches>:global(svg){margin-bottom:12px;color:var(--color-accent)}.hint{margin-top:-8px;font-size:11px;line-height:1.5}.error,.validation{color:var(--color-danger,#e76767);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger,#e76767) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 24px;border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent);border-color:var(--color-accent);color:white}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
</style>
+18
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import CreateReviewDialog from "./CreateReviewDialog.svelte";
import SelectMenu from "./SelectMenu.svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
@@ -32,6 +33,7 @@
}
let { language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
let createOpen = $state(false);
let requests = $state<IntegrationReviewRequest[]>([]);
let loading = $state(false);
let errors = $state<Array<{ source: string; message: string }>>([]);
@@ -398,9 +400,24 @@
}
</script>
{#if createOpen && activeSource}
<CreateReviewDialog source={activeSource} {de} {loadCredential} onClose={() => createOpen = false} onCreated={(request) => {
++loadGeneration;
loading = false;
requests = [request, ...requests.filter(item => item.id !== request.id)];
stateFilter = request.state;
query = "";
collapsedRepositories = new Set();
errors = [];
createOpen = false;
selectRequest(request, true);
}}/>
{/if}
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
<header class="review-header">
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
{#if activeSource}<button class="create-review" type="button" disabled={loading} onclick={() => { detailOpen = false; createOpen = true; }}><GitPullRequest size={14}/>{activeSource.provider.startsWith("gitlab") ? (de ? "MR erstellen" : "Create MR") : (de ? "PR erstellen" : "Create PR")}</button>{/if}
</header>
{#if sources.length === 0}
@@ -528,6 +545,7 @@
</section>
<style>
.create-review{margin-left:auto;display:flex;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--color-accent);color:white;cursor:pointer}.create-review:disabled{opacity:.5;cursor:default}
.review-center{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}.review-center button,.review-center input{font:inherit}.review-header{display:flex;min-height:48px;align-items:center;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.review-heading{display:flex;align-items:center;gap:9px}.review-heading>:global(svg){color:var(--color-accent)}.review-heading h1{margin:0;font-size:16px;font-weight:650}
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}
+4
View File
@@ -675,3 +675,7 @@ export function resolveConflictSide(
): Promise<GitStatus> {
return invoke<GitStatus>("resolve_conflict_side", { path, file, side });
}
export function createIntegrationReviewRequest(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: GitIntegrationRepository, sourceBranch: string, targetBranch: string, title: string, description: string): Promise<IntegrationReviewRequest> {
return invoke("create_integration_review_request", { provider, baseUrl, username, token, repository, sourceBranch, targetBranch, title, description });
}