Add embedded PTY terminal, keyboard shortcuts, and assignee/label integrations #53
@@ -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