Add a new labels integration (src-tauri/src/integrations/labels.rs) that implements listing, reading and updating issue labels for multiple providers (GitHub, Gitea, GitLab and Azure DevOps). The new module provides Tauri commands: - list_integration_labels - get_integration_issue_labels - set_integration_issue_labels Refactor assignees integration to make core HTTP helpers reusable: - Rename AssignmentApi -> IntegrationApi and make its fields/methods pub(super). - Make helper functions api_url, repository_parts and target_url pub(super) so labels.rs can construct and call provider endpoints. - Adjust wording of a few error messages (e.g. "Assignment request..." -> "Integration request...") and genericize some page/result error text. Export the new labels module from integrations.rs and relax test helper visibility (pub(crate)) so the labels tests can reuse the existing fixture utilities. This commit introduces provider-specific parsing and payload logic for labels and reuses the shared IntegrationApi to perform authenticated requests.
545 lines
18 KiB
Rust
545 lines
18 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|