diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index f086f4a..3e939f1 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -1,3 +1,5 @@ +mod assignees; +pub use assignees::*; mod cleanup; mod merge; pub use merge::get_integration_review_merge_options; diff --git a/src-tauri/src/integrations/assignees.rs b/src-tauri/src/integrations/assignees.rs new file mode 100644 index 0000000..8f874cd --- /dev/null +++ b/src-tauri/src/integrations/assignees.rs @@ -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 { + 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 { + 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 { + 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, 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 { + 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, 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, 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, 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 { + 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::() + .ok() + .filter(|id| *id > 0) + .ok_or("Invalid GitLab user ID.") + }) + .collect::, _>>()?; + return Ok(json!({"assignee_ids": if ids.is_empty() { vec![0] } else { ids }})); + } + Ok(json!({"assignees":users.iter().map(|u| &u.id).collect::>()})) +} + +#[tauri::command] +pub async fn set_integration_assignees( + provider: String, + base_url: String, + username: String, + token: String, + target: AssignmentTarget, + users: Vec, +) -> Result, 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, ¤t)?; + 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::>(); + 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>) { + 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::().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: ; 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"]})); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 46c2850..2152be5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -37,6 +37,7 @@ use git::{ use integrations::{ 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, + list_integration_assignees, get_integration_assignees, set_integration_assignees, 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, }; @@ -456,6 +457,9 @@ async fn main() { move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, + list_integration_assignees, + get_integration_assignees, + set_integration_assignees, create_integration_issue, list_azure_issue_projects, list_azure_issue_types, diff --git a/src/lib/components/AssigneePicker.svelte b/src/lib/components/AssigneePicker.svelte new file mode 100644 index 0000000..995f8a6 --- /dev/null +++ b/src/lib/components/AssigneePicker.svelte @@ -0,0 +1,75 @@ + + +
+ {label} + {#if value.length} +
    + {#each value as user (user.id)} +
  • {displayName(user)}
  • + {/each} +
+ {/if} + + {#if error} + {:else if target.repository && !loading && !users.length}{de ? "Keine zuweisbaren Benutzer gefunden." : "No assignable users found."}{/if} + {#if source.provider === "azure-devops"}{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.")}{/if} +
+ + diff --git a/src/lib/components/AssignmentEditor.svelte b/src/lib/components/AssignmentEditor.svelte new file mode 100644 index 0000000..907afff --- /dev/null +++ b/src/lib/components/AssignmentEditor.svelte @@ -0,0 +1,67 @@ + +
+ + {#if loading}{de ? "Zuweisung wird geladen …" : "Loading assignment …"}{/if} + {#if error}

{error}

{/if} + {#if loaded && dirty}
{/if} +
+ diff --git a/src/lib/components/CreateIssueDialog.svelte b/src/lib/components/CreateIssueDialog.svelte index 35d5877..4388930 100644 --- a/src/lib/components/CreateIssueDialog.svelte +++ b/src/lib/components/CreateIssueDialog.svelte @@ -1,6 +1,9 @@ - { event.preventDefault(); if (!busy) onClose(); }}> + { event.preventDefault(); if (!busy) finish(); }}>
-

{de ? "Neues Issue" : "New issue"}

{source.label}

-
+

{de ? "Neues Issue" : "New issue"}

{source.label}

+
{azure ? (de ? "Projekt" : "Project") : "Repository"} void selectTarget(value)}/>
@@ -98,6 +110,7 @@ {#if typeError} {:else if repository && !typesLoading && !types.length}

{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}

{/if} {/if} +
{de ? "Beschreibung" : "Description"} @@ -106,13 +119,14 @@ previewLabel={de ? "Beschreibungsvorschau" : "Description preview"} placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"} />
- {#if error}{/if} -
-
+ {#if error}{/if} + +