Add backend integration modules to discover, read, and modify provider-hosted boards, issues, and comments across multiple providers. Expose Tauri commands for board discovery, listing, and card moves, and implement safe issue actions and comment APIs. Wire new Svelte UI components to render boards, issue centers, comments, labels, and assignees, and add sanitized markdown rendering. - Add board discovery, board reading, and card-move APIs - Add Svelte components and styles for integrated board UI - Use marked + DOMPurify for safe markdown rendering
1166 lines
45 KiB
Rust
1166 lines
45 KiB
Rust
//! Read provider-owned board columns and memberships; explicit moves live in board_moves.
|
|
use super::*;
|
|
use serde_json::{Value, json};
|
|
use std::time::Instant;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BoardCard {
|
|
id: String,
|
|
title: String,
|
|
number: u64,
|
|
web_url: String,
|
|
repository: String,
|
|
description: String,
|
|
labels: Vec<String>,
|
|
assignees: Vec<String>,
|
|
lane: String,
|
|
}
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BoardColumn {
|
|
id: String,
|
|
title: String,
|
|
limit: u64,
|
|
cards: Vec<BoardCard>,
|
|
move_target: Option<Value>,
|
|
}
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IntegrationBoard {
|
|
title: String,
|
|
web_url: String,
|
|
columns: Vec<BoardColumn>,
|
|
notice: String,
|
|
}
|
|
|
|
pub(super) struct BoardApi<'a> {
|
|
client: Client,
|
|
provider: &'a str,
|
|
base: &'a str,
|
|
username: &'a str,
|
|
token: &'a str,
|
|
started: Instant,
|
|
}
|
|
impl BoardApi<'_> {
|
|
fn request(&self, method: reqwest::Method, url: String) -> reqwest::blocking::RequestBuilder {
|
|
let request = self
|
|
.client
|
|
.request(method, url)
|
|
.header(USER_AGENT, "Gitty")
|
|
.header(ACCEPT, "application/json");
|
|
match self.provider {
|
|
"github" => request.bearer_auth(self.token),
|
|
"gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", self.token),
|
|
"gitea" => request.header("Authorization", format!("token {}", self.token)),
|
|
_ => request.basic_auth(
|
|
if self.username.is_empty() {
|
|
"gitty"
|
|
} else {
|
|
self.username
|
|
},
|
|
Some(self.token),
|
|
),
|
|
}
|
|
}
|
|
fn read(&self, request: reqwest::blocking::RequestBuilder) -> Result<Value, String> {
|
|
if self.started.elapsed() > Duration::from_secs(50) {
|
|
return Err("Board is too large to load within the time limit.".into());
|
|
}
|
|
let response = request
|
|
.send()
|
|
.map_err(|e| format!("Could not load board: {e}"))?;
|
|
if !response.status().is_success() {
|
|
if self.provider == "gitea" && response.status() == reqwest::StatusCode::NOT_FOUND {
|
|
let schema = self.request(reqwest::Method::GET, endpoint(self.base, &["swagger.v1.json"])?).send().ok().filter(|r| r.status().is_success()).and_then(|r| r.json::<Value>().ok());
|
|
if schema.as_ref().and_then(|s| s["paths"].as_object()).is_some_and(|paths| !paths.contains_key("/repos/{owner}/{repo}/projects/{id}/columns")) {
|
|
return Err("This Gitea server publishes no Projects/Columns API. Boards can exist in the web interface but cannot be imported with an API token. Open the original board.".into());
|
|
}
|
|
return Err("Gitea returned 404 for this board. Check the board URL and token access; the server may not support the Projects/Columns API.".into());
|
|
}
|
|
return Err(response_error(response, self.provider));
|
|
}
|
|
let value: Value = response
|
|
.json()
|
|
.map_err(|e| format!("Invalid board response: {e}"))?;
|
|
if let Some(errors) = value.get("errors").and_then(Value::as_array) {
|
|
if !errors.is_empty() {
|
|
return Err(errors
|
|
.iter()
|
|
.map(|e| value_string(e, &["message"]))
|
|
.collect::<Vec<_>>()
|
|
.join("; "));
|
|
}
|
|
}
|
|
Ok(value)
|
|
}
|
|
fn get(&self, url: String) -> Result<Value, String> {
|
|
self.read(self.request(reqwest::Method::GET, url))
|
|
}
|
|
fn graphql(&self, url: String, query: &str, variables: Value) -> Result<Value, String> {
|
|
self.read(
|
|
self.request(reqwest::Method::POST, url)
|
|
.json(&json!({"query":query,"variables":variables})),
|
|
)
|
|
}
|
|
fn pages(&self, url: String, query: &[(String, String)]) -> Result<Vec<Value>, String> {
|
|
let mut items = vec![];
|
|
let mut seen = BTreeSet::new();
|
|
for page in 1..=20 {
|
|
let value = self.read(
|
|
self.request(reqwest::Method::GET, url.clone())
|
|
.query(query)
|
|
.query(&[("page", page), ("per_page", 100), ("limit", 100)]),
|
|
)?;
|
|
let values = value
|
|
.as_array()
|
|
.ok_or("Expected a board list from the provider.")?;
|
|
if values.is_empty() {
|
|
return Ok(items);
|
|
}
|
|
items.extend(
|
|
values
|
|
.iter()
|
|
.filter(|value| {
|
|
let id = json_id(value);
|
|
id.is_empty() || seen.insert(id)
|
|
})
|
|
.cloned(),
|
|
);
|
|
// Ask for the following page even when the server clamps its page size.
|
|
}
|
|
Err("Board exceeds the supported page limit; open the original board for the complete view.".into())
|
|
}
|
|
}
|
|
|
|
fn endpoint(base: &str, segments: &[&str]) -> Result<String, String> {
|
|
let mut url = reqwest::Url::parse(base).map_err(|_| "Invalid integration URL.")?;
|
|
url.path_segments_mut()
|
|
.map_err(|_| "Invalid integration URL.")?
|
|
.pop_if_empty()
|
|
.extend(segments);
|
|
Ok(url.to_string())
|
|
}
|
|
fn decode_segment(value: &str) -> Result<String, String> {
|
|
let bytes = value.as_bytes();
|
|
let mut result = vec![];
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i] == b'%' {
|
|
if i + 2 >= bytes.len() {
|
|
return Err("Invalid board URL encoding.".into());
|
|
}
|
|
let hex = std::str::from_utf8(&bytes[i + 1..i + 3])
|
|
.map_err(|_| "Invalid board URL encoding.")?;
|
|
result.push(u8::from_str_radix(hex, 16).map_err(|_| "Invalid board URL encoding.")?);
|
|
i += 3;
|
|
} else {
|
|
result.push(bytes[i]);
|
|
i += 1;
|
|
}
|
|
}
|
|
String::from_utf8(result).map_err(|_| "Invalid board URL encoding.".into())
|
|
}
|
|
fn board_path(base: &str, board_url: &str) -> Result<Vec<String>, String> {
|
|
let base = reqwest::Url::parse(base).map_err(|_| "Invalid integration URL.")?;
|
|
let url =
|
|
reqwest::Url::parse(board_url).map_err(|_| "Paste the full URL of the original board.")?;
|
|
if !["https", "http"].contains(&url.scheme())
|
|
|| base.origin() != url.origin()
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
{
|
|
return Err("The board URL must belong to the selected integration.".into());
|
|
}
|
|
let prefix = format!("{}/", base.path().trim_end_matches('/'));
|
|
let path = url
|
|
.path()
|
|
.strip_prefix(&prefix)
|
|
.ok_or("The board URL must belong to the configured organization or server path.")?;
|
|
path.trim_matches('/')
|
|
.split('/')
|
|
.map(decode_segment)
|
|
.collect()
|
|
}
|
|
fn column(id: String, title: String) -> BoardColumn {
|
|
BoardColumn {
|
|
id,
|
|
title,
|
|
limit: 0,
|
|
cards: vec![],
|
|
move_target: None,
|
|
}
|
|
}
|
|
fn strings(value: &Value, key: &str, name: &str) -> Vec<String> {
|
|
value
|
|
.get(key)
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|v| {
|
|
v.as_str()
|
|
.map(str::to_string)
|
|
.or_else(|| v.get(name).and_then(Value::as_str).map(str::to_string))
|
|
})
|
|
.collect()
|
|
}
|
|
fn rest_card(v: &Value, provider: &str) -> BoardCard {
|
|
let gitlab = provider.starts_with("gitlab");
|
|
let reference = value_string(v, &["references", "full"]);
|
|
BoardCard {
|
|
id: json_id(v),
|
|
title: value_string(v, &["title"]),
|
|
number: value_u64(v, if gitlab { "iid" } else { "number" }),
|
|
web_url: value_string(v, &[if gitlab { "web_url" } else { "html_url" }]),
|
|
repository: if gitlab {
|
|
reference
|
|
.rsplit_once('#')
|
|
.map_or(reference.clone(), |(repo, _)| repo.to_string())
|
|
} else {
|
|
value_string(v, &["repository", "full_name"])
|
|
},
|
|
description: value_string(v, &[if gitlab { "description" } else { "body" }]),
|
|
labels: strings(v, "labels", "name"),
|
|
assignees: strings(v, "assignees", if gitlab { "username" } else { "login" }),
|
|
lane: String::new(),
|
|
}
|
|
}
|
|
|
|
fn gitea_board(api: &BoardApi, path: &[String], web_url: &str) -> Result<IntegrationBoard, String> {
|
|
let (scope, id) = match path {
|
|
[owner, repo, kind, id] if kind == "projects" => {
|
|
(vec!["repos", owner, repo, "projects"], id)
|
|
}
|
|
[kind, owner, projects, id] if kind == "org" && projects == "projects" => {
|
|
(vec!["orgs", owner, "projects"], id)
|
|
}
|
|
_ => {
|
|
return Err(
|
|
"Use a Gitea repository or organization project URL ending in /projects/123."
|
|
.into(),
|
|
);
|
|
}
|
|
};
|
|
let mut segments = vec!["api", "v1"];
|
|
segments.extend(scope);
|
|
segments.push(id);
|
|
let project = api.get(endpoint(api.base, &segments)?)?;
|
|
segments.push("columns");
|
|
let mut columns = api
|
|
.get(endpoint(api.base, &segments)?)?
|
|
.as_array()
|
|
.ok_or("Gitea returned no project columns.")?
|
|
.clone();
|
|
columns.sort_by_key(|v| value_u64(v, "sorting"));
|
|
let mut result = vec![];
|
|
for c in columns {
|
|
let id = json_id(&c);
|
|
let mut col = column(id.clone(), value_string(&c, &["title"]));
|
|
let mut parts = segments.clone();
|
|
parts.extend([id.as_str(), "issues"]);
|
|
col.cards = api
|
|
.pages(endpoint(api.base, &parts)?, &[])?
|
|
.iter()
|
|
.map(|v| rest_card(v, "gitea"))
|
|
.collect();
|
|
result.push(col);
|
|
}
|
|
Ok(IntegrationBoard {
|
|
title: value_string(&project, &["title"]),
|
|
web_url: web_url.into(),
|
|
columns: result,
|
|
notice: String::new(),
|
|
})
|
|
}
|
|
|
|
fn gitlab_board(
|
|
api: &BoardApi,
|
|
path: &[String],
|
|
web_url: &str,
|
|
) -> Result<IntegrationBoard, String> {
|
|
let marker = path
|
|
.iter()
|
|
.position(|p| p == "-")
|
|
.ok_or("Use a GitLab board URL ending in /-/boards/123.")?;
|
|
if path.get(marker + 1).map(String::as_str) != Some("boards") {
|
|
return Err("Use a GitLab issue board URL.".into());
|
|
}
|
|
let id = path
|
|
.get(marker + 2)
|
|
.ok_or("Include the board number in the URL.")?;
|
|
let group = path.first().map(String::as_str) == Some("groups");
|
|
let project = path[if group { 1 } else { 0 }..marker].join("/");
|
|
let scope = if group { "groups" } else { "projects" };
|
|
let board = api.get(endpoint(
|
|
api.base,
|
|
&["api", "v4", scope, &project, "boards", id],
|
|
)?)?;
|
|
let mut lists = api.pages(
|
|
endpoint(
|
|
api.base,
|
|
&["api", "v4", scope, &project, "boards", id, "lists"],
|
|
)?,
|
|
&[],
|
|
)?;
|
|
lists.sort_by_key(|l| l.get("position").and_then(Value::as_i64).unwrap_or(0));
|
|
let mut filters = vec![
|
|
("scope".into(), "all".into()),
|
|
("order_by".into(), "relative_position".into()),
|
|
("sort".into(), "asc".into()),
|
|
];
|
|
let labels = strings(&board, "labels", "name");
|
|
if !labels.is_empty() {
|
|
filters.push(("labels".into(), labels.join(",")));
|
|
}
|
|
for (key, param) in [("assignee", "assignee_id"), ("iteration", "iteration_id")] {
|
|
if let Some(id) = board
|
|
.get(key)
|
|
.and_then(|v| v.get("id"))
|
|
.and_then(Value::as_u64)
|
|
{
|
|
filters.push((param.into(), id.to_string()));
|
|
}
|
|
}
|
|
if let Some(milestone) = board.get("milestone").filter(|v| !v.is_null()) {
|
|
let title = value_string(milestone, &["title"]);
|
|
if title.is_empty() {
|
|
return Err("This board uses a milestone filter that cannot be read by the Issues API. Open the original board.".into());
|
|
}
|
|
filters.push(("milestone".into(), title));
|
|
}
|
|
if let Some(weight) = board
|
|
.get("weight")
|
|
.and_then(Value::as_i64)
|
|
.filter(|n| *n >= 0)
|
|
{
|
|
filters.push(("weight".into(), weight.to_string()));
|
|
}
|
|
if board
|
|
.get("iteration_cadence_id")
|
|
.is_some_and(|v| !v.is_null())
|
|
{
|
|
return Err(
|
|
"Iteration-cadence board filters are not supported yet. Open the original board."
|
|
.into(),
|
|
);
|
|
}
|
|
for key in ["assignee", "milestone", "iteration"] {
|
|
if let Some(entity) = board.get(key).filter(|v| !v.is_null()) {
|
|
if entity.get("id").and_then(Value::as_u64).unwrap_or(0) == 0 {
|
|
return Err(format!(
|
|
"The board uses a special {key} filter that cannot be imported reliably. Open the original board."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if labels.iter().any(|label| label.contains(',')) {
|
|
return Err("This board uses a label filter containing a comma that the Issues API cannot represent reliably.".into());
|
|
}
|
|
let values = api.pages(
|
|
endpoint(api.base, &["api", "v4", scope, &project, "issues"])?,
|
|
&filters,
|
|
)?;
|
|
let mut columns = vec![];
|
|
let mut matched = BTreeSet::new();
|
|
let label_board = lists.iter().all(|list| list["label"].is_object() && !value_string(&list["label"], &["name"]).contains(','));
|
|
for list in lists {
|
|
let (kind, entity) = ["label", "assignee", "milestone", "iteration"]
|
|
.into_iter()
|
|
.find_map(|kind| list.get(kind).filter(|v| !v.is_null()).map(|v| (kind, v)))
|
|
.ok_or(
|
|
"This GitLab board contains an unsupported list type. Open the original board.",
|
|
)?;
|
|
let title = ["name", "title", "username"]
|
|
.iter()
|
|
.map(|key| value_string(entity, &[key]))
|
|
.find(|s| !s.is_empty())
|
|
.unwrap_or_default();
|
|
if kind != "label" && entity.get("id").and_then(Value::as_u64).unwrap_or(0) == 0 {
|
|
return Err("This board list has no supported assignment identifier.".into());
|
|
}
|
|
let mut col = column(json_id(&list), title.clone());
|
|
col.limit = value_u64(&list, "max_issue_count");
|
|
if label_board { col.move_target = Some(json!({"label":title,"protectedLabels":labels})); }
|
|
for v in &values {
|
|
if value_string(v, &["state"]) != "opened" {
|
|
continue;
|
|
}
|
|
let matches = match kind {
|
|
"label" => strings(v, "labels", "name").contains(&title),
|
|
"assignee" => v
|
|
.get("assignees")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|items| {
|
|
items.iter().any(|item| item.get("id") == entity.get("id"))
|
|
}),
|
|
key => v.get(key).and_then(|v| v.get("id")) == entity.get("id"),
|
|
};
|
|
if matches {
|
|
matched.insert(json_id(v));
|
|
col.cards.push(rest_card(v, api.provider));
|
|
}
|
|
}
|
|
columns.push(col);
|
|
}
|
|
if board.get("hide_backlog_list").and_then(Value::as_bool) != Some(true) {
|
|
let mut open = column("open".into(), "Open".into());
|
|
if label_board { open.move_target = Some(json!({"state":"reopen","protectedLabels":labels})); }
|
|
open.cards = values
|
|
.iter()
|
|
.filter(|v| value_string(v, &["state"]) == "opened" && !matched.contains(&json_id(v)))
|
|
.map(|v| rest_card(v, api.provider))
|
|
.collect();
|
|
columns.insert(0, open);
|
|
}
|
|
if board.get("hide_closed_list").and_then(Value::as_bool) != Some(true) {
|
|
let mut closed = column("closed".into(), "Closed".into());
|
|
if label_board { closed.move_target = Some(json!({"state":"close","protectedLabels":labels})); }
|
|
closed.cards = values
|
|
.iter()
|
|
.filter(|v| value_string(v, &["state"]) == "closed")
|
|
.map(|v| rest_card(v, api.provider))
|
|
.collect();
|
|
columns.push(closed);
|
|
}
|
|
Ok(IntegrationBoard {
|
|
title: value_string(&board, &["name"]),
|
|
web_url: web_url.into(),
|
|
columns,
|
|
notice: "Saved board scope is applied. Temporary browser search filters are not imported."
|
|
.into(),
|
|
})
|
|
}
|
|
|
|
fn github_board(
|
|
api: &BoardApi,
|
|
path: &[String],
|
|
web_url: &str,
|
|
) -> Result<IntegrationBoard, String> {
|
|
github_api_base_url(api.base)?;
|
|
github_board_from_api(api, path, web_url, "https://api.github.com/graphql")
|
|
}
|
|
|
|
fn github_board_from_api(
|
|
api: &BoardApi,
|
|
path: &[String],
|
|
web_url: &str,
|
|
graphql_url: &str,
|
|
) -> Result<IntegrationBoard, String> {
|
|
let (owner_type, owner, number) =
|
|
match path {
|
|
[kind, owner, projects, number, ..]
|
|
if (kind == "orgs" || kind == "users") && projects == "projects" =>
|
|
{
|
|
(
|
|
if kind == "orgs" {
|
|
"organization"
|
|
} else {
|
|
"user"
|
|
},
|
|
owner,
|
|
number
|
|
.parse::<u64>()
|
|
.map_err(|_| "Invalid project number.")?,
|
|
)
|
|
}
|
|
_ => return Err(
|
|
"Use a GitHub Projects URL: /orgs/OWNER/projects/123 or /users/OWNER/projects/123."
|
|
.into(),
|
|
),
|
|
};
|
|
let graphql = graphql_url.to_string();
|
|
let query = format!(
|
|
r#"query($owner:String!,$number:Int!) {{ {owner_type}(login:$owner) {{ projectV2(number:$number) {{
|
|
id title fields(first:100) {{ pageInfo {{ hasNextPage }} nodes {{ ... on ProjectV2SingleSelectField {{ id name options {{ id name }} }} }} }}
|
|
views(first:100) {{ pageInfo {{ hasNextPage }} nodes {{ number name layout filter groupByFields(first:10) {{ nodes {{ ... on ProjectV2SingleSelectField {{ id name options {{ id name }} }} }} }} }} }}
|
|
}} }} }}"#
|
|
);
|
|
let payload = api.graphql(
|
|
graphql.clone(),
|
|
&query,
|
|
json!({"owner":owner,"number":number}),
|
|
)?;
|
|
let project = &payload["data"][owner_type]["projectV2"];
|
|
if project.is_null() {
|
|
return Err(
|
|
"Project not found. The token needs read access to this GitHub Project.".into(),
|
|
);
|
|
}
|
|
if project["fields"]["pageInfo"]["hasNextPage"] == true
|
|
|| project["views"]["pageInfo"]["hasNextPage"] == true
|
|
{
|
|
return Err("This project has more fields or views than can be imported. Open the original project.".into());
|
|
}
|
|
let view_number = path
|
|
.iter()
|
|
.position(|p| p == "views")
|
|
.and_then(|i| path.get(i + 1))
|
|
.map(|n| n.parse::<u64>().map_err(|_| "Invalid view number."))
|
|
.transpose()?;
|
|
let views = project["views"]["nodes"]
|
|
.as_array()
|
|
.ok_or("GitHub returned no project views.")?;
|
|
let view = views
|
|
.iter()
|
|
.find(|v| view_number.map_or(v["layout"] == "BOARD_LAYOUT", |n| v["number"] == n))
|
|
.ok_or("No matching board view found in this GitHub Project.")?;
|
|
if view["layout"] != "BOARD_LAYOUT" {
|
|
return Err("The selected GitHub view is not a board view.".into());
|
|
}
|
|
let fields = view["groupByFields"]["nodes"]
|
|
.as_array()
|
|
.ok_or("The board has no grouping field.")?;
|
|
let field = fields.iter().find(|f| f.get("options").is_some()).ok_or(
|
|
"Only GitHub board views grouped by a single-select field are currently supported.",
|
|
)?;
|
|
if fields.len() != 1 {
|
|
return Err("This GitHub view uses a grouping that cannot be imported yet.".into());
|
|
}
|
|
let field_id = value_string(field, &["id"]);
|
|
let mut columns: Vec<_> = field["options"]
|
|
.as_array()
|
|
.ok_or("No board columns returned.")?
|
|
.iter()
|
|
.map(|v| column(value_string(v, &["id"]), value_string(v, &["name"])))
|
|
.collect();
|
|
let mut unassigned = column(
|
|
"unassigned".into(),
|
|
format!("No {}", value_string(field, &["name"])),
|
|
);
|
|
let query = r#"query($id:ID!,$after:String) { node(id:$id) { ... on ProjectV2 { items(first:100,after:$after) {
|
|
pageInfo { hasNextPage endCursor } nodes { id isArchived fieldValues(first:100) { pageInfo { hasNextPage } nodes {
|
|
... on ProjectV2ItemFieldSingleSelectValue { optionId field { ... on ProjectV2SingleSelectField { id } } }
|
|
} } content {
|
|
... on Issue { title number body url repository { nameWithOwner } labels(first:50) { nodes { name } } assignees(first:50) { nodes { login } } }
|
|
... on PullRequest { title number body url repository { nameWithOwner } labels(first:50) { nodes { name } } assignees(first:50) { nodes { login } } }
|
|
... on DraftIssue { title body assignees(first:50) { nodes { login } } }
|
|
} }
|
|
} } } }"#;
|
|
let mut after = Value::Null;
|
|
let mut finished = false;
|
|
for _ in 0..20 {
|
|
let page = api.graphql(
|
|
graphql.clone(),
|
|
query,
|
|
json!({"id":project["id"],"after":after}),
|
|
)?;
|
|
let connection = &page["data"]["node"]["items"];
|
|
for item in connection["nodes"]
|
|
.as_array()
|
|
.ok_or("No project items returned.")?
|
|
{
|
|
if item["isArchived"] == true {
|
|
continue;
|
|
}
|
|
if item["fieldValues"]["pageInfo"]["hasNextPage"] == true {
|
|
return Err(
|
|
"Project item has too many fields to assign its board column reliably.".into(),
|
|
);
|
|
}
|
|
let option = item["fieldValues"]["nodes"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.find(|v| v["field"]["id"] == field_id)
|
|
.map(|v| value_string(v, &["optionId"]))
|
|
.unwrap_or_default();
|
|
let content = &item["content"];
|
|
let card = BoardCard {
|
|
id: value_string(item, &["id"]),
|
|
title: if content.is_null() {
|
|
"Restricted item".into()
|
|
} else {
|
|
value_string(content, &["title"])
|
|
},
|
|
number: value_u64(content, "number"),
|
|
web_url: value_string(content, &["url"]),
|
|
repository: value_string(content, &["repository", "nameWithOwner"]),
|
|
description: value_string(content, &["body"]),
|
|
labels: strings(&content["labels"], "nodes", "name"),
|
|
assignees: strings(&content["assignees"], "nodes", "login"),
|
|
lane: String::new(),
|
|
};
|
|
if let Some(col) = columns.iter_mut().find(|c| c.id == option) {
|
|
col.cards.push(card);
|
|
} else {
|
|
unassigned.cards.push(card);
|
|
}
|
|
}
|
|
if connection["pageInfo"]["hasNextPage"] != true {
|
|
finished = true;
|
|
break;
|
|
}
|
|
let next = connection["pageInfo"]["endCursor"].clone();
|
|
if next.is_null() || next == after {
|
|
return Err("GitHub returned an invalid pagination cursor.".into());
|
|
}
|
|
after = next;
|
|
}
|
|
if !finished {
|
|
return Err(
|
|
"Project exceeds the supported 2,000-item limit. Open the original project.".into(),
|
|
);
|
|
}
|
|
columns.insert(0, unassigned);
|
|
for col in &mut columns { col.move_target = Some(json!({"project":project["id"],"field":field_id})); }
|
|
let filter = value_string(view, &["filter"]);
|
|
Ok(IntegrationBoard {
|
|
title: format!(
|
|
"{} · {}",
|
|
value_string(project, &["title"]),
|
|
value_string(view, &["name"])
|
|
),
|
|
web_url: web_url.into(),
|
|
columns,
|
|
notice: if filter.trim().is_empty() {
|
|
"Archived cards are omitted. Columns use the board's single-select options.".into()
|
|
} else {
|
|
format!(
|
|
"The saved GitHub view filter is not applied: {filter}. All non-archived project cards are shown in their original columns."
|
|
)
|
|
},
|
|
})
|
|
}
|
|
|
|
fn wiql_literal(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', "''"))
|
|
}
|
|
fn wiql_field(value: &str) -> Result<String, String> {
|
|
if value.is_empty()
|
|
|| !value
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_')
|
|
{
|
|
return Err("Invalid Azure board field reference.".into());
|
|
}
|
|
Ok(format!("[{value}]"))
|
|
}
|
|
fn azure_board(api: &BoardApi, path: &[String], web_url: &str) -> Result<IntegrationBoard, String> {
|
|
let marker = path
|
|
.iter()
|
|
.position(|p| p == "_boards")
|
|
.ok_or("Use an Azure Boards URL containing /_boards/board/t/TEAM/Stories.")?;
|
|
let project = path
|
|
.first()
|
|
.ok_or("The Azure board URL must include the project.")?;
|
|
let team_index = path
|
|
.iter()
|
|
.position(|p| p == "t")
|
|
.ok_or("The Azure board URL must include /t/TEAM/.")?;
|
|
let team = path
|
|
.get(team_index + 1)
|
|
.ok_or("The Azure board URL must include the team.")?;
|
|
let name = path
|
|
.get(team_index + 2)
|
|
.ok_or("The Azure board URL must include the board name, for example Stories.")?;
|
|
if marker == 0 {
|
|
return Err("The Azure board URL must include the project.".into());
|
|
}
|
|
let board_url = endpoint(api.base, &[project, team, "_apis", "work", "boards", name])?;
|
|
let board = api.read(
|
|
api.request(reqwest::Method::GET, board_url)
|
|
.query(&[("api-version", "7.1")]),
|
|
)?;
|
|
let column_field = value_string(&board, &["fields", "columnField", "referenceName"]);
|
|
let done_field = value_string(&board, &["fields", "doneField", "referenceName"]);
|
|
let row_field = value_string(&board, &["fields", "rowField", "referenceName"]);
|
|
let field = wiql_field(&column_field)?;
|
|
let mut conditions = vec![
|
|
format!("[System.TeamProject] = {}", wiql_literal(project)),
|
|
format!("{field} <> ''"),
|
|
];
|
|
let settings = api.read(
|
|
api.request(
|
|
reqwest::Method::GET,
|
|
endpoint(
|
|
api.base,
|
|
&[
|
|
project,
|
|
team,
|
|
"_apis",
|
|
"work",
|
|
"teamsettings",
|
|
"teamfieldvalues",
|
|
],
|
|
)?,
|
|
)
|
|
.query(&[("api-version", "7.1")]),
|
|
)?;
|
|
let team_field = wiql_field(&value_string(&settings, &["field", "referenceName"]))?;
|
|
let areas = settings["values"]
|
|
.as_array()
|
|
.ok_or("Could not read the team's board scope.")?;
|
|
if areas.is_empty() {
|
|
return Err("The Azure team has no configured board area paths.".into());
|
|
}
|
|
conditions.push(format!(
|
|
"({})",
|
|
areas
|
|
.iter()
|
|
.map(|area| format!(
|
|
"{team_field} {} {}",
|
|
if area["includeChildren"] == true {
|
|
"UNDER"
|
|
} else {
|
|
"="
|
|
},
|
|
wiql_literal(&value_string(area, &["value"]))
|
|
))
|
|
.collect::<Vec<_>>()
|
|
.join(" OR ")
|
|
));
|
|
let raw_columns = board["columns"]
|
|
.as_array()
|
|
.ok_or("Azure returned no board columns.")?;
|
|
let work_types: BTreeSet<_> = raw_columns
|
|
.iter()
|
|
.flat_map(|col| {
|
|
col["stateMappings"]
|
|
.as_object()
|
|
.into_iter()
|
|
.flat_map(|map| map.keys().cloned())
|
|
})
|
|
.collect();
|
|
if !work_types.is_empty() {
|
|
conditions.push(format!(
|
|
"[System.WorkItemType] IN ({})",
|
|
work_types
|
|
.iter()
|
|
.map(|t| wiql_literal(t))
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
));
|
|
}
|
|
let query = format!(
|
|
"SELECT [System.Id] FROM WorkItems WHERE {} ORDER BY [System.Id]",
|
|
conditions.join(" AND ")
|
|
);
|
|
let ids = api.read(
|
|
api.request(
|
|
reqwest::Method::POST,
|
|
endpoint(api.base, &[project, "_apis", "wit", "wiql"])?,
|
|
)
|
|
.query(&[("api-version", "7.1"), ("$top", "2001")])
|
|
.json(&json!({"query":query})),
|
|
)?;
|
|
let ids: Vec<u64> = ids["workItems"]
|
|
.as_array()
|
|
.ok_or("Azure returned no board work items.")?
|
|
.iter()
|
|
.filter_map(|v| v["id"].as_u64())
|
|
.collect();
|
|
if ids.len() > 2000 {
|
|
return Err(
|
|
"Board exceeds the supported 2,000-card limit. Open the original board.".into(),
|
|
);
|
|
}
|
|
let mut values = vec![];
|
|
for chunk in ids.chunks(200) {
|
|
let ids = chunk
|
|
.iter()
|
|
.map(u64::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let page = api.read(
|
|
api.request(
|
|
reqwest::Method::GET,
|
|
endpoint(api.base, &["_apis", "wit", "workitems"])?,
|
|
)
|
|
.query(&[("api-version", "7.1"), ("ids", ids.as_str())]),
|
|
)?;
|
|
values.extend(
|
|
page["value"]
|
|
.as_array()
|
|
.ok_or("Azure returned no board card details.")?
|
|
.clone(),
|
|
);
|
|
}
|
|
// The provider's backlog rank orders cards within columns.
|
|
values.sort_by(|a, b| {
|
|
let rank = |v: &Value| {
|
|
v["fields"]["Microsoft.VSTS.Common.StackRank"]
|
|
.as_f64()
|
|
.or_else(|| v["fields"]["Microsoft.VSTS.Common.BacklogPriority"].as_f64())
|
|
.unwrap_or(f64::MAX)
|
|
};
|
|
rank(a)
|
|
.total_cmp(&rank(b))
|
|
.then_with(|| value_u64(a, "id").cmp(&value_u64(b, "id")))
|
|
});
|
|
if raw_columns.iter().any(|c| c["isSplit"] == true) && done_field.is_empty() {
|
|
return Err("Azure did not return the field required to map split board columns.".into());
|
|
}
|
|
let mut columns = vec![];
|
|
for c in raw_columns {
|
|
for done in if c["isSplit"] == true {
|
|
vec![false, true]
|
|
} else {
|
|
vec![false]
|
|
} {
|
|
let title = value_string(c, &["name"]);
|
|
let split = c["isSplit"] == true;
|
|
let mut col = column(
|
|
format!("{}:{done}", value_string(c, &["id"])),
|
|
if split {
|
|
format!("{title} · {}", if done { "Done" } else { "Doing" })
|
|
} else {
|
|
title.clone()
|
|
},
|
|
);
|
|
col.limit = value_u64(c, "itemLimit");
|
|
col.move_target = Some(json!({"columnField":column_field,"doneField":done_field,"name":title,"done":done,"states":c["stateMappings"]}));
|
|
for v in &values {
|
|
if value_string(v, &["fields", &column_field]) != title {
|
|
continue;
|
|
}
|
|
if split && v["fields"][&done_field].as_bool().unwrap_or(false) != done {
|
|
continue;
|
|
}
|
|
let fields = &v["fields"];
|
|
let number = value_u64(v, "id");
|
|
col.cards.push(BoardCard {
|
|
id: number.to_string(),
|
|
number,
|
|
title: value_string(fields, &["System.Title"]),
|
|
web_url: format!("{}/_workitems/edit/{number}", api.base),
|
|
repository: project.clone(),
|
|
description: String::new(),
|
|
labels: value_string(fields, &["System.Tags"])
|
|
.split(';')
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_string)
|
|
.collect(),
|
|
assignees: vec![value_string(fields, &["System.AssignedTo", "displayName"])]
|
|
.into_iter()
|
|
.filter(|s| !s.is_empty())
|
|
.collect(),
|
|
lane: value_string(fields, &[&row_field]),
|
|
});
|
|
}
|
|
columns.push(col);
|
|
}
|
|
}
|
|
Ok(IntegrationBoard { title: format!("{project} · {team} · {}",value_string(&board,&["name"])),web_url:web_url.into(),columns,
|
|
notice:"Team scope, board columns, split columns and swimlane names are imported. Temporary browser filters and the completed-card age limit are not applied.".into() })
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_integration_board(
|
|
provider: String,
|
|
base_url: String,
|
|
username: String,
|
|
token: String,
|
|
board_url: String,
|
|
) -> Result<IntegrationBoard, String> {
|
|
tokio::time::timeout(
|
|
Duration::from_secs(65),
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
if token.trim().is_empty() {
|
|
return Err("No token stored for this integration.".into());
|
|
}
|
|
let base = normalized_base_url(&base_url)?;
|
|
let path = board_path(&base, &board_url)?;
|
|
let client = Client::builder()
|
|
.connect_timeout(Duration::from_secs(7))
|
|
.timeout(Duration::from_secs(12))
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
let api = BoardApi {
|
|
client,
|
|
provider: &provider,
|
|
base: &base,
|
|
username: &username,
|
|
token: &token,
|
|
started: Instant::now(),
|
|
};
|
|
match provider.as_str() {
|
|
"github" => github_board(&api, &path, &board_url),
|
|
"gitlab" | "gitlab-self-hosted" => gitlab_board(&api, &path, &board_url),
|
|
"gitea" => gitea_board(&api, &path, &board_url),
|
|
"azure-devops" => azure_board(&api, &path, &board_url),
|
|
_ => Err("Unsupported board provider.".into()),
|
|
}
|
|
}),
|
|
)
|
|
.await
|
|
.map_err(|_| "Board loading timed out.".to_string())?
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
#[path = "board_moves.rs"]
|
|
mod moves;
|
|
pub use moves::*;
|
|
|
|
#[path = "board_discovery.rs"]
|
|
mod discovery;
|
|
pub use discovery::*;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn board_urls_cannot_send_tokens_to_another_origin_or_organization() {
|
|
for url in [
|
|
"https://evil.test/org/p/_boards/board/t/team/Stories",
|
|
"https://dev.azure.com/other/p/_boards/board/t/team/Stories",
|
|
"https://user:pass@dev.azure.com/org/p/_boards/board/t/team/Stories",
|
|
] {
|
|
assert!(board_path("https://dev.azure.com/org", url).is_err());
|
|
}
|
|
assert_eq!(
|
|
board_path(
|
|
"https://dev.azure.com/org",
|
|
"https://dev.azure.com/org/My%20Project/_boards/board/t/My%20Team/Stories"
|
|
)
|
|
.unwrap()[0],
|
|
"My Project"
|
|
);
|
|
}
|
|
#[test]
|
|
fn api_paths_and_query_literals_are_encoded() {
|
|
assert_eq!(
|
|
endpoint(
|
|
"https://gitlab.test/root",
|
|
&["api", "v4", "projects", "group/repo"]
|
|
)
|
|
.unwrap(),
|
|
"https://gitlab.test/root/api/v4/projects/group%2Frepo"
|
|
);
|
|
assert_eq!(wiql_literal("Team's Project"), "'Team''s Project'");
|
|
assert!(wiql_field("Bad] OR [System.Id").is_err());
|
|
}
|
|
#[test]
|
|
fn board_cards_keep_provider_identity_and_labels() {
|
|
let card = rest_card(
|
|
&json!({"id":5,"iid":7,"title":"Issue","references":{"full":"group/repo#7"},"labels":["Review"],"assignees":[{"username":"alex"}]}),
|
|
"gitlab",
|
|
);
|
|
assert_eq!(card.number, 7);
|
|
assert_eq!(card.repository, "group/repo");
|
|
assert_eq!(card.labels, ["Review"]);
|
|
assert_eq!(card.assignees, ["alex"]);
|
|
}
|
|
pub(super) fn mock_api(responses: Vec<(&'static str, Value)>) -> (String, std::thread::JoinHandle<()>) {
|
|
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 || {
|
|
for (expected_path, payload) in responses {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(3)))
|
|
.unwrap();
|
|
let mut request = Vec::new();
|
|
let mut buffer = [0; 4096];
|
|
loop {
|
|
let count = stream.read(&mut buffer).unwrap();
|
|
assert!(count > 0);
|
|
request.extend_from_slice(&buffer[..count]);
|
|
if let Some(end) = request.windows(4).position(|v| v == b"\r\n\r\n") {
|
|
let header = String::from_utf8_lossy(&request[..end]);
|
|
let length = header
|
|
.lines()
|
|
.find_map(|line| {
|
|
line.to_lowercase()
|
|
.strip_prefix("content-length:")
|
|
.map(|v| v.trim().parse::<usize>().unwrap())
|
|
})
|
|
.unwrap_or(0);
|
|
if request.len() >= end + 4 + length {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let request = String::from_utf8_lossy(&request);
|
|
assert!(
|
|
request.lines().next().unwrap().contains(expected_path),
|
|
"Unexpected endpoint: {}",
|
|
request.lines().next().unwrap()
|
|
);
|
|
let body = payload.to_string();
|
|
write!(stream,"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",body.len(),body).unwrap();
|
|
}
|
|
});
|
|
(base, worker)
|
|
}
|
|
pub(super) fn test_api<'a>(base: &'a str, provider: &'a str) -> BoardApi<'a> {
|
|
BoardApi {
|
|
client: client().unwrap(),
|
|
provider,
|
|
base,
|
|
username: "test",
|
|
token: "test-token",
|
|
started: Instant::now(),
|
|
}
|
|
}
|
|
#[test]
|
|
fn gitlab_import_uses_real_labels_and_keeps_closed_cards_out_of_label_columns() {
|
|
let (base, worker) = mock_api(vec![
|
|
(
|
|
"/api/v4/projects/team%2Frepo/boards/1 ",
|
|
json!({"name":"Delivery"}),
|
|
),
|
|
(
|
|
"/lists?page=1",
|
|
json!([{"id":8,"label":{"name":"QA"},"position":2},{"id":7,"label":{"name":"Doing"},"position":1}]),
|
|
),
|
|
("/lists?page=2", json!([])),
|
|
(
|
|
"/issues?",
|
|
json!([
|
|
{"id":1,"iid":1,"title":"Unassigned","state":"opened","labels":[]},
|
|
{"id":2,"iid":2,"title":"Development","state":"opened","labels":["Doing"]},
|
|
{"id":3,"iid":3,"title":"Finished","state":"closed","labels":["Doing"]}
|
|
]),
|
|
),
|
|
("/issues?", json!([])),
|
|
]);
|
|
let board = gitlab_board(
|
|
&test_api(&base, "gitlab"),
|
|
&vec![
|
|
"team".into(),
|
|
"repo".into(),
|
|
"-".into(),
|
|
"boards".into(),
|
|
"1".into(),
|
|
],
|
|
"https://gitlab.test/team/repo/-/boards/1",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
board
|
|
.columns
|
|
.iter()
|
|
.map(|c| c.title.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["Open", "Doing", "QA", "Closed"]
|
|
);
|
|
assert_eq!(board.columns[0].cards[0].title, "Unassigned");
|
|
assert_eq!(board.columns[1].cards.len(), 1);
|
|
assert!(board.columns[2].cards.is_empty());
|
|
assert_eq!(board.columns[3].cards[0].title, "Finished");
|
|
worker.join().unwrap();
|
|
}
|
|
#[test]
|
|
fn gitea_import_keeps_native_order_and_column_membership() {
|
|
let (base, worker) = mock_api(vec![
|
|
("/projects/1 ", json!({"title":"Roadmap"})),
|
|
(
|
|
"/projects/1/columns ",
|
|
json!([{"id":2,"title":"QA","sorting":2},{"id":1,"title":"Build","sorting":1}]),
|
|
),
|
|
(
|
|
"/columns/1/issues?page=1",
|
|
json!([{"id":4,"number":4,"title":"Build task"}]),
|
|
),
|
|
("/columns/1/issues?page=2", json!([])),
|
|
("/columns/2/issues?page=1", json!([])),
|
|
]);
|
|
let board = gitea_board(
|
|
&test_api(&base, "gitea"),
|
|
&vec!["owner".into(), "repo".into(), "projects".into(), "1".into()],
|
|
"https://gitea.test/owner/repo/projects/1",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(board.columns[0].title, "Build");
|
|
assert_eq!(board.columns[0].cards[0].number, 4);
|
|
assert!(board.columns[1].cards.is_empty());
|
|
worker.join().unwrap();
|
|
}
|
|
#[test]
|
|
fn azure_import_uses_board_specific_fields_and_split_columns() {
|
|
let (base, worker) = mock_api(vec![
|
|
(
|
|
"/Project/Team/_apis/work/boards/Stories?",
|
|
json!({"name":"Stories","fields":{"columnField":{"referenceName":"WEF_Test.Column"},"doneField":{"referenceName":"WEF_Test.Done"},"rowField":{"referenceName":"WEF_Test.Row"}},"columns":[{"id":"a","name":"Build","isSplit":true,"itemLimit":3,"stateMappings":{"User Story":"Active"}},{"id":"b","name":"QA","isSplit":false}]}),
|
|
),
|
|
(
|
|
"/teamfieldvalues?",
|
|
json!({"field":{"referenceName":"System.AreaPath"},"values":[{"value":"Project","includeChildren":true}]}),
|
|
),
|
|
("/wiql?", json!({"workItems":[{"id":1},{"id":2}]})),
|
|
(
|
|
"/workitems?",
|
|
json!({"value":[
|
|
{"id":1,"fields":{"System.Title":"In progress","System.State":"Active","WEF_Test.Column":"Build","WEF_Test.Done":false,"WEF_Test.Row":"Expedite"}},
|
|
{"id":2,"fields":{"System.Title":"Ready","System.State":"Active","WEF_Test.Column":"Build","WEF_Test.Done":true}}
|
|
]}),
|
|
),
|
|
]);
|
|
let board = azure_board(
|
|
&test_api(&base, "azure-devops"),
|
|
&vec![
|
|
"Project".into(),
|
|
"_boards".into(),
|
|
"board".into(),
|
|
"t".into(),
|
|
"Team".into(),
|
|
"Stories".into(),
|
|
],
|
|
"https://dev.azure.com/org/Project/_boards/board/t/Team/Stories",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(board.columns[0].title, "Build · Doing");
|
|
assert_eq!(board.columns[1].title, "Build · Done");
|
|
assert_eq!(board.columns[0].cards[0].title, "In progress");
|
|
assert_eq!(board.columns[0].cards[0].lane, "Expedite");
|
|
assert_eq!(board.columns[1].cards[0].title, "Ready");
|
|
assert!(board.columns[2].cards.is_empty());
|
|
worker.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn github_import_uses_view_field_values_and_follows_item_cursors() {
|
|
let field = json!({"id":"field","name":"Workflow","options":[{"id":"ready","name":"Ready"},{"id":"qa","name":"Testing"}]});
|
|
let (base, worker) = mock_api(vec![
|
|
(
|
|
"POST /graphql ",
|
|
json!({"data":{"organization":{"projectV2":{"id":"project","title":"Delivery","fields":{"pageInfo":{"hasNextPage":false}},"views":{"pageInfo":{"hasNextPage":false},"nodes":[{"number":2,"name":"Team board","layout":"BOARD_LAYOUT","filter":"","groupByFields":{"nodes":[field]}}]}}}}}),
|
|
),
|
|
(
|
|
"POST /graphql ",
|
|
json!({"data":{"node":{"items":{"pageInfo":{"hasNextPage":true,"endCursor":"second"},"nodes":[
|
|
{"id":"item1","isArchived":false,"fieldValues":{"nodes":[{"optionId":"qa","field":{"id":"field"}}]},"content":{"title":"Testing item","number":3,"state":"CLOSED"}},
|
|
{"id":"archived","isArchived":true,"content":{"title":"Archived"}}
|
|
]}}}}),
|
|
),
|
|
(
|
|
"POST /graphql ",
|
|
json!({"data":{"node":{"items":{"pageInfo":{"hasNextPage":false},"nodes":[{"id":"draft","fieldValues":{"nodes":[]},"content":{"title":"Unassigned draft"}}]}}}}),
|
|
),
|
|
]);
|
|
let board = github_board_from_api(
|
|
&test_api(&base, "github"),
|
|
&vec![
|
|
"orgs".into(),
|
|
"team".into(),
|
|
"projects".into(),
|
|
"1".into(),
|
|
"views".into(),
|
|
"2".into(),
|
|
],
|
|
"https://github.com/orgs/team/projects/1/views/2",
|
|
&format!("{base}/graphql"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
board
|
|
.columns
|
|
.iter()
|
|
.map(|c| c.title.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["No Workflow", "Ready", "Testing"]
|
|
);
|
|
assert_eq!(board.columns[0].cards[0].title, "Unassigned draft");
|
|
assert!(board.columns[1].cards.is_empty());
|
|
assert_eq!(board.columns[2].cards[0].title, "Testing item");
|
|
assert_eq!(
|
|
board.columns.iter().map(|c| c.cards.len()).sum::<usize>(),
|
|
2
|
|
);
|
|
worker.join().unwrap();
|
|
}
|
|
}
|