Add embedded PTY terminal, keyboard shortcuts, and assignee/label integrations #53

Merged
Christoph merged 9 commits from UI-UX into main 2026-09-22 19:13:07 +00:00
11 changed files with 1048 additions and 17 deletions
Showing only changes of commit 8e63f2a939 - Show all commits
+2
View File
@@ -1,3 +1,5 @@
mod assignees;
pub use assignees::*;
mod cleanup; mod cleanup;
mod merge; mod merge;
pub use merge::get_integration_review_merge_options; pub use merge::get_integration_review_merge_options;
+814
View File
@@ -0,0 +1,814 @@
use super::issue_comments::{comment_client, request};
use super::*;
use reqwest::{Method, Url};
use serde_json::{Value, json};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct IntegrationAssignee {
pub id: String,
pub username: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssignmentTarget {
pub repository: String,
#[serde(default)]
pub repository_id: String,
pub number: u64,
pub kind: String,
}
fn api_url(provider: &str, base: &str, segments: &[&str]) -> Result<Url, String> {
let base = match provider {
"github" => github_api_base_url(base)?,
"gitea" | "gitlab" | "gitlab-self-hosted" | "azure-devops" => normalized_base_url(base)?,
_ => return Err("Unsupported assignment provider.".into()),
};
let mut url = Url::parse(&base).map_err(|e| e.to_string())?;
url.path_segments_mut()
.map_err(|_| "Invalid API URL.")?
.pop_if_empty()
.extend(segments.iter().copied());
if provider == "azure-devops" {
url.set_query(Some("api-version=7.1"));
}
Ok(url)
}
fn project(target: &AssignmentTarget) -> Result<&str, String> {
let name = if target.kind == "review" {
target.repository.split_once('/').map(|p| p.0).unwrap_or("")
} else {
&target.repository
};
if name.is_empty() {
return Err("Select an Azure project.".into());
}
Ok(name)
}
fn repository_parts(repository: &str) -> Result<(&str, &str), String> {
repository
.split_once('/')
.filter(|(owner, repo)| {
!owner.is_empty()
&& !repo.is_empty()
&& !repo.contains('/')
&& ![".", ".."].contains(owner)
&& ![".", ".."].contains(repo)
})
.ok_or("Invalid repository name.".into())
}
fn target_url(provider: &str, base: &str, target: &AssignmentTarget) -> Result<Url, String> {
if target.number == 0 || !["issue", "review"].contains(&target.kind.as_str()) {
return Err("Invalid assignment target.".into());
}
let number = target.number.to_string();
match provider {
"github" | "gitea" => {
let (owner, repo) = repository_parts(&target.repository)?;
let mut parts = vec!["repos", owner, repo, "issues", &number];
if provider == "gitea" {
parts.splice(0..0, ["api", "v1"]);
}
api_url(provider, base, &parts)
}
"gitlab" | "gitlab-self-hosted" => api_url(
provider,
base,
&[
"api",
"v4",
"projects",
&target.repository,
if target.kind == "review" {
"merge_requests"
} else {
"issues"
},
&number,
],
),
"azure-devops" if target.kind == "review" => {
if target.repository_id.is_empty() {
return Err("Missing Azure repository ID.".into());
}
api_url(
provider,
base,
&[
project(target)?,
"_apis",
"git",
"repositories",
&target.repository_id,
"pullrequests",
&number,
"reviewers",
],
)
}
"azure-devops" => api_url(
provider,
base,
&[project(target)?, "_apis", "wit", "workitems", &number],
),
_ => Err("Unsupported assignment provider.".into()),
}
}
fn user(value: &Value, provider: &str) -> Option<IntegrationAssignee> {
let username = value_string(
value,
&[if provider == "azure-devops" {
"uniqueName"
} else if provider.starts_with("gitlab") {
"username"
} else {
"login"
}],
);
let id = if provider == "github" || provider == "gitea" {
username.clone()
} else {
json_id(value)
};
if id.is_empty() {
return None;
}
let name = value_string(
value,
&[if provider == "azure-devops" {
"displayName"
} else if provider == "gitea" {
"full_name"
} else {
"name"
}],
);
Some(IntegrationAssignee {
id,
name: if name.is_empty() {
username.clone()
} else {
name
},
username,
})
}
fn assigned(value: &Value, provider: &str, kind: &str) -> Result<Vec<IntegrationAssignee>, String> {
if provider == "azure-devops" && kind == "issue" {
let identity = &value["fields"]["System.AssignedTo"];
if identity.is_null() || identity.as_str() == Some("") {
return Ok(vec![]);
}
return user(identity, provider)
.map(|u| vec![u])
.ok_or("Azure returned an invalid assigned identity.".into());
}
// Gitea serializes an unassigned issue/PR with `assignees: null`.
// Older responses can expose only the singular `assignee` field.
if provider == "gitea" && value.get("assignees").is_none_or(Value::is_null) {
if let Some(identity) = value.get("assignee").filter(|identity| !identity.is_null()) {
return user(identity, provider)
.map(|u| vec![u])
.ok_or("Gitea returned an invalid assigned user.".into());
}
if value.get("assignees").is_some() || value.get("assignee").is_some() {
return Ok(vec![]);
}
}
let field = if provider == "azure-devops" {
"value"
} else {
"assignees"
};
let entries = value[field]
.as_array()
.ok_or("The provider returned no assignment list.")?;
entries
.iter()
.map(|entry| {
user(entry, provider).ok_or("The provider returned an invalid assigned user.".into())
})
.collect()
}
struct AssignmentApi<'a> {
client: Client,
provider: &'a str,
username: &'a str,
token: &'a str,
}
impl AssignmentApi<'_> {
fn send(&self, method: Method, url: Url, body: Option<&Value>) -> Result<Value, String> {
let mut req = request(
&self.client,
method,
url,
self.provider,
self.username,
self.token,
)?;
if let Some(body) = body {
if body.is_array() {
req = req.header("Content-Type", "application/json-patch+json");
}
req = req.json(body);
}
let response = req
.send()
.map_err(|e| format!("Assignment request could not be confirmed: {e}"))?;
if !response.status().is_success() {
return Err(response_error(response, self.provider));
}
if response.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(Value::Null);
}
response.json().map_err(|e| e.to_string())
}
fn pages(&self, url: Url) -> Result<Vec<Value>, String> {
let mut result = Vec::new();
let mut previous = None;
for page in 1..=1000 {
let mut endpoint = url.clone();
if self.provider == "azure-devops" {
endpoint
.query_pairs_mut()
.append_pair("$top", "100")
.append_pair("$skip", &((page - 1) * 100).to_string());
} else {
endpoint
.query_pairs_mut()
.append_pair("per_page", "100")
.append_pair("limit", "100")
.append_pair("page", &page.to_string());
}
let response = request(
&self.client,
Method::GET,
endpoint,
self.provider,
self.username,
self.token,
)?
.send()
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(response_error(response, self.provider));
}
let next = response
.headers()
.get("link")
.and_then(|h| h.to_str().ok())
.map(|s| s.contains("rel=\"next\""));
let value: Value = response.json().map_err(|e| e.to_string())?;
let entries = if self.provider == "azure-devops" {
value["value"].as_array()
} else {
value.as_array()
}
.ok_or("Invalid user list.")?;
if entries.is_empty() {
return Ok(result);
}
if previous.as_ref() == Some(&value) {
return Err("The provider repeated a user page.".into());
}
result.extend(entries.iter().cloned());
if !next.unwrap_or(entries.len() == 100) {
return Ok(result);
}
previous = Some(value);
}
Err("Too many user pages returned by the provider.".into())
}
}
#[tauri::command]
pub async fn list_integration_assignees(
provider: String,
base_url: String,
username: String,
token: String,
target: AssignmentTarget,
) -> Result<Vec<IntegrationAssignee>, String> {
tauri::async_runtime::spawn_blocking(move || {
let api = AssignmentApi {
client: comment_client()?,
provider: &provider,
username: &username,
token: &token,
};
let entries = match provider.as_str() {
"github" | "gitea" => {
let (owner, repo) = repository_parts(&target.repository)?;
let mut parts = vec!["repos", owner, repo, "assignees"];
if provider == "gitea" {
parts.splice(0..0, ["api", "v1"]);
}
api.pages(api_url(&provider, &base_url, &parts)?)?
}
"gitlab" | "gitlab-self-hosted" => api.pages(api_url(
&provider,
&base_url,
&[
"api",
"v4",
"projects",
&target.repository,
"members",
"all",
],
)?)?,
"azure-devops" => {
let project = project(&target)?;
let teams = api.pages(api_url(
&provider,
&base_url,
&["_apis", "projects", project, "teams"],
)?)?;
let mut members = vec![];
for team in teams {
let id = team["id"]
.as_str()
.ok_or("Azure returned an invalid team.")?;
members.extend(
api.pages(api_url(
&provider,
&base_url,
&["_apis", "projects", project, "teams", id, "members"],
)?)?
.into_iter()
.map(|v| v["identity"].clone()),
);
}
members
}
_ => return Err("Unsupported assignment provider.".into()),
};
let mut seen = BTreeSet::new();
let mut users: Vec<_> = entries
.iter()
.filter(|entry| {
entry["state"]
.as_str()
.is_none_or(|state| state == "active")
&& entry["isContainer"] != true
})
.filter_map(|v| user(v, &provider))
.filter(|u| seen.insert(u.id.clone()))
.collect();
users.sort_by_key(|u| (u.name.to_lowercase(), u.username.to_lowercase()));
Ok(users)
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn get_integration_assignees(
provider: String,
base_url: String,
username: String,
token: String,
target: AssignmentTarget,
) -> Result<Vec<IntegrationAssignee>, String> {
tauri::async_runtime::spawn_blocking(move || {
let api = AssignmentApi {
client: comment_client()?,
provider: &provider,
username: &username,
token: &token,
};
assigned(
&api.send(
Method::GET,
target_url(&provider, &base_url, &target)?,
None,
)?,
&provider,
&target.kind,
)
})
.await
.map_err(|e| e.to_string())?
}
fn assignment_payload(
provider: &str,
kind: &str,
users: &[IntegrationAssignee],
current: &Value,
) -> Result<Value, String> {
if users.iter().any(|u| u.id.trim().is_empty()) {
return Err("Invalid assigned user.".into());
}
if provider == "azure-devops" && kind == "issue" {
if users.len() > 1 {
return Err("Azure work items support one assignee.".into());
}
let rev = current["rev"]
.as_u64()
.ok_or("Azure returned no work item revision.")?;
let identity = users
.first()
.map(|u| {
if u.username.is_empty() {
u.id.as_str()
} else {
u.username.as_str()
}
})
.unwrap_or("");
return Ok(
json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.AssignedTo","value":identity}]),
);
}
if provider.starts_with("gitlab") {
let ids = users
.iter()
.map(|u| {
u.id.parse::<u64>()
.ok()
.filter(|id| *id > 0)
.ok_or("Invalid GitLab user ID.")
})
.collect::<Result<Vec<_>, _>>()?;
return Ok(json!({"assignee_ids": if ids.is_empty() { vec![0] } else { ids }}));
}
Ok(json!({"assignees":users.iter().map(|u| &u.id).collect::<Vec<_>>()}))
}
#[tauri::command]
pub async fn set_integration_assignees(
provider: String,
base_url: String,
username: String,
token: String,
target: AssignmentTarget,
users: Vec<IntegrationAssignee>,
) -> Result<Vec<IntegrationAssignee>, String> {
tauri::async_runtime::spawn_blocking(move || {
let api = AssignmentApi { client: comment_client()?, provider: &provider, username: &username, token: &token };
let url = target_url(&provider, &base_url, &target)?;
if users.iter().any(|u| u.id.trim().is_empty()) { return Err("Invalid assigned user.".into()); }
let value = if provider == "azure-devops" && target.kind == "review" {
let current = assigned(&api.send(Method::GET, url.clone(), None)?, &provider, "review")?;
// Only touch changed reviewers: rewriting existing reviewers resets their votes.
for user in users.iter().filter(|u| !current.iter().any(|old| old.id == u.id)) {
let mut endpoint = url.clone();
endpoint.path_segments_mut().map_err(|_| "Invalid reviewer URL.")?.push(&user.id);
api.send(Method::PUT, endpoint, Some(&json!({"id":user.id,"vote":0})))?;
}
for user in current.iter().filter(|u| !users.iter().any(|next| next.id == u.id)) {
let mut endpoint = url.clone();
endpoint.path_segments_mut().map_err(|_| "Invalid reviewer URL.")?.push(&user.id);
api.send(Method::DELETE, endpoint, None)?;
}
api.send(Method::GET, url, None)?
} else {
let current = if provider == "azure-devops" { api.send(Method::GET, url.clone(), None)? } else { Value::Null };
let payload = assignment_payload(&provider, &target.kind, &users, &current)?;
api.send(if provider.starts_with("gitlab") { Method::PUT } else { Method::PATCH }, url, Some(&payload))?
};
let actual = assigned(&value, &provider, &target.kind)?;
let ids = |users: &[IntegrationAssignee]| users.iter().map(|u| u.id.to_lowercase()).collect::<BTreeSet<_>>();
if ids(&actual) != ids(&users) { return Err("The provider did not confirm all assignments. Reload and check your permissions or the provider's assignee limit.".into()); }
Ok(actual)
}).await.map_err(|e| e.to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
fn target(kind: &str) -> AssignmentTarget {
AssignmentTarget {
repository: "team/repo".into(),
repository_id: "repo-id".into(),
number: 12,
kind: kind.into(),
}
}
fn person(id: &str) -> IntegrationAssignee {
IntegrationAssignee {
id: id.into(),
username: "alex@example.com".into(),
name: "Alex".into(),
}
}
#[test]
fn routes_prs_and_issues_to_correct_provider_endpoints() {
assert_eq!(
target_url("github", "https://github.com", &target("review"))
.unwrap()
.path(),
"/repos/team/repo/issues/12"
);
assert_eq!(
target_url("gitea", "https://git.test/sub", &target("review"))
.unwrap()
.path(),
"/sub/api/v1/repos/team/repo/issues/12"
);
assert_eq!(
target_url(
"gitlab-self-hosted",
"https://git.test/sub",
&target("review")
)
.unwrap()
.path(),
"/sub/api/v4/projects/team%2Frepo/merge_requests/12"
);
assert_eq!(
target_url("gitlab", "https://git.test", &target("issue"))
.unwrap()
.path(),
"/api/v4/projects/team%2Frepo/issues/12"
);
assert_eq!(
target_url(
"azure-devops",
"https://dev.azure.com/org/",
&target("review")
)
.unwrap()
.path(),
"/org/team/_apis/git/repositories/repo-id/pullrequests/12/reviewers"
);
assert!(target_url("gitea", "https://git.test", &target("other")).is_err());
}
#[test]
fn payloads_support_assignment_and_removal() {
assert_eq!(
assignment_payload("github", "review", &[person("alex")], &Value::Null).unwrap(),
json!({"assignees":["alex"]})
);
assert_eq!(
assignment_payload("gitea", "issue", &[], &Value::Null).unwrap(),
json!({"assignees":[]})
);
assert_eq!(
assignment_payload("gitlab", "review", &[person("42")], &Value::Null).unwrap(),
json!({"assignee_ids":[42]})
);
assert_eq!(
assignment_payload("gitlab", "issue", &[], &Value::Null).unwrap(),
json!({"assignee_ids":[0]})
);
assert!(assignment_payload("gitlab", "issue", &[person("alex")], &Value::Null).is_err());
let patch = assignment_payload(
"azure-devops",
"issue",
&[person("uuid")],
&json!({"rev":3}),
)
.unwrap();
assert_eq!(patch[0], json!({"op":"test","path":"/rev","value":3}));
assert_eq!(patch[1]["value"], "alex@example.com");
assert_eq!(
assignment_payload("azure-devops", "issue", &[], &json!({"rev":3})).unwrap()[1]["value"],
""
);
assert!(
assignment_payload(
"azure-devops",
"issue",
&[person("a"), person("b")],
&json!({"rev":3})
)
.is_err()
);
assert!(assignment_payload("azure-devops", "issue", &[], &Value::Null).is_err());
}
#[test]
fn gitea_accepts_unassigned_and_legacy_responses_without_hiding_invalid_data() {
for kind in ["issue", "review"] {
for response in [
json!({"assignees":null,"assignee":null}),
json!({"assignees":null}),
json!({"assignees":[]}),
json!({"assignee":null}),
] {
assert!(assigned(&response, "gitea", kind).unwrap().is_empty());
}
for response in [
json!({"assignee":{"login":"alex"}}),
json!({"assignees":null,"assignee":{"login":"alex"}}),
] {
assert_eq!(assigned(&response, "gitea", kind).unwrap()[0].id, "alex");
}
assert_eq!(
assigned(
&json!({"assignees":[{"login":"alex"},{"login":"sam"}]}),
"gitea",
kind
)
.unwrap()
.len(),
2
);
for response in [
json!({}),
json!({"assignees":"invalid"}),
json!({"assignees":null,"assignee":{}}),
] {
assert!(assigned(&response, "gitea", kind).is_err());
}
}
assert!(assigned(&json!({"assignees":null}), "github", "issue").is_err());
}
#[test]
fn reads_identities_and_rejects_missing_assignment_data() {
assert_eq!(
assigned(
&json!({"assignees":[{"login":"alex","id":3}]}),
"github",
"review"
)
.unwrap()[0]
.id,
"alex"
);
assert_eq!(
assigned(
&json!({"assignees":[{"username":"alex","id":42}]}),
"gitlab",
"issue"
)
.unwrap()[0]
.id,
"42"
);
assert_eq!(assigned(&json!({"fields":{"System.AssignedTo":{"id":"uuid","displayName":"Alex","uniqueName":"alex@example.com"}}}), "azure-devops", "issue").unwrap()[0], person("uuid"));
assert!(
assigned(&json!({"fields":{}}), "azure-devops", "issue")
.unwrap()
.is_empty()
);
assert!(assigned(&json!({}), "github", "review").is_err());
}
// Exercise the actual authenticated HTTP path against a local provider fixture.
fn fixture(
responses: Vec<(String, u16, String, String)>,
) -> (String, std::thread::JoinHandle<Vec<String>>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let worker = std::thread::spawn(move || {
let mut requests = vec![];
for (expected, status, headers, body) in responses {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut data = Vec::new();
let header_end = loop {
let mut byte = [0];
stream.read_exact(&mut byte).unwrap();
data.push(byte[0]);
if data.ends_with(b"\r\n\r\n") {
break data.len();
}
};
let header = String::from_utf8_lossy(&data).to_string();
let length = header
.lines()
.find_map(|line| {
line.to_lowercase()
.strip_prefix("content-length:")
.map(|n| n.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
data.resize(header_end + length, 0);
stream.read_exact(&mut data[header_end..]).unwrap();
let received = String::from_utf8(data).unwrap();
assert!(
received.starts_with(&expected),
"Unexpected HTTP request: {expected}"
);
assert!(header.to_lowercase().contains("authorization:"));
requests.push(received);
write!(stream, "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}", body.len()).unwrap();
}
requests
});
(base, worker)
}
#[tokio::test]
async fn user_directory_follows_pages_and_deduplicates_users() {
let (base, worker) = fixture(vec![
(
"GET /api/v1/repos/team/repo/assignees?".into(),
200,
"Link: </next>; rel=\"next\"\r\n".into(),
json!([{"login":"alex","id":1}]).to_string(),
),
(
"GET /api/v1/repos/team/repo/assignees?".into(),
200,
String::new(),
json!([{"login":"alex","id":1},{"login":"sam","id":2}]).to_string(),
),
]);
let users = list_integration_assignees(
"gitea".into(),
base,
"qa".into(),
"fixture-token".into(),
target("issue"),
)
.await
.unwrap();
assert_eq!(users.len(), 2);
let requests = worker.join().unwrap();
assert!(requests[0].contains("page=1"));
assert!(requests[1].contains("page=2"));
}
#[tokio::test]
async fn azure_reviewer_changes_preserve_existing_votes() {
let root = "/team/_apis/git/repositories/repo-id/pullrequests/12/reviewers";
let old = json!({"id":"a","uniqueName":"alex@example.com","displayName":"Alex","vote":10});
let new = json!({"id":"c","uniqueName":"sam@example.com","displayName":"Sam","vote":0});
let (base, worker) = fixture(vec![
(
format!("GET {root}?"),
200,
String::new(),
json!({"value":[old,{"id":"b","displayName":"Former reviewer"}]}).to_string(),
),
(
format!("PUT {root}/c?"),
200,
String::new(),
new.to_string(),
),
(
format!("DELETE {root}/b?"),
204,
String::new(),
String::new(),
),
(
format!("GET {root}?"),
200,
String::new(),
json!({"value":[old,new]}).to_string(),
),
]);
let users = set_integration_assignees(
"azure-devops".into(),
base,
"qa".into(),
"fixture-token".into(),
target("review"),
vec![person("a"), person("c")],
)
.await
.unwrap();
assert_eq!(users.len(), 2);
let requests = worker.join().unwrap();
let body: Value =
serde_json::from_str(requests[1].split_once("\r\n\r\n").unwrap().1).unwrap();
assert_eq!(body, json!({"id":"c","vote":0}));
assert!(
!requests
.iter()
.any(|request| request.starts_with(&format!("PUT {root}/a")))
);
}
#[tokio::test]
async fn silent_provider_rejection_is_not_reported_as_success() {
let (base, worker) = fixture(vec![(
"PATCH /api/v1/repos/team/repo/issues/12 ".into(),
200,
String::new(),
json!({"assignees":[]}).to_string(),
)]);
let result = set_integration_assignees(
"gitea".into(),
base,
"qa".into(),
"fixture-token".into(),
target("issue"),
vec![person("alex")],
)
.await;
assert!(result.unwrap_err().contains("did not confirm"));
let requests = worker.join().unwrap();
let body: Value =
serde_json::from_str(requests[0].split_once("\r\n\r\n").unwrap().1).unwrap();
assert_eq!(body, json!({"assignees":["alex"]}));
}
}
+4
View File
@@ -37,6 +37,7 @@ use git::{
use integrations::{ use integrations::{
create_integration_review_request, list_integration_repository_branches, create_integration_review_request, list_integration_repository_branches,
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
list_integration_assignees, get_integration_assignees, set_integration_assignees,
create_integration_issue, list_azure_issue_projects, list_azure_issue_types, create_integration_issue, list_azure_issue_projects, list_azure_issue_types,
run_integration_review_action, get_integration_review_merge_options, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, list_azure_issue_states, set_azure_issue_state, run_integration_review_action, get_integration_review_merge_options, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, list_azure_issue_states, set_azure_issue_state,
}; };
@@ -456,6 +457,9 @@ async fn main() {
move_integration_board_card, move_integration_board_card,
list_integration_issue_comments, list_integration_issue_comments,
add_integration_issue_comment, add_integration_issue_comment,
list_integration_assignees,
get_integration_assignees,
set_integration_assignees,
create_integration_issue, create_integration_issue,
list_azure_issue_projects, list_azure_issue_projects,
list_azure_issue_types, list_azure_issue_types,
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import SelectMenu from "./SelectMenu.svelte";
import { X } from "@lucide/svelte";
import { listIntegrationAssignees } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { AssignmentTarget, GitIntegrationSource, IntegrationAssignee, StoredCredential } from "../types";
let { source, target, de, loadCredential, value = $bindable<IntegrationAssignee[]>([]), disabled = false }: {
source: GitIntegrationSource; target: AssignmentTarget; de: boolean;
loadCredential: (key: string) => Promise<StoredCredential | null>;
value?: IntegrationAssignee[]; disabled?: boolean;
} = $props();
let users = $state<IntegrationAssignee[]>([]);
let loading = $state(false);
let error = $state("");
let retry = $state(0);
const reviewer = $derived(source.provider === "azure-devops" && target.kind === "review");
const single = $derived(source.provider === "azure-devops" && target.kind === "issue");
const label = $derived(reviewer ? "Reviewer" : (de ? "Zugewiesen an" : "Assignees"));
function displayName(user: IntegrationAssignee): string {
return [user.name, user.username].map(name => name.trim()).find(name => name && !name.includes("@")) || (de ? "Benutzer" : "User");
}
const options = $derived(users.filter(user => !value.some(selected => selected.id === user.id)).map(user => ({
value: user.id, label: displayName(user),
})));
$effect(() => {
const current = source;
const context = { repository: target.repository, repositoryId: target.repositoryId, kind: target.kind, number: 0 };
void retry;
let cancelled = false;
users = []; error = ""; loading = !!context.repository;
if (context.repository) void (async () => {
try {
const auth = await loadCredential(integrationCredentialKey(current.provider, current.accountId));
if (!auth?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
const result = await listIntegrationAssignees(current.provider, current.baseUrl, auth.username, auth.password, context);
if (!cancelled) users = result;
} catch (cause) { if (!cancelled) error = String(cause); }
finally { if (!cancelled) loading = false; }
})();
return () => { cancelled = true; };
});
function add(id: string) {
if (disabled) return;
const user = users.find(user => user.id === id);
if (user) value = single ? [user] : [...value, user];
}
</script>
<div class="assignee-picker">
<span class="field-label">{label}</span>
{#if value.length}
<ul aria-label={label}>
{#each value as user (user.id)}
<li><span>{displayName(user)}</span><button type="button" {disabled} aria-label={`${de ? "Entfernen" : "Remove"}: ${displayName(user)}`} onclick={() => value = value.filter(selected => selected.id !== user.id)}><X size={13}/></button></li>
{/each}
</ul>
{/if}
<SelectMenu value="" {options} disabled={disabled || loading || !target.repository || !!error} searchable ariaLabel={label}
placeholder={loading ? (de ? "Benutzer werden geladen …" : "Loading users …") : !target.repository ? (de ? "Zuerst Repository/Projekt auswählen" : "Select a repository/project first") : single && value.length ? (de ? "Benutzer wechseln …" : "Change user …") : (de ? "Benutzer auswählen …" : "Select user …")}
searchPlaceholder={de ? "Benutzer suchen …" : "Search users …"} emptyText={de ? "Keine verfügbaren Benutzer" : "No available users"} onChange={add}/>
{#if error}<div class="error" role="alert">{de ? "Benutzer konnten nicht geladen werden." : "Could not load users."} {error}<button type="button" {disabled} onclick={() => retry++}>{de ? "Erneut laden" : "Retry"}</button></div>
{:else if target.repository && !loading && !users.length}<small>{de ? "Keine zuweisbaren Benutzer gefunden." : "No assignable users found."}</small>{/if}
{#if source.provider === "azure-devops"}<small>{reviewer ? (de ? "Azure-PRs verwenden Reviewer. Auswahl aus den Projektteams." : "Azure PRs use reviewers. Select from project teams.") : (de ? "Auswahl aus den Projektteams; eine Person pro Work Item." : "Select from project teams; one person per work item.")}</small>{/if}
</div>
<style>
.assignee-picker{display:grid;gap:8px;min-width:0;font-size:12px;color:var(--color-ink)}
.field-label{font-weight:500}ul{display:flex;flex-wrap:wrap;gap:6px;list-style:none;margin:0;padding:0}
li{display:flex;align-items:center;gap:6px;max-width:100%;padding:4px 6px;background:var(--color-surface);border:1px solid var(--color-border)}
li span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
button{font:inherit;color:inherit;cursor:pointer;background:var(--color-surface);border:1px solid var(--color-border);padding:4px 8px}li button{display:grid;place-items:center;border:0;padding:2px;background:transparent}
button:disabled{opacity:.5;cursor:default}button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}
small{font-size:11px;color:var(--color-ink-dim);line-height:1.5}.error{color:var(--color-danger);overflow-wrap:anywhere}.error button{margin-top:6px;display:block}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import AssigneePicker from "./AssigneePicker.svelte";
import { getIntegrationAssignees, setIntegrationAssignees } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { AssignmentTarget, GitIntegrationSource, IntegrationAssignee, StoredCredential } from "../types";
let { source, target, de, loadCredential, disabled = false, onSaved = () => {} }: {
source: GitIntegrationSource; target: AssignmentTarget; de: boolean; disabled?: boolean;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onSaved?: (users: IntegrationAssignee[]) => void;
} = $props();
let value = $state<IntegrationAssignee[]>([]);
let original = $state<IntegrationAssignee[]>([]);
let loading = $state(true);
let busy = $state(false);
let error = $state("");
let loaded = $state(false);
let retry = $state(0);
let generation = 0;
const ids = (users: IntegrationAssignee[]) => JSON.stringify(users.map(user => user.id).sort());
const dirty = $derived(ids(value) !== ids(original));
async function auth(current: GitIntegrationSource) {
const result = await loadCredential(integrationCredentialKey(current.provider, current.accountId));
if (!result?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
return result;
}
$effect(() => {
const current = source;
const context = { ...target };
void retry;
const requestGeneration = ++generation;
loading = true; loaded = false; busy = false; error = ""; value = []; original = [];
void (async () => {
try {
const credential = await auth(current);
const result = await getIntegrationAssignees(current.provider, current.baseUrl, credential.username, credential.password, context);
if (requestGeneration !== generation) return;
original = result; value = [...result]; loaded = true;
} catch (cause) { if (requestGeneration === generation) error = String(cause); }
finally { if (requestGeneration === generation) loading = false; }
})();
return () => { generation++; };
});
async function save() {
if (!loaded || busy || disabled || !dirty) return;
const current = source, context = { ...target }, users = [...value], requestGeneration = generation;
const savedCallback = onSaved;
busy = true; error = "";
try {
const credential = await auth(current);
const result = await setIntegrationAssignees(current.provider, current.baseUrl, credential.username, credential.password, context, users);
savedCallback(result);
if (requestGeneration !== generation) return;
original = result; value = [...result];
} catch (cause) { if (requestGeneration === generation) error = String(cause); }
finally { if (requestGeneration === generation) busy = false; }
}
</script>
<div class="assignment-editor" aria-busy={loading || busy}>
<AssigneePicker {source} {target} {de} {loadCredential} bind:value disabled={disabled || loading || busy || !loaded}/>
{#if loading}<small role="status">{de ? "Zuweisung wird geladen …" : "Loading assignment …"}</small>{/if}
{#if error}<p role="alert">{error}</p><button type="button" disabled={busy} onclick={() => retry++}>{de ? "Aktuelle Zuweisung neu laden" : "Reload current assignment"}</button>{/if}
{#if loaded && dirty}<div class="actions"><button type="button" disabled={disabled || busy} onclick={save}>{busy ? (de ? "Wird gespeichert …" : "Saving …") : (de ? "Zuweisung speichern" : "Save assignment")}</button><button type="button" disabled={disabled || busy} onclick={() => { value = [...original]; error = ""; }}>{de ? "Abbrechen" : "Cancel"}</button></div>{/if}
</div>
<style>
.assignment-editor{display:grid;gap:8px;min-width:0}.actions{display:flex;flex-wrap:wrap;gap:6px}button{font:inherit;font-size:11px;padding:6px 8px;color:var(--color-ink);background:var(--color-surface);border:1px solid var(--color-border);cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}small{color:var(--color-ink-dim);font-size:11px}p{margin:0;font-size:12px;line-height:1.5;color:var(--color-danger);overflow-wrap:anywhere}
</style>
+22 -8
View File
@@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { CirclePlus, X } from "@lucide/svelte"; import { CirclePlus, X } from "@lucide/svelte";
import AssigneePicker from "./AssigneePicker.svelte";
import { setIntegrationAssignees } from "../git";
import type { IntegrationAssignee } from "../types";
import SelectMenu from "./SelectMenu.svelte"; import SelectMenu from "./SelectMenu.svelte";
import CommentEditor from "./CommentEditor.svelte"; import CommentEditor from "./CommentEditor.svelte";
import { createIntegrationIssue, listIntegrationRepositories, listAzureIssueProjects, listAzureIssueTypes } from "../git"; import { createIntegrationIssue, listIntegrationRepositories, listAzureIssueProjects, listAzureIssueTypes } from "../git";
@@ -20,6 +23,9 @@
let workItemType = $state(""); let workItemType = $state("");
let title = $state(""); let title = $state("");
let description = $state(""); let description = $state("");
let assignees = $state<IntegrationAssignee[]>([]);
let created = $state<IntegrationIssue | null>(null);
function finish() { if (created) onCreated(created); else onClose(); }
let loading = $state(true); let loading = $state(true);
let typesLoading = $state(false); let typesLoading = $state(false);
let busy = $state(false); let busy = $state(false);
@@ -56,6 +62,7 @@
finally { if (!destroyed) loading = false; } finally { if (!destroyed) loading = false; }
} }
async function selectTarget(value: string) { async function selectTarget(value: string) {
assignees = [];
repository = value; types = []; workItemType = ""; typeError = ""; repository = value; types = []; workItemType = ""; typeError = "";
const generation = ++typeGeneration; const generation = ++typeGeneration;
typesLoading = azure && !!value; typesLoading = azure && !!value;
@@ -75,8 +82,13 @@
busy = true; error = ""; busy = true; error = "";
try { try {
const auth = await credential(); const auth = await credential();
const issue = await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType); created ??= await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType);
onCreated(issue); if (assignees.length) {
const assigned = await setIntegrationAssignees(source.provider, source.baseUrl, auth.username, auth.password,
{ repository: created.repositoryName, number: created.number, kind: "issue" }, assignees);
created.assignees = assigned.map(user => azure ? user.name || user.username : user.username || user.name);
}
onCreated(created);
} catch (cause) { if (!destroyed) error = String(cause); } } catch (cause) { if (!destroyed) error = String(cause); }
finally { if (!destroyed) busy = false; } finally { if (!destroyed) busy = false; }
} }
@@ -84,10 +96,10 @@
onDestroy(() => { destroyed = true; typeGeneration++; }); onDestroy(() => { destroyed = true; typeGeneration++; });
</script> </script>
<dialog bind:this={dialog} aria-labelledby="create-issue-title" oncancel={event => { event.preventDefault(); if (!busy) onClose(); }}> <dialog bind:this={dialog} aria-labelledby="create-issue-title" oncancel={event => { event.preventDefault(); if (!busy) finish(); }}>
<form onsubmit={submit}> <form onsubmit={submit}>
<header class="unified-dialog-header"><span class="unified-dialog-icon" aria-hidden="true"><CirclePlus size={20}/></span><div class="unified-dialog-text"><h2 id="create-issue-title">{de ? "Neues Issue" : "New issue"}</h2><p>{source.label}</p></div><button class="dialog-close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header> <header class="unified-dialog-header"><span class="unified-dialog-icon" aria-hidden="true"><CirclePlus size={20}/></span><div class="unified-dialog-text"><h2 id="create-issue-title">{de ? "Neues Issue" : "New issue"}</h2><p>{source.label}</p></div><button class="dialog-close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={finish}><X size={18}/></button></header>
<div class="body"> <fieldset class="body" disabled={!!created}>
<div class="field"><span>{azure ? (de ? "Projekt" : "Project") : "Repository"}</span> <div class="field"><span>{azure ? (de ? "Projekt" : "Project") : "Repository"}</span>
<SelectMenu value={repository} options={targets} showSelectedGroup searchable disabled={loading || busy} ariaLabel={azure ? (de ? "Projekt" : "Project") : "Repository"} placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Bitte auswählen" : "Select an option")} searchPlaceholder={de ? "Suchen …" : "Search …"} onChange={value => void selectTarget(value)}/> <SelectMenu value={repository} options={targets} showSelectedGroup searchable disabled={loading || busy} ariaLabel={azure ? (de ? "Projekt" : "Project") : "Repository"} placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Bitte auswählen" : "Select an option")} searchPlaceholder={de ? "Suchen …" : "Search …"} onChange={value => void selectTarget(value)}/>
</div> </div>
@@ -98,6 +110,7 @@
{#if typeError}<p class="error" role="alert">{typeError}</p><button type="button" disabled={typesLoading || busy} onclick={() => selectTarget(repository)}>{de ? "Typen erneut laden" : "Retry types"}</button> {#if typeError}<p class="error" role="alert">{typeError}</p><button type="button" disabled={typesLoading || busy} onclick={() => selectTarget(repository)}>{de ? "Typen erneut laden" : "Retry types"}</button>
{:else if repository && !typesLoading && !types.length}<p>{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}</p>{/if} {:else if repository && !typesLoading && !types.length}<p>{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}</p>{/if}
{/if} {/if}
<AssigneePicker {source} target={{ repository, number: 0, kind: "issue" }} {de} {loadCredential} bind:value={assignees} disabled={busy || !!created}/>
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={busy} required placeholder={de ? "Was soll erledigt werden?" : "What needs to be done?"}/></label> <label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={busy} required placeholder={de ? "Was soll erledigt werden?" : "What needs to be done?"}/></label>
<div class="field"> <div class="field">
<span>{de ? "Beschreibung" : "Description"}</span> <span>{de ? "Beschreibung" : "Description"}</span>
@@ -106,13 +119,14 @@
previewLabel={de ? "Beschreibungsvorschau" : "Description preview"} previewLabel={de ? "Beschreibungsvorschau" : "Description preview"}
placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"} /> placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"} />
</div> </div>
{#if error}<p class="error" role="alert">{de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed."} {error}</p>{/if} {#if error}<p class="error" role="alert">{created ? (de ? "Issue wurde erstellt, aber die Zuweisung konnte nicht bestätigt werden. Du kannst nur die Zuweisung erneut versuchen oder mit Fertig fortfahren." : "Issue created, but assignment could not be confirmed. Retry the assignment or continue with Done.") : (de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed.")} {error}</p>{/if}
</div> </fieldset>
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : (de ? "Issue erstellen" : "Create issue")}</button></footer> <footer><button type="button" disabled={busy} onclick={finish}>{created ? (de ? "Fertig" : "Done") : (de ? "Abbrechen" : "Cancel")}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : created ? (de ? "Zuweisung erneut versuchen" : "Retry assignment") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
</form> </form>
</dialog> </dialog>
<style> <style>
fieldset.body {border:0;margin:0;min-width:0}
dialog {margin:auto;width:min(640px,calc(100vw - 32px));max-height:calc(100dvh - 40px);padding:0;border:1px solid var(--color-border);background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font:inherit;overflow:auto} dialog {margin:auto;width:min(640px,calc(100vw - 32px));max-height:calc(100dvh - 40px);padding:0;border:1px solid var(--color-border);background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font:inherit;overflow:auto}
dialog::backdrop {background:color-mix(in srgb, var(--app-dialog-backdrop) 92%, transparent)} dialog::backdrop {background:color-mix(in srgb, var(--app-dialog-backdrop) 92%, transparent)}
header {display:flex;align-items:center;gap:12px;padding:18px 24px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border)} header {display:flex;align-items:center;gap:12px;padding:18px 24px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border)}
+19 -8
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import AssigneePicker from "./AssigneePicker.svelte";
import { setIntegrationAssignees } from "../git";
import type { IntegrationAssignee } from "../types";
import SelectMenu from "./SelectMenu.svelte"; import SelectMenu from "./SelectMenu.svelte";
import CommentEditor from "./CommentEditor.svelte"; import CommentEditor from "./CommentEditor.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
@@ -51,6 +54,7 @@
}); });
async function loadRepositoryBranches(repository?: GitIntegrationRepository) { async function loadRepositoryBranches(repository?: GitIntegrationRepository) {
const generation = ++branchGeneration; const generation = ++branchGeneration;
assignees = [];
branches = []; sourceBranch = ""; targetBranch = ""; defaultBranch = ""; branchError = ""; branches = []; sourceBranch = ""; targetBranch = ""; defaultBranch = ""; branchError = "";
branchesLoading = !!repository; branchesLoading = !!repository;
if (!repository) return; if (!repository) return;
@@ -85,6 +89,9 @@
let generating = $state(false); let generating = $state(false);
let title = $state(""); let title = $state("");
let description = $state(""); let description = $state("");
let assignees = $state<IntegrationAssignee[]>([]);
let created = $state<IntegrationReviewRequest | null>(null);
function finish() { if (created) onCreated(created); else onClose(); }
let loading = $state(true); let loading = $state(true);
let busy = $state(false); let busy = $state(false);
let error = $state(""); let error = $state("");
@@ -137,17 +144,19 @@
try { try {
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId)); 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."); 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); created ??= await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
onCreated(request); if (assignees.length) await setIntegrationAssignees(source.provider, source.baseUrl, credential.username, credential.password,
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); } { repository: created.repositoryName, repositoryId: created.repositoryId, number: created.number, kind: "review" }, assignees);
onCreated(created);
} catch (cause) { error = (created ? (de ? "PR wurde erstellt, aber die Zuweisung konnte nicht bestätigt werden. Du kannst nur die Zuweisung erneut versuchen oder mit Fertig fortfahren. " : "PR created, but assignment could not be confirmed. Retry the assignment or continue with Done. ") : "") + (cause instanceof Error ? cause.message : String(cause)); }
finally { busy = false; } finally { busy = false; }
} }
</script> </script>
<dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy && !generating) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy && !generating) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}> <dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy && !generating) finish(); }} onclick={(event) => { if (event.target === dialog && !busy && !generating) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) finish(); } }}>
<form onsubmit={submit}> <form onsubmit={submit}>
<header class="unified-dialog-header"><div class="heading-icon unified-dialog-icon"><GitPullRequest size={19} /></div><div class="unified-dialog-text"><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button data-dialog-close class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={generating || busy} onclick={onClose}><X size={18}/></button></header> <header class="unified-dialog-header"><div class="heading-icon unified-dialog-icon"><GitPullRequest size={19} /></div><div class="unified-dialog-text"><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button data-dialog-close class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={generating || busy} onclick={finish}><X size={18}/></button></header>
<div class="body"> <fieldset class="body" disabled={!!created}>
{#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} {#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}
<div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div> <div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div>
<SelectMenu value={repositoryId} options={repositoryOptions} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} showSelectedGroup onChange={value => repositoryId = value}> <SelectMenu value={repositoryId} options={repositoryOptions} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} showSelectedGroup onChange={value => repositoryId = value}>
@@ -169,6 +178,7 @@
{#if sameBranch}<p class="validation">{de ? "Quell- und Zielbranch müssen unterschiedlich sein." : "Source and target branches must be different."}</p>{/if} {#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> <p class="hint">{de ? "Beide Branches müssen bereits in diesem Repository gepusht sein." : "Both branches must already be pushed to this repository."}</p>
<div class="ai-draft-action"><button type="button" disabled={generating || busy || branchesLoading || !sourceBranch || !targetBranch || sameBranch} onclick={generateDraft}>{#if generating}<LoaderCircle class="spin" size={15}/>{:else}<Sparkles size={15}/>{/if}{generating ? (de ? "Wird generiert …" : "Generating…") : (de ? "Mit KI erstellen" : "Generate with AI")}</button><small>{de ? "Erstellt Titel und Beschreibung aus dem lokalen Stand der Remote-Branches. Vorher Fetch ausführen." : "Creates a title and description from locally fetched remote branches. Fetch first."}</small></div> <div class="ai-draft-action"><button type="button" disabled={generating || busy || branchesLoading || !sourceBranch || !targetBranch || sameBranch} onclick={generateDraft}>{#if generating}<LoaderCircle class="spin" size={15}/>{:else}<Sparkles size={15}/>{/if}{generating ? (de ? "Wird generiert …" : "Generating…") : (de ? "Mit KI erstellen" : "Generate with AI")}</button><small>{de ? "Erstellt Titel und Beschreibung aus dem lokalen Stand der Remote-Branches. Vorher Fetch ausführen." : "Creates a title and description from locally fetched remote branches. Fetch first."}</small></div>
<AssigneePicker {source} target={{ repository: repositoryById(repositoryId)?.fullName ?? "", repositoryId, number: 0, kind: "review" }} {de} {loadCredential} bind:value={assignees} disabled={generating || busy || !!created}/>
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={generating || busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label> <label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={generating || busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
<div class="repository-field"> <div class="repository-field">
<span>{de ? "Beschreibung" : "Description"}</span> <span>{de ? "Beschreibung" : "Description"}</span>
@@ -177,12 +187,13 @@
previewLabel={de ? "Beschreibungsvorschau" : "Description preview"} previewLabel={de ? "Beschreibungsvorschau" : "Description preview"}
placeholder={de ? "Beschreibe deine Änderungen …" : "Describe your changes…"} /> placeholder={de ? "Beschreibe deine Änderungen …" : "Describe your changes…"} />
</div> </div>
</div> </fieldset>
<footer><button type="button" disabled={generating || busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={generating || busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : heading}</button></footer> <footer><button type="button" disabled={generating || busy} onclick={finish}>{created ? (de ? "Fertig" : "Done") : (de ? "Abbrechen" : "Cancel")}</button><button class="primary" type="submit" disabled={generating || busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : created ? (de ? "Zuweisung erneut versuchen" : "Retry assignment") : heading}</button></footer>
</form> </form>
</dialog> </dialog>
<style> <style>
fieldset.body {border:0;margin:0;min-width:0}
.ai-draft-action{display:flex;align-items:center;gap:12px}.ai-draft-action small{color:var(--color-ink-muted);line-height:1.5}.ai-draft-action button{flex-shrink:0} .ai-draft-action{display:flex;align-items:center;gap:12px}.ai-draft-action small{color:var(--color-ink-muted);line-height:1.5}.ai-draft-action button{flex-shrink:0}
.repository-field{min-width:0;display:grid;gap:9px}.field-heading{display:flex;align-items:center;justify-content:space-between;font-weight:600}.field-heading small{font-size:10px;font-weight:400;color:var(--color-ink-faint)} .repository-field{min-width:0;display:grid;gap:9px}.field-heading{display:flex;align-items:center;justify-content:space-between;font-weight:600}.field-heading small{font-size:10px;font-weight:400;color:var(--color-ink-faint)}
dialog{margin:auto;width:min(640px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:color-mix(in srgb, var(--app-dialog-backdrop) 92%, transparent);backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 26px;background:var(--app-dialog-chrome);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{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:20px;padding:24px 26px}label{display:grid;gap:8px;font-weight:600;min-width:0}input{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}input:focus{outline:2px solid var(--color-accent);outline-offset:1px}.branches{padding:16px;background:color-mix(in srgb,var(--color-accent) 3%,var(--app-bg));border:1px solid var(--color-border-subtle);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);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 26px;background:var(--app-dialog-chrome);border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent-solid);border-color:var(--color-accent-solid);color:var(--color-on-accent)}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}} dialog{margin:auto;width:min(640px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:color-mix(in srgb, var(--app-dialog-backdrop) 92%, transparent);backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 26px;background:var(--app-dialog-chrome);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{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:20px;padding:24px 26px}label{display:grid;gap:8px;font-weight:600;min-width:0}input{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}input:focus{outline:2px solid var(--color-accent);outline-offset:1px}.branches{padding:16px;background:color-mix(in srgb,var(--color-accent) 3%,var(--app-bg));border:1px solid var(--color-border-subtle);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);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 26px;background:var(--app-dialog-chrome);border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent-solid);border-color:var(--color-accent-solid);color:var(--color-on-accent)}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
+14 -1
View File
@@ -11,6 +11,7 @@
import CreateIssueDialog from "./CreateIssueDialog.svelte"; import CreateIssueDialog from "./CreateIssueDialog.svelte";
import IssueComments from "./IssueComments.svelte"; import IssueComments from "./IssueComments.svelte";
import IssueLabels from "./IssueLabels.svelte"; import IssueLabels from "./IssueLabels.svelte";
import AssignmentEditor from "./AssignmentEditor.svelte";
import IssueAssignees from "./IssueAssignees.svelte"; import IssueAssignees from "./IssueAssignees.svelte";
import { issueTone, issueStateLabel } from "../issuePresentation"; import { issueTone, issueStateLabel } from "../issuePresentation";
import IntegrationBoardView from "./IntegrationBoardView.svelte"; import IntegrationBoardView from "./IntegrationBoardView.svelte";
@@ -330,7 +331,19 @@
{/if} {/if}
{#if actionError}<p class="comment-error" role="alert">{actionError}</p>{/if} {#if actionError}<p class="comment-error" role="alert">{actionError}</p>{/if}
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openIssue(selected!.webUrl)}><ExternalLink size={14} />{de ? "Im Browser öffnen" : "Open in browser"}</button>{/if} {#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openIssue(selected!.webUrl)}><ExternalLink size={14} />{de ? "Im Browser öffnen" : "Open in browser"}</button>{/if}
<section><h3>{de ? "Zugewiesen" : "Assignees"}</h3><IssueAssignees names={selected.assignees} /></section> <section>
{#key `${sourceKey}:${selected.id}`}
{@const issueId = selected.id}
{@const assignmentSourceKey = sourceKey}
{@const assignmentProvider = source.provider}
<AssignmentEditor {source} target={{ repository: selected.repositoryName, number: selected.number, kind: "issue" }} {de} {loadCredential} disabled={!!closingId || loading} onSaved={users => {
const assignees = users.map(user => assignmentProvider === "azure-devops" ? user.name || user.username : user.username || user.name);
const saved = cache.get(assignmentSourceKey);
if (saved) cache.set(assignmentSourceKey, { ...saved, issues: saved.issues.map(item => item.id === issueId ? { ...item, assignees } : item) });
if (sourceKey === assignmentSourceKey) issues = issues.map(item => item.id === issueId ? { ...item, assignees } : item);
}}/>
{/key}
</section>
<section><h3>Labels</h3>{#if selected.labels.length}<IssueLabels labels={selected.labels} />{:else}<small>{de ? "Keine Labels" : "No labels"}</small>{/if}</section> <section><h3>Labels</h3>{#if selected.labels.length}<IssueLabels labels={selected.labels} />{:else}<small>{de ? "Keine Labels" : "No labels"}</small>{/if}</section>
<section><h3>Repository</h3><strong class="issue-detail-repo"><FolderGit2 size={15} />{selected.repositoryName}</strong></section> <section><h3>Repository</h3><strong class="issue-detail-repo"><FolderGit2 size={15} />{selected.repositoryName}</strong></section>
{#if selected.updatedAt}<section><h3>{de ? "Aktualisiert" : "Updated"}</h3><small>{new Date(selected.updatedAt).toLocaleString(de ? "de-DE" : "en-US")}</small></section>{/if} {#if selected.updatedAt}<section><h3>{de ? "Aktualisiert" : "Updated"}</h3><small>{new Date(selected.updatedAt).toLocaleString(de ? "de-DE" : "en-US")}</small></section>{/if}
+8
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { AiSettings } from "../types"; import type { AiSettings } from "../types";
import AssignmentEditor from "./AssignmentEditor.svelte";
import CreateReviewDialog from "./CreateReviewDialog.svelte"; import CreateReviewDialog from "./CreateReviewDialog.svelte";
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte"; import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
import { t } from "../i18n.svelte"; import { t } from "../i18n.svelte";
@@ -643,6 +644,13 @@
</main> </main>
<aside class="detail-sidebar"> <aside class="detail-sidebar">
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if isWaitingForResolvedStatus(selected)}<button class="detail-action" type="button" disabled><LoaderCircle class="spin" size={14} />{de ? "Status wird geprüft" : "Checking status"}</button>{:else if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div> <div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if isWaitingForResolvedStatus(selected)}<button class="detail-action" type="button" disabled><LoaderCircle class="spin" size={14} />{de ? "Status wird geprüft" : "Checking status"}</button>{:else if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
{#if activeSource}
<section class="people-section">
{#key `${activeSource.id}:${selected.id}`}
<AssignmentEditor source={activeSource} target={{ repository: selected.repositoryName, repositoryId: selected.repositoryId, number: selected.number, kind: "review" }} {de} {loadCredential} disabled={!!actionBusyId}/>
{/key}
</section>
{/if}
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong></div></section> <section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong></div></section>
<section class="detail-meta"><h3>Repository</h3><strong><GitBranch size={16} />{selected.repositoryName}</strong></section> <section class="detail-meta"><h3>Repository</h3><strong><GitBranch size={16} />{selected.repositoryName}</strong></section>
<section class="detail-meta"><h3>{de ? "Aktualisiert" : "Updated"}</h3><span><Clock3 size={16} />{formatDate(selected.updatedAt || selected.createdAt)}</span></section> <section class="detail-meta"><h3>{de ? "Aktualisiert" : "Updated"}</h3><span><Clock3 size={16} />{formatDate(selected.updatedAt || selected.createdAt)}</span></section>
+10
View File
@@ -757,3 +757,13 @@ export function checkoutSubmoduleRevision(path: string, modulePath: string, revi
export function getFileRestorePatch(path: string, commit: string, file: string): Promise<string> { export function getFileRestorePatch(path: string, commit: string, file: string): Promise<string> {
return invoke("get_file_restore_patch", { path, commit, file }); return invoke("get_file_restore_patch", { path, commit, file });
} }
export function listIntegrationAssignees(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, target: import("./types").AssignmentTarget): Promise<import("./types").IntegrationAssignee[]> {
return invoke("list_integration_assignees", { provider, baseUrl, username, token, target });
}
export function getIntegrationAssignees(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, target: import("./types").AssignmentTarget): Promise<import("./types").IntegrationAssignee[]> {
return invoke("get_integration_assignees", { provider, baseUrl, username, token, target });
}
export function setIntegrationAssignees(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, target: import("./types").AssignmentTarget, users: import("./types").IntegrationAssignee[]): Promise<import("./types").IntegrationAssignee[]> {
return invoke("set_integration_assignees", { provider, baseUrl, username, token, target, users });
}
+13
View File
@@ -511,3 +511,16 @@ export interface GitSubmodule {
conflicted: boolean; conflicted: boolean;
depth: number; depth: number;
} }
export interface IntegrationAssignee {
id: string;
username: string;
name: string;
}
export interface AssignmentTarget {
repository: string;
repositoryId?: string;
number: number;
kind: "issue" | "review";
}