feat(integrations): add assignee/assignment support

Add a new integrations/assignees backend (src-tauri/src/integrations/assignees.rs)
and expose commands to list, read and set assignees:
- list_integration_assignees
- get_integration_assignees
- set_integration_assignees

Introduce IntegrationAssignee and AssignmentTarget types and provider-specific
URL/payload logic (GitHub, Gitea, GitLab / self-hosted, Azure DevOps). The code
handles pagination, provider quirks (Gitea legacy fields, GitLab assignee_ids,
Azure reviewer vs work-item differences) and validates/verifies assignments.
Unit tests cover routing and payload behavior.

Add UI components AssigneePicker.svelte and AssignmentEditor.svelte and update
CreateIssueDialog, CreateReviewDialog, IssueCenter, ReviewCenter, git types and
git.ts to use the new assignment functionality.
This commit is contained in:
2026-09-22 10:21:57 +02:00
parent 865a9a5560
commit 8e63f2a939
11 changed files with 1048 additions and 17 deletions
+2
View File
@@ -1,3 +1,5 @@
mod assignees;
pub use assignees::*;
mod cleanup;
mod merge;
pub use merge::get_integration_review_merge_options;
+814
View File
@@ -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, &current)?;
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"]}));
}
}
+4
View File
@@ -37,6 +37,7 @@ use git::{
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,
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,
};
@@ -456,6 +457,9 @@ async fn main() {
move_integration_board_card,
list_integration_issue_comments,
add_integration_issue_comment,
list_integration_assignees,
get_integration_assignees,
set_integration_assignees,
create_integration_issue,
list_azure_issue_projects,
list_azure_issue_types,