Add embedded PTY terminal, keyboard shortcuts, and assignee/label integrations #53
@@ -1,3 +1,5 @@
|
||||
mod labels;
|
||||
pub use labels::*;
|
||||
mod assignees;
|
||||
pub use assignees::*;
|
||||
mod cleanup;
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct AssignmentTarget {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
fn api_url(provider: &str, base: &str, segments: &[&str]) -> Result<Url, String> {
|
||||
pub(super) 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)?,
|
||||
@@ -50,7 +50,7 @@ fn project(target: &AssignmentTarget) -> Result<&str, String> {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn repository_parts(repository: &str) -> Result<(&str, &str), String> {
|
||||
pub(super) fn repository_parts(repository: &str) -> Result<(&str, &str), String> {
|
||||
repository
|
||||
.split_once('/')
|
||||
.filter(|(owner, repo)| {
|
||||
@@ -63,7 +63,7 @@ fn repository_parts(repository: &str) -> Result<(&str, &str), String> {
|
||||
.ok_or("Invalid repository name.".into())
|
||||
}
|
||||
|
||||
fn target_url(provider: &str, base: &str, target: &AssignmentTarget) -> Result<Url, String> {
|
||||
pub(super) 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());
|
||||
}
|
||||
@@ -199,14 +199,14 @@ fn assigned(value: &Value, provider: &str, kind: &str) -> Result<Vec<Integration
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct AssignmentApi<'a> {
|
||||
client: Client,
|
||||
provider: &'a str,
|
||||
username: &'a str,
|
||||
token: &'a str,
|
||||
pub(super) struct IntegrationApi<'a> {
|
||||
pub(super) client: Client,
|
||||
pub(super) provider: &'a str,
|
||||
pub(super) username: &'a str,
|
||||
pub(super) token: &'a str,
|
||||
}
|
||||
impl AssignmentApi<'_> {
|
||||
fn send(&self, method: Method, url: Url, body: Option<&Value>) -> Result<Value, String> {
|
||||
impl IntegrationApi<'_> {
|
||||
pub(super) fn send(&self, method: Method, url: Url, body: Option<&Value>) -> Result<Value, String> {
|
||||
let mut req = request(
|
||||
&self.client,
|
||||
method,
|
||||
@@ -223,7 +223,7 @@ impl AssignmentApi<'_> {
|
||||
}
|
||||
let response = req
|
||||
.send()
|
||||
.map_err(|e| format!("Assignment request could not be confirmed: {e}"))?;
|
||||
.map_err(|e| format!("Integration request could not be confirmed: {e}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(response_error(response, self.provider));
|
||||
}
|
||||
@@ -232,7 +232,7 @@ impl AssignmentApi<'_> {
|
||||
}
|
||||
response.json().map_err(|e| e.to_string())
|
||||
}
|
||||
fn pages(&self, url: Url) -> Result<Vec<Value>, String> {
|
||||
pub(super) fn pages(&self, url: Url) -> Result<Vec<Value>, String> {
|
||||
let mut result = Vec::new();
|
||||
let mut previous = None;
|
||||
for page in 1..=1000 {
|
||||
@@ -268,17 +268,18 @@ impl AssignmentApi<'_> {
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.contains("rel=\"next\""));
|
||||
let value: Value = response.json().map_err(|e| e.to_string())?;
|
||||
if self.provider == "gitea" && value.is_null() { return Ok(result); }
|
||||
let entries = if self.provider == "azure-devops" {
|
||||
value["value"].as_array()
|
||||
} else {
|
||||
value.as_array()
|
||||
}
|
||||
.ok_or("Invalid user list.")?;
|
||||
.ok_or("Invalid entry list.")?;
|
||||
if entries.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
if previous.as_ref() == Some(&value) {
|
||||
return Err("The provider repeated a user page.".into());
|
||||
return Err("The provider repeated a result page.".into());
|
||||
}
|
||||
result.extend(entries.iter().cloned());
|
||||
if !next.unwrap_or(entries.len() == 100) {
|
||||
@@ -286,7 +287,7 @@ impl AssignmentApi<'_> {
|
||||
}
|
||||
previous = Some(value);
|
||||
}
|
||||
Err("Too many user pages returned by the provider.".into())
|
||||
Err("Too many result pages returned by the provider.".into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +300,7 @@ pub async fn list_integration_assignees(
|
||||
target: AssignmentTarget,
|
||||
) -> Result<Vec<IntegrationAssignee>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = AssignmentApi {
|
||||
let api = IntegrationApi {
|
||||
client: comment_client()?,
|
||||
provider: &provider,
|
||||
username: &username,
|
||||
@@ -380,7 +381,7 @@ pub async fn get_integration_assignees(
|
||||
target: AssignmentTarget,
|
||||
) -> Result<Vec<IntegrationAssignee>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = AssignmentApi {
|
||||
let api = IntegrationApi {
|
||||
client: comment_client()?,
|
||||
provider: &provider,
|
||||
username: &username,
|
||||
@@ -455,7 +456,7 @@ pub async fn set_integration_assignees(
|
||||
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 api = IntegrationApi { 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" {
|
||||
@@ -485,7 +486,7 @@ pub async fn set_integration_assignees(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
fn target(kind: &str) -> AssignmentTarget {
|
||||
AssignmentTarget {
|
||||
@@ -657,7 +658,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Exercise the actual authenticated HTTP path against a local provider fixture.
|
||||
fn fixture(
|
||||
pub(crate) fn fixture(
|
||||
responses: Vec<(String, u16, String, String)>,
|
||||
) -> (String, std::thread::JoinHandle<Vec<String>>) {
|
||||
use std::io::{Read, Write};
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
use super::assignees::{IntegrationApi, api_url, repository_parts, target_url};
|
||||
use super::issue_comments::comment_client;
|
||||
use super::*;
|
||||
use reqwest::{Method, Url};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntegrationLabel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
fn catalog_url(provider: &str, base: &str, repository: &str) -> Result<Url, String> {
|
||||
if repository.trim().is_empty() {
|
||||
return Err("Select a repository or project.".into());
|
||||
}
|
||||
match provider {
|
||||
"github" | "gitea" => {
|
||||
let (owner, repo) = repository_parts(repository)?;
|
||||
let mut parts = vec!["repos", owner, repo, "labels"];
|
||||
if provider == "gitea" {
|
||||
parts.splice(0..0, ["api", "v1"]);
|
||||
}
|
||||
api_url(provider, base, &parts)
|
||||
}
|
||||
"gitlab" | "gitlab-self-hosted" => {
|
||||
let mut url = api_url(
|
||||
provider,
|
||||
base,
|
||||
&["api", "v4", "projects", repository, "labels"],
|
||||
)?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("include_ancestor_groups", "true");
|
||||
Ok(url)
|
||||
}
|
||||
"azure-devops" => api_url(provider, base, &[repository, "_apis", "wit", "tags"]),
|
||||
_ => Err("Unsupported label provider.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_label(value: &Value, provider: &str) -> Result<IntegrationLabel, String> {
|
||||
let name = value
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| value_string(value, &["name"]));
|
||||
if name.trim().is_empty() {
|
||||
return Err("The provider returned an invalid label name.".into());
|
||||
}
|
||||
let id = if provider == "gitea" {
|
||||
json_id(value)
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
if id.is_empty() {
|
||||
return Err("Gitea returned no label ID.".into());
|
||||
}
|
||||
let color = value_string(value, &["color"]);
|
||||
let color = color.trim_start_matches('#');
|
||||
Ok(IntegrationLabel {
|
||||
id,
|
||||
name,
|
||||
color: if color.len() == 6 && color.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
format!("#{color}")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
description: value_string(value, &["description"]),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_labels(value: &Value, provider: &str) -> Result<Vec<IntegrationLabel>, String> {
|
||||
if provider == "gitea" && value.is_null() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
value
|
||||
.as_array()
|
||||
.ok_or("The provider returned no label list.")?
|
||||
.iter()
|
||||
.map(|label| parse_label(label, provider))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn issue_labels(value: &Value, provider: &str) -> Result<Vec<IntegrationLabel>, String> {
|
||||
if provider == "azure-devops" {
|
||||
let fields = value["fields"]
|
||||
.as_object()
|
||||
.ok_or("Azure returned no work item fields.")?;
|
||||
let tags = match fields.get("System.Tags") {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(Value::String(tags)) => tags,
|
||||
_ => return Err("Azure returned an invalid tag list.".into()),
|
||||
};
|
||||
return tags
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.map(|name| parse_label(&json!(name), provider))
|
||||
.collect();
|
||||
}
|
||||
parse_labels(
|
||||
value
|
||||
.get("labels")
|
||||
.ok_or("The provider returned no issue labels.")?,
|
||||
provider,
|
||||
)
|
||||
}
|
||||
|
||||
fn label_payload(
|
||||
provider: &str,
|
||||
labels: &[IntegrationLabel],
|
||||
current: &Value,
|
||||
) -> Result<Value, String> {
|
||||
if labels.iter().any(|label| label.name.trim().is_empty()) {
|
||||
return Err("Labels must have a name.".into());
|
||||
}
|
||||
match provider {
|
||||
"gitea" => {
|
||||
let ids = labels
|
||||
.iter()
|
||||
.map(|label| {
|
||||
label
|
||||
.id
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| *id > 0)
|
||||
.ok_or("Invalid Gitea label ID.")
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(json!({"labels":ids}))
|
||||
}
|
||||
"github" => {
|
||||
Ok(json!({"labels":labels.iter().map(|label| &label.name).collect::<Vec<_>>()}))
|
||||
}
|
||||
"gitlab" | "gitlab-self-hosted" => {
|
||||
if labels.iter().any(|label| label.name.contains(',')) {
|
||||
return Err("GitLab label names cannot contain commas for this operation.".into());
|
||||
}
|
||||
Ok(
|
||||
json!({"labels":labels.iter().map(|label| label.name.as_str()).collect::<Vec<_>>().join(",")}),
|
||||
)
|
||||
}
|
||||
"azure-devops" => {
|
||||
if labels.iter().any(|label| label.name.contains(';')) {
|
||||
return Err("Azure tag names cannot contain semicolons.".into());
|
||||
}
|
||||
let rev = current["rev"]
|
||||
.as_u64()
|
||||
.ok_or("Azure returned no work item revision.")?;
|
||||
Ok(
|
||||
json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.Tags","value":labels.iter().map(|label| label.name.as_str()).collect::<Vec<_>>().join("; ")}]),
|
||||
)
|
||||
}
|
||||
_ => Err("Unsupported label provider.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn names(labels: &[IntegrationLabel]) -> BTreeSet<String> {
|
||||
labels.iter().map(|label| label.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn desired_labels(
|
||||
current: &[IntegrationLabel],
|
||||
requested: Vec<IntegrationLabel>,
|
||||
expected: Option<Vec<String>>,
|
||||
) -> Result<Vec<IntegrationLabel>, String> {
|
||||
if let Some(expected) = expected {
|
||||
if names(current) != expected.into_iter().collect() {
|
||||
return Err(
|
||||
"The issue labels changed. Reload the current labels before saving.".into(),
|
||||
);
|
||||
}
|
||||
Ok(requested)
|
||||
} else {
|
||||
// Creation adds to provider defaults. Retrying is idempotent after a lost response.
|
||||
let mut result = current.to_vec();
|
||||
for label in requested {
|
||||
if !result.iter().any(|existing| existing.name == label.name) {
|
||||
result.push(label);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_integration_labels(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
repository: String,
|
||||
) -> Result<Vec<IntegrationLabel>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = IntegrationApi {
|
||||
client: comment_client()?,
|
||||
provider: &provider,
|
||||
username: &username,
|
||||
token: &token,
|
||||
};
|
||||
let url = catalog_url(&provider, &base_url, &repository)?;
|
||||
let mut labels = if provider == "azure-devops" {
|
||||
let value = api.send(Method::GET, url, None)?;
|
||||
// Azure services return the collection envelope; also accept the documented array form.
|
||||
parse_labels(value.get("value").unwrap_or(&value), &provider)?
|
||||
} else {
|
||||
api.pages(url)?
|
||||
.iter()
|
||||
.filter(|label| label["archived_at"].is_null())
|
||||
.map(|label| parse_label(label, &provider))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
labels.retain(|label| seen.insert(label.name.clone()));
|
||||
labels.sort_by_key(|label| label.name.to_lowercase());
|
||||
Ok(labels)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_integration_issue_labels(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
repository: String,
|
||||
number: u64,
|
||||
) -> Result<Vec<IntegrationLabel>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = IntegrationApi {
|
||||
client: comment_client()?,
|
||||
provider: &provider,
|
||||
username: &username,
|
||||
token: &token,
|
||||
};
|
||||
let target = AssignmentTarget {
|
||||
repository,
|
||||
repository_id: String::new(),
|
||||
number,
|
||||
kind: "issue".into(),
|
||||
};
|
||||
issue_labels(
|
||||
&api.send(
|
||||
Method::GET,
|
||||
target_url(&provider, &base_url, &target)?,
|
||||
None,
|
||||
)?,
|
||||
&provider,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_integration_issue_labels(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
repository: String,
|
||||
number: u64,
|
||||
labels: Vec<IntegrationLabel>,
|
||||
expected: Option<Vec<String>>,
|
||||
) -> Result<Vec<IntegrationLabel>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = IntegrationApi { client: comment_client()?, provider: &provider, username: &username, token: &token };
|
||||
let target = AssignmentTarget { repository, repository_id: String::new(), number, kind: "issue".into() };
|
||||
let mut url = target_url(&provider, &base_url, &target)?;
|
||||
let current = api.send(Method::GET, url.clone(), None)?;
|
||||
let current_labels = issue_labels(¤t, &provider)?;
|
||||
let labels = desired_labels(¤t_labels, labels, expected)?;
|
||||
if names(¤t_labels) == names(&labels) { return Ok(current_labels); }
|
||||
let body = label_payload(&provider, &labels, ¤t)?;
|
||||
let array_response = provider == "github" || provider == "gitea";
|
||||
if array_response { url.path_segments_mut().map_err(|_| "Invalid labels URL.")?.push("labels"); }
|
||||
let response = api.send(if provider == "azure-devops" { Method::PATCH } else { Method::PUT }, url, Some(&body))?;
|
||||
let actual = if array_response { parse_labels(&response, &provider)? } else { issue_labels(&response, &provider)? };
|
||||
if names(&actual) != names(&labels) { return Err("The provider did not confirm the labels. Reload the issue and check your permissions.".into()); }
|
||||
Ok(actual)
|
||||
}).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn label(name: &str) -> IntegrationLabel {
|
||||
IntegrationLabel {
|
||||
id: "7".into(),
|
||||
name: name.into(),
|
||||
color: String::new(),
|
||||
description: String::new(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn provider_catalog_routes_preserve_subpaths() {
|
||||
assert_eq!(
|
||||
catalog_url("gitea", "https://git.test/sub", "team/repo")
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/sub/api/v1/repos/team/repo/labels"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog_url("github", "https://github.com", "team/repo")
|
||||
.unwrap()
|
||||
.host_str(),
|
||||
Some("api.github.com")
|
||||
);
|
||||
let gl = catalog_url(
|
||||
"gitlab-self-hosted",
|
||||
"https://git.test/sub",
|
||||
"team/nested/repo",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
gl.path(),
|
||||
"/sub/api/v4/projects/team%2Fnested%2Frepo/labels"
|
||||
);
|
||||
assert_eq!(gl.query(), Some("include_ancestor_groups=true"));
|
||||
assert_eq!(
|
||||
catalog_url("azure-devops", "https://dev.azure.com/org", "My Project")
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/org/My%20Project/_apis/wit/tags"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn provider_payloads_add_and_clear_labels() {
|
||||
assert_eq!(
|
||||
label_payload("gitea", &[label("bug")], &Value::Null).unwrap(),
|
||||
json!({"labels":[7]})
|
||||
);
|
||||
assert_eq!(
|
||||
label_payload("github", &[label("bug")], &Value::Null).unwrap(),
|
||||
json!({"labels":["bug"]})
|
||||
);
|
||||
assert_eq!(
|
||||
label_payload("gitlab", &[label("bug"), label("urgent")], &Value::Null).unwrap(),
|
||||
json!({"labels":"bug,urgent"})
|
||||
);
|
||||
assert_eq!(
|
||||
label_payload("gitlab", &[], &Value::Null).unwrap(),
|
||||
json!({"labels":""})
|
||||
);
|
||||
assert_eq!(
|
||||
label_payload("gitea", &[], &Value::Null).unwrap(),
|
||||
json!({"labels":[]})
|
||||
);
|
||||
let patch = label_payload("azure-devops", &[label("bug")], &json!({"rev":8})).unwrap();
|
||||
assert_eq!(patch[0], json!({"op":"test","path":"/rev","value":8}));
|
||||
assert_eq!(patch[1]["value"], "bug");
|
||||
assert_eq!(
|
||||
label_payload("azure-devops", &[], &json!({"rev":8})).unwrap()[1]["value"],
|
||||
""
|
||||
);
|
||||
assert!(label_payload("gitlab", &[label("comma,name")], &Value::Null).is_err());
|
||||
assert!(label_payload("azure-devops", &[label("bad;tag")], &json!({"rev":8})).is_err());
|
||||
assert!(label_payload("azure-devops", &[], &Value::Null).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn reads_gitea_null_gitlab_strings_and_azure_tags() {
|
||||
assert!(
|
||||
issue_labels(&json!({"labels":null}), "gitea")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let gitea = issue_labels(
|
||||
&json!({"labels":[{"id":7,"name":"bug","color":"ff0000"}]}),
|
||||
"gitea",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(gitea[0].id, "7");
|
||||
assert_eq!(gitea[0].color, "#ff0000");
|
||||
assert_eq!(
|
||||
issue_labels(&json!({"labels":["bug"]}), "gitlab").unwrap()[0].name,
|
||||
"bug"
|
||||
);
|
||||
assert_eq!(
|
||||
issue_labels(
|
||||
&json!({"fields":{"System.Tags":"bug; urgent; "}}),
|
||||
"azure-devops"
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
issue_labels(&json!({"fields":{}}), "azure-devops")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(issue_labels(&json!({}), "gitea").is_err());
|
||||
assert!(issue_labels(&json!({}), "azure-devops").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn creation_preserves_defaults_and_edit_detects_stale_labels() {
|
||||
let current = vec![label("default")];
|
||||
let next = desired_labels(¤t, vec![label("bug")], None).unwrap();
|
||||
assert_eq!(next.len(), 2);
|
||||
assert_eq!(
|
||||
desired_labels(&next, vec![label("bug")], None)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(desired_labels(¤t, vec![label("bug")], Some(vec![])).is_err());
|
||||
assert!(
|
||||
desired_labels(¤t, vec![], Some(vec!["default".into()]))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn gitea_writes_numeric_label_ids_and_reads_null_current_labels() {
|
||||
let (base, worker) = super::super::assignees::tests::fixture(vec![
|
||||
(
|
||||
"GET /api/v1/repos/team/repo/issues/12 ".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"labels":null}).to_string(),
|
||||
),
|
||||
(
|
||||
"PUT /api/v1/repos/team/repo/issues/12/labels ".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!([{"id":7,"name":"bug","color":"ff0000"}]).to_string(),
|
||||
),
|
||||
]);
|
||||
let result = set_integration_issue_labels(
|
||||
"gitea".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
"team/repo".into(),
|
||||
12,
|
||||
vec![label("bug")],
|
||||
Some(vec![]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result[0].name, "bug");
|
||||
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!({"labels":[7]}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn azure_tag_updates_guard_revision_and_preserve_other_fields() {
|
||||
let (base, worker) = super::super::assignees::tests::fixture(vec![
|
||||
(
|
||||
"GET /Project/_apis/wit/workitems/12?".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"rev":4,"fields":{"System.Tags":"default"}}).to_string(),
|
||||
),
|
||||
(
|
||||
"PATCH /Project/_apis/wit/workitems/12?".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"rev":5,"fields":{"System.Tags":"default; bug"}}).to_string(),
|
||||
),
|
||||
]);
|
||||
let result = set_integration_issue_labels(
|
||||
"azure-devops".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
"Project".into(),
|
||||
12,
|
||||
vec![label("bug")],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
let requests = worker.join().unwrap();
|
||||
assert!(requests[1].contains("application/json-patch+json"));
|
||||
let body: Value =
|
||||
serde_json::from_str(requests[1].split_once("\r\n\r\n").unwrap().1).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!([{"op":"test","path":"/rev","value":4},{"op":"add","path":"/fields/System.Tags","value":"default; bug"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_labels_stop_before_any_write() {
|
||||
let (base, worker) = super::super::assignees::tests::fixture(vec![(
|
||||
"GET /api/v1/repos/team/repo/issues/12 ".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"labels":[{"id":9,"name":"new"}]}).to_string(),
|
||||
)]);
|
||||
let result = set_integration_issue_labels(
|
||||
"gitea".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
"team/repo".into(),
|
||||
12,
|
||||
vec![label("bug")],
|
||||
Some(vec![]),
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().contains("labels changed"));
|
||||
assert_eq!(worker.join().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_gitea_catalog_is_valid_but_permission_errors_are_not_empty_lists() {
|
||||
for (status, response, valid) in [
|
||||
(200, "null", true),
|
||||
(403, "{\"message\":\"Forbidden\"}", false),
|
||||
] {
|
||||
let (base, worker) = super::super::assignees::tests::fixture(vec![(
|
||||
"GET /api/v1/repos/team/repo/labels?".into(),
|
||||
status,
|
||||
String::new(),
|
||||
response.into(),
|
||||
)]);
|
||||
let result = list_integration_labels(
|
||||
"gitea".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
"team/repo".into(),
|
||||
)
|
||||
.await;
|
||||
if valid {
|
||||
assert!(result.unwrap().is_empty());
|
||||
} else {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ use integrations::{
|
||||
create_integration_review_request, list_integration_repository_branches,
|
||||
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
|
||||
list_integration_assignees, get_integration_assignees, set_integration_assignees,
|
||||
list_integration_labels, get_integration_issue_labels, set_integration_issue_labels,
|
||||
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,
|
||||
};
|
||||
@@ -457,6 +458,9 @@ async fn main() {
|
||||
move_integration_board_card,
|
||||
list_integration_issue_comments,
|
||||
add_integration_issue_comment,
|
||||
list_integration_labels,
|
||||
get_integration_issue_labels,
|
||||
set_integration_issue_labels,
|
||||
list_integration_assignees,
|
||||
get_integration_assignees,
|
||||
set_integration_assignees,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { CirclePlus, X } from "@lucide/svelte";
|
||||
import IssueLabelEditor from "./IssueLabelEditor.svelte";
|
||||
import AssigneePicker from "./AssigneePicker.svelte";
|
||||
import { setIntegrationAssignees } from "../git";
|
||||
import type { IntegrationAssignee } from "../types";
|
||||
import { setIntegrationAssignees, setIntegrationIssueLabels } from "../git";
|
||||
import type { IntegrationAssignee, IntegrationLabel } from "../types";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import CommentEditor from "./CommentEditor.svelte";
|
||||
import { createIntegrationIssue, listIntegrationRepositories, listAzureIssueProjects, listAzureIssueTypes } from "../git";
|
||||
@@ -24,6 +25,9 @@
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let assignees = $state<IntegrationAssignee[]>([]);
|
||||
let labels = $state<IntegrationLabel[]>([]);
|
||||
let assigneesSaved = false;
|
||||
let labelsSaved = false;
|
||||
let created = $state<IntegrationIssue | null>(null);
|
||||
function finish() { if (created) onCreated(created); else onClose(); }
|
||||
let loading = $state(true);
|
||||
@@ -62,7 +66,7 @@
|
||||
finally { if (!destroyed) loading = false; }
|
||||
}
|
||||
async function selectTarget(value: string) {
|
||||
assignees = [];
|
||||
assignees = []; labels = []; assigneesSaved = false; labelsSaved = false;
|
||||
repository = value; types = []; workItemType = ""; typeError = "";
|
||||
const generation = ++typeGeneration;
|
||||
typesLoading = azure && !!value;
|
||||
@@ -83,10 +87,16 @@
|
||||
try {
|
||||
const auth = await credential();
|
||||
created ??= await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType);
|
||||
if (assignees.length) {
|
||||
if (assignees.length && !assigneesSaved) {
|
||||
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);
|
||||
assigneesSaved = true;
|
||||
}
|
||||
if (labels.length && !labelsSaved) {
|
||||
const saved = await setIntegrationIssueLabels(source.provider, source.baseUrl, auth.username, auth.password, created.repositoryName, created.number, labels);
|
||||
created.labels = saved.map(label => label.name);
|
||||
labelsSaved = true;
|
||||
}
|
||||
onCreated(created);
|
||||
} catch (cause) { if (!destroyed) error = String(cause); }
|
||||
@@ -111,6 +121,7 @@
|
||||
{:else if repository && !typesLoading && !types.length}<p>{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}</p>{/if}
|
||||
{/if}
|
||||
<AssigneePicker {source} target={{ repository, number: 0, kind: "issue" }} {de} {loadCredential} bind:value={assignees} disabled={busy || !!created}/>
|
||||
<IssueLabelEditor {source} {repository} {de} {loadCredential} bind:value={labels} 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>
|
||||
<div class="field">
|
||||
<span>{de ? "Beschreibung" : "Description"}</span>
|
||||
@@ -119,9 +130,9 @@
|
||||
previewLabel={de ? "Beschreibungsvorschau" : "Description preview"}
|
||||
placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"} />
|
||||
</div>
|
||||
{#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}
|
||||
{#if error}<p class="error" role="alert">{created ? (de ? "Issue wurde erstellt, aber Zuweisung oder Labels konnten nicht vollständig gespeichert werden. Erneut versuchen speichert nur die ausstehenden Angaben." : "Issue created, but assignment or labels could not be fully saved. Retry saves only the remaining details.") : (de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed.")} {error}</p>{/if}
|
||||
</fieldset>
|
||||
<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>
|
||||
<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 ? "Angaben erneut speichern" : "Retry saving details") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import "../issueWorkspace.css";
|
||||
import CreateIssueDialog from "./CreateIssueDialog.svelte";
|
||||
import IssueComments from "./IssueComments.svelte";
|
||||
import IssueLabelEditor from "./IssueLabelEditor.svelte";
|
||||
import IssueLabels from "./IssueLabels.svelte";
|
||||
import AssignmentEditor from "./AssignmentEditor.svelte";
|
||||
import IssueAssignees from "./IssueAssignees.svelte";
|
||||
@@ -344,7 +345,18 @@
|
||||
}}/>
|
||||
{/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>
|
||||
{#key `${sourceKey}:${selected.id}`}
|
||||
{@const issueId = selected.id}
|
||||
{@const labelSourceKey = sourceKey}
|
||||
<IssueLabelEditor {source} repository={selected.repositoryName} number={selected.number} {de} {loadCredential} disabled={!!closingId || loading} onSaved={savedLabels => {
|
||||
const labels = savedLabels.map(label => label.name);
|
||||
const saved = cache.get(labelSourceKey);
|
||||
if (saved) cache.set(labelSourceKey, { ...saved, issues: saved.issues.map(item => item.id === issueId ? { ...item, labels } : item) });
|
||||
if (sourceKey === labelSourceKey) issues = issues.map(item => item.id === issueId ? { ...item, labels } : item);
|
||||
}}/>
|
||||
{/key}
|
||||
</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}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import { X } from "@lucide/svelte";
|
||||
import { listIntegrationLabels, getIntegrationIssueLabels, setIntegrationIssueLabels } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSource, IntegrationLabel, StoredCredential } from "../types";
|
||||
|
||||
let { source, repository, number = 0, de, loadCredential, value = $bindable<IntegrationLabel[]>([]), disabled = false, onSaved = () => {} }: {
|
||||
source: GitIntegrationSource; repository: string; number?: number; de: boolean;
|
||||
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
value?: IntegrationLabel[]; disabled?: boolean; onSaved?: (labels: IntegrationLabel[]) => void;
|
||||
} = $props();
|
||||
let catalog = $state<IntegrationLabel[]>([]);
|
||||
let original = $state<IntegrationLabel[]>([]);
|
||||
let loading = $state(false);
|
||||
let loaded = $state(false);
|
||||
let busy = $state(false);
|
||||
let catalogError = $state("");
|
||||
let error = $state("");
|
||||
let retry = $state(0);
|
||||
let generation = 0;
|
||||
const heading = $derived(source.provider === "azure-devops" ? "Tags" : "Labels");
|
||||
const names = (labels: IntegrationLabel[]) => JSON.stringify(labels.map(label => label.name).sort());
|
||||
const dirty = $derived(names(value) !== names(original));
|
||||
const options = $derived(catalog.filter(label => !value.some(selected => selected.name === label.name)).map(label => ({value:label.id,label:label.name})));
|
||||
function colorFor(label?: IntegrationLabel): string {
|
||||
const color = label?.color || catalog.find(item => item.name === label?.name)?.color || "";
|
||||
return /^#[0-9a-f]{6}$/i.test(color) ? color : "var(--color-ink-dim)";
|
||||
}
|
||||
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, repo = repository, issue = number;
|
||||
void retry;
|
||||
const currentGeneration = ++generation;
|
||||
catalog = []; original = []; catalogError = ""; error = ""; busy = false;
|
||||
loading = !!repo; loaded = !issue;
|
||||
if (issue) value = [];
|
||||
if (repo) void (async () => {
|
||||
try {
|
||||
const credential = await auth(current);
|
||||
const [available, selected] = await Promise.allSettled([
|
||||
listIntegrationLabels(current.provider, current.baseUrl, credential.username, credential.password, repo),
|
||||
issue ? getIntegrationIssueLabels(current.provider, current.baseUrl, credential.username, credential.password, repo, issue) : Promise.resolve(null),
|
||||
]);
|
||||
if (currentGeneration !== generation) return;
|
||||
if (available.status === "fulfilled") catalog = available.value;
|
||||
else catalogError = String(available.reason);
|
||||
if (selected.status === "fulfilled") {
|
||||
if (selected.value) { original = selected.value; value = [...selected.value]; }
|
||||
loaded = true;
|
||||
} else error = String(selected.reason);
|
||||
} catch (cause) { if (currentGeneration === generation) catalogError = String(cause); }
|
||||
finally { if (currentGeneration === generation) loading = false; }
|
||||
})();
|
||||
return () => { generation++; };
|
||||
});
|
||||
function add(id: string) {
|
||||
if (disabled || busy || loading || !loaded) return;
|
||||
const label = catalog.find(label => label.id === id);
|
||||
if (label && !value.some(selected => selected.name === label.name)) value = [...value, label];
|
||||
}
|
||||
async function save() {
|
||||
if (disabled || busy || loading || !loaded || !number || !dirty) return;
|
||||
const current = source, repo = repository, issue = number, labels = [...value], expected = original.map(label => label.name), currentGeneration = generation, savedCallback = onSaved;
|
||||
busy = true; error = "";
|
||||
try {
|
||||
const credential = await auth(current);
|
||||
const saved = await setIntegrationIssueLabels(current.provider, current.baseUrl, credential.username, credential.password, repo, issue, labels, expected);
|
||||
savedCallback(saved);
|
||||
if (currentGeneration !== generation) return;
|
||||
original = saved; value = [...saved];
|
||||
} catch (cause) { if (currentGeneration === generation) error = String(cause); }
|
||||
finally { if (currentGeneration === generation) busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="label-editor" aria-busy={loading || busy}>
|
||||
<span class="field-label">{heading}</span>
|
||||
{#if value.length}<ul aria-label={heading}>
|
||||
{#each value as label (label.name)}
|
||||
<li title={label.description || label.name}><span class="color-dot" style:background={colorFor(label)}></span><span class="label-name">{label.name}</span><button type="button" disabled={disabled || busy || loading || !loaded} aria-label={`${de ? "Label entfernen" : "Remove label"}: ${label.name}`} onclick={() => value = value.filter(selected => selected.name !== label.name)}><X size={13}/></button></li>
|
||||
{/each}
|
||||
</ul>{/if}
|
||||
<SelectMenu value="" {options} searchable disabled={disabled || loading || busy || !loaded || !repository || !!catalogError} ariaLabel={heading}
|
||||
placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : !repository ? (de ? "Zuerst Repository/Projekt auswählen" : "Select a repository/project first") : `${heading} ${de ? "auswählen …" : "…"}`}
|
||||
searchPlaceholder={de ? `${heading} suchen …` : `Search ${heading.toLowerCase()} …`} emptyText={de ? "Keine passenden Einträge" : "No matching entries"} onChange={add}>
|
||||
{#snippet optionIcon(option)}<span class="color-dot" style:background={colorFor(catalog.find(label => label.id === option.value))}></span>{/snippet}
|
||||
</SelectMenu>
|
||||
{#if !loading && repository && !catalog.length && !catalogError}<small>{de ? `Keine ${heading} im Repository/Projekt vorhanden.` : `No ${heading.toLowerCase()} available in this repository/project.`}</small>{/if}
|
||||
{#if catalogError || error}<p role="alert">{catalogError || error}</p><button class="retry" type="button" disabled={disabled || busy || loading} onclick={() => retry++}>{de ? `${heading} neu laden` : `Reload ${heading.toLowerCase()}`}</button>{/if}
|
||||
{#if number && loaded && dirty}<div class="actions"><button type="button" disabled={disabled || busy || loading} onclick={save}>{busy ? (de ? "Wird gespeichert …" : "Saving …") : (de ? `${heading} speichern` : `Save ${heading.toLowerCase()}`)}</button><button type="button" disabled={disabled || busy || loading} onclick={() => { value = [...original]; error = ""; }}>{de ? "Abbrechen" : "Cancel"}</button></div>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.label-editor{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)}.label-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.color-dot{display:inline-block;width:9px;height:9px;border-radius:50%;flex-shrink:0}
|
||||
button{font:inherit;font-size:11px;padding:6px 8px;color:inherit;background:var(--color-surface);border:1px solid var(--color-border);cursor:pointer}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}.actions{display:flex;flex-wrap:wrap;gap:6px}small{color:var(--color-ink-dim);font-size:11px;line-height:1.5}p{margin:0;color:var(--color-danger);overflow-wrap:anywhere;line-height:1.5}
|
||||
</style>
|
||||
@@ -767,3 +767,13 @@ export function getIntegrationAssignees(provider: GitIntegrationProvider, baseUr
|
||||
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 });
|
||||
}
|
||||
|
||||
export function listIntegrationLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string): Promise<import("./types").IntegrationLabel[]> {
|
||||
return invoke("list_integration_labels", { provider, baseUrl, username, token, repository });
|
||||
}
|
||||
export function getIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number): Promise<import("./types").IntegrationLabel[]> {
|
||||
return invoke("get_integration_issue_labels", { provider, baseUrl, username, token, repository, number });
|
||||
}
|
||||
export function setIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, labels: import("./types").IntegrationLabel[], expected: string[] | null = null): Promise<import("./types").IntegrationLabel[]> {
|
||||
return invoke("set_integration_issue_labels", { provider, baseUrl, username, token, repository, number, labels, expected });
|
||||
}
|
||||
|
||||
@@ -524,3 +524,10 @@ export interface AssignmentTarget {
|
||||
number: number;
|
||||
kind: "issue" | "review";
|
||||
}
|
||||
|
||||
export interface IntegrationLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user