feat(integrations): add assignee/assignment support
Add a new integrations/assignees backend (src-tauri/src/integrations/assignees.rs) and expose commands to list, read and set assignees: - list_integration_assignees - get_integration_assignees - set_integration_assignees Introduce IntegrationAssignee and AssignmentTarget types and provider-specific URL/payload logic (GitHub, Gitea, GitLab / self-hosted, Azure DevOps). The code handles pagination, provider quirks (Gitea legacy fields, GitLab assignee_ids, Azure reviewer vs work-item differences) and validates/verifies assignments. Unit tests cover routing and payload behavior. Add UI components AssigneePicker.svelte and AssignmentEditor.svelte and update CreateIssueDialog, CreateReviewDialog, IssueCenter, ReviewCenter, git types and git.ts to use the new assignment functionality.
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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, ¤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::<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"]}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)}}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user