feat(integrations): add labels integration and expose shared IntegrationApi

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.
This commit is contained in:
2026-09-22 11:28:33 +02:00
parent 8e63f2a939
commit aec0d431f9
9 changed files with 720 additions and 27 deletions
+2
View File
@@ -1,3 +1,5 @@
mod labels;
pub use labels::*;
mod assignees;
pub use assignees::*;
mod cleanup;
+21 -20
View File
@@ -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};
+544
View File
@@ -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(&current, &provider)?;
let labels = desired_labels(&current_labels, labels, expected)?;
if names(&current_labels) == names(&labels) { return Ok(current_labels); }
let body = label_payload(&provider, &labels, &current)?;
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(&current, 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(&current, vec![label("bug")], Some(vec![])).is_err());
assert!(
desired_labels(&current, 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();
}
}
}
+4
View File
@@ -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,