mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-21 00:21:27 +00:00
1234 lines
42 KiB
Rust
1234 lines
42 KiB
Rust
use anyhow::{Context as _, anyhow};
|
|
use async_trait::async_trait;
|
|
use fabro_github::{
|
|
GitHubCredentials, create_installation_access_token_for_projects, sign_app_jwt,
|
|
};
|
|
use tokio::sync::OnceCell;
|
|
|
|
use crate::{Issue, Tracker, execute_graphql_request};
|
|
|
|
/// Execute a GitHub GraphQL request and return the response JSON.
|
|
async fn execute_github_graphql(
|
|
client: &fabro_http::HttpClient,
|
|
token: &str,
|
|
endpoint: &str,
|
|
query: &str,
|
|
variables: serde_json::Value,
|
|
) -> anyhow::Result<serde_json::Value> {
|
|
execute_graphql_request(
|
|
client,
|
|
endpoint,
|
|
&format!("Bearer {token}"),
|
|
"GitHub",
|
|
query,
|
|
variables,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// A `Tracker` implementation backed by GitHub Projects V2.
|
|
///
|
|
/// Scoped to a single project board identified by `project_number`.
|
|
pub struct GitHubTracker {
|
|
creds: GitHubCredentials,
|
|
client: fabro_http::HttpClient,
|
|
owner: String,
|
|
repo: String,
|
|
project_number: u64,
|
|
base_url: String,
|
|
project_node_id: OnceCell<String>,
|
|
}
|
|
|
|
impl GitHubTracker {
|
|
pub fn new(
|
|
creds: GitHubCredentials,
|
|
client: fabro_http::HttpClient,
|
|
owner: String,
|
|
repo: String,
|
|
project_number: u64,
|
|
base_url: String,
|
|
) -> Self {
|
|
Self {
|
|
creds,
|
|
client,
|
|
owner,
|
|
repo,
|
|
project_number,
|
|
base_url,
|
|
project_node_id: OnceCell::new(),
|
|
}
|
|
}
|
|
|
|
fn graphql_url(&self) -> String {
|
|
format!("{}/graphql", self.base_url)
|
|
}
|
|
|
|
async fn fresh_token(&self) -> anyhow::Result<String> {
|
|
match &self.creds {
|
|
GitHubCredentials::App(creds) => {
|
|
let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)
|
|
.context("signing GitHub App JWT")?;
|
|
create_installation_access_token_for_projects(
|
|
&self.client,
|
|
&jwt,
|
|
&self.owner,
|
|
&self.repo,
|
|
&self.base_url,
|
|
)
|
|
.await
|
|
.context("creating GitHub App installation token")
|
|
}
|
|
GitHubCredentials::Pat(token) => Ok(token.clone()),
|
|
GitHubCredentials::Installation(token) => token.valid_token().map(str::to_owned),
|
|
}
|
|
}
|
|
|
|
async fn resolve_project_node_id(&self, token: &str) -> anyhow::Result<&str> {
|
|
self.project_node_id
|
|
.get_or_try_init(|| async {
|
|
tracing::debug!(
|
|
owner = %self.owner,
|
|
project_number = self.project_number,
|
|
"Resolving GitHub project node ID"
|
|
);
|
|
let graphql_url = self.graphql_url();
|
|
let query = r"
|
|
query($owner: String!, $number: Int!) {
|
|
organization(login: $owner) {
|
|
projectV2(number: $number) { id }
|
|
}
|
|
}
|
|
";
|
|
let variables = serde_json::json!({
|
|
"owner": self.owner,
|
|
"number": self.project_number,
|
|
});
|
|
|
|
let resp = execute_github_graphql(
|
|
&self.client,
|
|
token,
|
|
&graphql_url,
|
|
query,
|
|
variables.clone(),
|
|
)
|
|
.await?;
|
|
|
|
// Try org path first, fall back to user path
|
|
if let Some(id) = resp["data"]["organization"]["projectV2"]["id"].as_str() {
|
|
return Ok(id.to_string());
|
|
}
|
|
|
|
let user_query = r"
|
|
query($owner: String!, $number: Int!) {
|
|
user(login: $owner) {
|
|
projectV2(number: $number) { id }
|
|
}
|
|
}
|
|
";
|
|
let user_resp = execute_github_graphql(
|
|
&self.client,
|
|
token,
|
|
&graphql_url,
|
|
user_query,
|
|
variables,
|
|
)
|
|
.await?;
|
|
|
|
user_resp["data"]["user"]["projectV2"]["id"]
|
|
.as_str()
|
|
.map(std::string::ToString::to_string)
|
|
.ok_or_else(|| {
|
|
anyhow!(
|
|
"Project #{} not found for owner '{}'",
|
|
self.project_number,
|
|
self.owner
|
|
)
|
|
})
|
|
})
|
|
.await
|
|
.map(std::string::String::as_str)
|
|
}
|
|
}
|
|
|
|
fn normalize_github_item(item: &serde_json::Value) -> Option<Issue> {
|
|
let project_item_id = item["id"].as_str()?.to_string();
|
|
let content = &item["content"];
|
|
|
|
let id = content["id"].as_str()?.to_string();
|
|
let number = content["number"].as_u64()?;
|
|
let identifier = format!("#{number}");
|
|
let title = content["title"].as_str()?.to_string();
|
|
let url = content["url"].as_str()?.to_string();
|
|
let description = content["body"]
|
|
.as_str()
|
|
.map(std::string::ToString::to_string);
|
|
|
|
let state = item["fieldValueByName"]["name"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
|
|
let assignee_id = content["assignees"]["nodes"]
|
|
.as_array()
|
|
.and_then(|arr| arr.first())
|
|
.and_then(|a| a["id"].as_str())
|
|
.map(std::string::ToString::to_string);
|
|
|
|
let labels = content["labels"]["nodes"]
|
|
.as_array()
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|l| l["name"].as_str())
|
|
.map(str::to_lowercase)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let created_at = content["createdAt"]
|
|
.as_str()
|
|
.map(std::string::ToString::to_string);
|
|
let updated_at = content["updatedAt"]
|
|
.as_str()
|
|
.map(std::string::ToString::to_string);
|
|
|
|
Some(Issue {
|
|
id,
|
|
project_item_id: Some(project_item_id),
|
|
identifier,
|
|
title,
|
|
description,
|
|
priority: None,
|
|
state,
|
|
branch_name: None,
|
|
url,
|
|
assignee_id,
|
|
labels,
|
|
blocked_by: vec![],
|
|
created_at,
|
|
updated_at,
|
|
})
|
|
}
|
|
|
|
/// Fetch one page of project items. Returns (items, has_next_page, end_cursor).
|
|
async fn fetch_project_items_page(
|
|
client: &fabro_http::HttpClient,
|
|
token: &str,
|
|
graphql_url: &str,
|
|
project_node_id: &str,
|
|
cursor: Option<&str>,
|
|
) -> anyhow::Result<(Vec<serde_json::Value>, bool, Option<String>)> {
|
|
let query = r#"
|
|
query($projectId: ID!, $cursor: String) {
|
|
node(id: $projectId) {
|
|
... on ProjectV2 {
|
|
items(first: 100, after: $cursor) {
|
|
nodes {
|
|
id
|
|
fieldValueByName(name: "Status") {
|
|
... on ProjectV2ItemFieldSingleSelectValue {
|
|
name
|
|
}
|
|
}
|
|
content {
|
|
... on Issue {
|
|
id
|
|
number
|
|
title
|
|
body
|
|
url
|
|
createdAt
|
|
updatedAt
|
|
assignees(first: 1) { nodes { id } }
|
|
labels(first: 20) { nodes { name } }
|
|
}
|
|
}
|
|
}
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let variables = serde_json::json!({
|
|
"projectId": project_node_id,
|
|
"cursor": cursor,
|
|
});
|
|
|
|
let mut resp = execute_github_graphql(client, token, graphql_url, query, variables).await?;
|
|
|
|
let has_next = resp["data"]["node"]["items"]["pageInfo"]["hasNextPage"]
|
|
.as_bool()
|
|
.unwrap_or(false);
|
|
let end_cursor = resp["data"]["node"]["items"]["pageInfo"]["endCursor"]
|
|
.as_str()
|
|
.map(std::string::ToString::to_string);
|
|
|
|
// Take ownership of the nodes array in-place instead of deep-cloning it.
|
|
let nodes = resp
|
|
.pointer_mut("/data/node/items/nodes")
|
|
.and_then(|v| v.as_array_mut())
|
|
.map(std::mem::take)
|
|
.unwrap_or_default();
|
|
|
|
Ok((nodes, has_next, end_cursor))
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tracker for GitHubTracker {
|
|
async fn fetch_viewer_id(&self) -> anyhow::Result<String> {
|
|
tracing::debug!("Fetching viewer ID from GitHub");
|
|
let token = self.fresh_token().await?;
|
|
let query = "query { viewer { id } }";
|
|
let resp = execute_github_graphql(
|
|
&self.client,
|
|
&token,
|
|
&self.graphql_url(),
|
|
query,
|
|
serde_json::json!({}),
|
|
)
|
|
.await?;
|
|
|
|
resp["data"]["viewer"]["id"]
|
|
.as_str()
|
|
.map(std::string::ToString::to_string)
|
|
.ok_or_else(|| anyhow!("Missing viewer id in GitHub response"))
|
|
}
|
|
|
|
async fn create_comment(&self, issue: &Issue, body: &str) -> anyhow::Result<()> {
|
|
tracing::debug!(issue_id = %issue.id, "Creating comment on GitHub issue");
|
|
let token = self.fresh_token().await?;
|
|
let query = r"
|
|
mutation($subjectId: ID!, $body: String!) {
|
|
addComment(input: { subjectId: $subjectId, body: $body }) {
|
|
clientMutationId
|
|
}
|
|
}
|
|
";
|
|
let variables = serde_json::json!({
|
|
"subjectId": issue.id,
|
|
"body": body,
|
|
});
|
|
execute_github_graphql(&self.client, &token, &self.graphql_url(), query, variables).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_issue_state(&self, issue: &Issue, state_name: &str) -> anyhow::Result<()> {
|
|
let project_item_id = issue
|
|
.project_item_id
|
|
.as_deref()
|
|
.ok_or_else(|| anyhow!("update_issue_state requires project_item_id"))?;
|
|
|
|
tracing::debug!(
|
|
project_item_id,
|
|
state_name,
|
|
"Updating GitHub project item status"
|
|
);
|
|
|
|
let token = self.fresh_token().await?;
|
|
let project_node_id = self.resolve_project_node_id(&token).await?;
|
|
let graphql_url = self.graphql_url();
|
|
|
|
// Step 1: Get the Status field ID and the target option ID
|
|
let field_query = r#"
|
|
query($projectId: ID!) {
|
|
node(id: $projectId) {
|
|
... on ProjectV2 {
|
|
field(name: "Status") {
|
|
... on ProjectV2SingleSelectField {
|
|
id
|
|
options { id name }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"#;
|
|
let field_resp = execute_github_graphql(
|
|
&self.client,
|
|
&token,
|
|
&graphql_url,
|
|
field_query,
|
|
serde_json::json!({ "projectId": project_node_id }),
|
|
)
|
|
.await?;
|
|
|
|
let field = &field_resp["data"]["node"]["field"];
|
|
let field_id = field["id"]
|
|
.as_str()
|
|
.ok_or_else(|| anyhow!("Missing Status field id"))?
|
|
.to_string();
|
|
|
|
let option_id = field["options"]
|
|
.as_array()
|
|
.and_then(|opts| {
|
|
opts.iter().find(|o| {
|
|
o["name"]
|
|
.as_str()
|
|
.is_some_and(|n| n.eq_ignore_ascii_case(state_name))
|
|
})
|
|
})
|
|
.and_then(|o| o["id"].as_str())
|
|
.ok_or_else(|| anyhow!("Status option '{state_name}' not found in project"))?
|
|
.to_string();
|
|
|
|
// Step 2: Update the field value
|
|
let update_query = r"
|
|
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
updateProjectV2ItemFieldValue(input: {
|
|
projectId: $projectId
|
|
itemId: $itemId
|
|
fieldId: $fieldId
|
|
value: { singleSelectOptionId: $optionId }
|
|
}) {
|
|
projectV2Item { id }
|
|
}
|
|
}
|
|
";
|
|
execute_github_graphql(
|
|
&self.client,
|
|
&token,
|
|
&graphql_url,
|
|
update_query,
|
|
serde_json::json!({
|
|
"projectId": project_node_id,
|
|
"itemId": project_item_id,
|
|
"fieldId": field_id,
|
|
"optionId": option_id,
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn fetch_candidate_issues(&self, state_names: &[&str]) -> anyhow::Result<Vec<Issue>> {
|
|
tracing::debug!(
|
|
owner = %self.owner,
|
|
project_number = self.project_number,
|
|
?state_names,
|
|
"Fetching candidate issues from GitHub project"
|
|
);
|
|
|
|
let token = self.fresh_token().await?;
|
|
let project_node_id = self.resolve_project_node_id(&token).await?;
|
|
let graphql_url = self.graphql_url();
|
|
|
|
let mut all_issues = Vec::new();
|
|
let mut cursor: Option<String> = None;
|
|
|
|
loop {
|
|
let (nodes, has_next, end_cursor) = fetch_project_items_page(
|
|
&self.client,
|
|
&token,
|
|
&graphql_url,
|
|
project_node_id,
|
|
cursor.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
for node in &nodes {
|
|
if let Some(issue) = normalize_github_item(node) {
|
|
if state_names
|
|
.iter()
|
|
.any(|s| s.eq_ignore_ascii_case(&issue.state))
|
|
{
|
|
all_issues.push(issue);
|
|
}
|
|
}
|
|
}
|
|
|
|
if has_next {
|
|
cursor = end_cursor;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(all_issues)
|
|
}
|
|
|
|
async fn fetch_issues_by_ids(&self, ids: &[&str]) -> anyhow::Result<Vec<Issue>> {
|
|
if ids.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
tracing::debug!(
|
|
count = ids.len(),
|
|
"Fetching GitHub issues by ID from project"
|
|
);
|
|
|
|
let token = self.fresh_token().await?;
|
|
let project_node_id = self.resolve_project_node_id(&token).await?;
|
|
let graphql_url = self.graphql_url();
|
|
|
|
let id_set: std::collections::HashSet<&str> = ids.iter().copied().collect();
|
|
let mut issue_map: std::collections::HashMap<String, Issue> =
|
|
std::collections::HashMap::with_capacity(ids.len());
|
|
let mut cursor: Option<String> = None;
|
|
|
|
loop {
|
|
let (nodes, has_next, end_cursor) = fetch_project_items_page(
|
|
&self.client,
|
|
&token,
|
|
&graphql_url,
|
|
project_node_id,
|
|
cursor.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
for node in &nodes {
|
|
if let Some(issue) = normalize_github_item(node) {
|
|
if id_set.contains(issue.id.as_str()) {
|
|
issue_map.insert(issue.id.clone(), issue);
|
|
}
|
|
}
|
|
}
|
|
|
|
if issue_map.len() == id_set.len() {
|
|
break;
|
|
}
|
|
|
|
if has_next {
|
|
cursor = end_cursor;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Return in the same order as the input IDs
|
|
Ok(ids.iter().filter_map(|id| issue_map.remove(*id)).collect())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use fabro_github::{GitHubAppCredentials, GitHubCredentials};
|
|
|
|
use super::*;
|
|
use crate::Issue;
|
|
fn test_http_client() -> fabro_http::HttpClient {
|
|
fabro_http::test_http_client().unwrap()
|
|
}
|
|
|
|
fn test_rsa_key() -> String {
|
|
include_str!("fixtures/github-app-test-key.pem").to_string()
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// execute_github_graphql
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn execute_github_graphql_success() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
|
|
let mock = server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.header("Authorization", "Bearer test-token")
|
|
.header("Content-Type", "application/json")
|
|
.header("User-Agent", "fabro");
|
|
then.status(200)
|
|
.body(r#"{"data": {"viewer": {"id": "U_abc"}}}"#);
|
|
})
|
|
.await;
|
|
|
|
let client = test_http_client();
|
|
let result = execute_github_graphql(
|
|
&client,
|
|
"test-token",
|
|
&server.url("/graphql"),
|
|
"query { viewer { id } }",
|
|
serde_json::json!({}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result["data"]["viewer"]["id"], "U_abc");
|
|
mock.assert_async().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_github_graphql_http_error() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST").path("/graphql");
|
|
then.status(401).body("Unauthorized");
|
|
})
|
|
.await;
|
|
|
|
let client = test_http_client();
|
|
let err = execute_github_graphql(
|
|
&client,
|
|
"bad-token",
|
|
&server.url("/graphql"),
|
|
"query { viewer { id } }",
|
|
serde_json::json!({}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("401"), "got: {err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_github_graphql_errors_array() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST").path("/graphql");
|
|
then.status(200)
|
|
.body(r#"{"data": null, "errors": [{"message": "Not found"}]}"#);
|
|
})
|
|
.await;
|
|
|
|
let client = test_http_client();
|
|
let err = execute_github_graphql(
|
|
&client,
|
|
"token",
|
|
&server.url("/graphql"),
|
|
"query { bad }",
|
|
serde_json::json!({}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("Not found"), "got: {err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_github_graphql_correct_headers() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
|
|
let mock = server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.header("Authorization", "Bearer my-token")
|
|
.header("Content-Type", "application/json")
|
|
.header("User-Agent", "fabro");
|
|
then.status(200).body(r#"{"data": {}}"#);
|
|
})
|
|
.await;
|
|
|
|
let client = test_http_client();
|
|
execute_github_graphql(
|
|
&client,
|
|
"my-token",
|
|
&server.url("/graphql"),
|
|
"query { viewer { id } }",
|
|
serde_json::json!({}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
mock.assert_async().await;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// GitHubTracker helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn mock_github_tracker(server_url: &str, pem: String) -> GitHubTracker {
|
|
GitHubTracker::new(
|
|
GitHubCredentials::App(GitHubAppCredentials {
|
|
app_id: "test-app".to_string(),
|
|
private_key_pem: pem,
|
|
slug: None,
|
|
}),
|
|
test_http_client(),
|
|
"owner".to_string(),
|
|
"repo".to_string(),
|
|
1,
|
|
server_url.to_string(),
|
|
)
|
|
}
|
|
|
|
fn make_test_issue(state: &str) -> Issue {
|
|
Issue {
|
|
id: "I_issue1".to_string(),
|
|
project_item_id: Some("PVTI_item1".to_string()),
|
|
identifier: "#42".to_string(),
|
|
title: "Fix bug".to_string(),
|
|
description: None,
|
|
priority: None,
|
|
state: state.to_string(),
|
|
branch_name: None,
|
|
url: "https://github.com/owner/repo/issues/42".to_string(),
|
|
assignee_id: None,
|
|
labels: vec![],
|
|
blocked_by: vec![],
|
|
created_at: None,
|
|
updated_at: None,
|
|
}
|
|
}
|
|
|
|
fn org_project_node_id_response() -> &'static str {
|
|
r#"{"data": {"organization": {"projectV2": {"id": "PVT_abc123"}}}}"#
|
|
}
|
|
|
|
fn empty_items_response() -> &'static str {
|
|
r#"{"data": {"node": {"items": {"nodes": [], "pageInfo": {"hasNextPage": false, "endCursor": null}}}}}"#
|
|
}
|
|
|
|
fn single_item_response(status: &str) -> String {
|
|
serde_json::json!({
|
|
"data": {
|
|
"node": {
|
|
"items": {
|
|
"nodes": [
|
|
{
|
|
"id": "PVTI_item1",
|
|
"fieldValueByName": {"name": status},
|
|
"content": {
|
|
"id": "I_issue1",
|
|
"number": 42,
|
|
"title": "Fix bug",
|
|
"body": "Description",
|
|
"url": "https://github.com/owner/repo/issues/42",
|
|
"createdAt": "2026-01-01T00:00:00Z",
|
|
"updatedAt": "2026-01-02T00:00:00Z",
|
|
"assignees": {"nodes": []},
|
|
"labels": {"nodes": [{"name": "bug"}]}
|
|
}
|
|
}
|
|
],
|
|
"pageInfo": {"hasNextPage": false, "endCursor": null}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// project node ID resolution
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn project_node_id_resolved_via_org() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("organization(login:");
|
|
then.status(200).body(org_project_node_id_response());
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("items(first:");
|
|
then.status(200).body(empty_items_response());
|
|
})
|
|
.await;
|
|
|
|
let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap();
|
|
assert!(issues.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn project_node_id_falls_back_to_user() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("organization(login:");
|
|
then.status(200).body(r#"{"data": {"organization": null}}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("user(login:");
|
|
then.status(200)
|
|
.body(r#"{"data": {"user": {"projectV2": {"id": "PVT_user1"}}}}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("items(first:");
|
|
then.status(200).body(empty_items_response());
|
|
})
|
|
.await;
|
|
|
|
let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap();
|
|
assert!(issues.is_empty());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// fetch_viewer_id (GitHubTracker)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn github_tracker_fetch_viewer_id_success() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST").path("/graphql");
|
|
then.status(200)
|
|
.body(r#"{"data": {"viewer": {"id": "U_xyz"}}}"#);
|
|
})
|
|
.await;
|
|
|
|
let id = tracker.fetch_viewer_id().await.unwrap();
|
|
assert_eq!(id, "U_xyz");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// create_comment (GitHubTracker)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn github_tracker_create_comment_success() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST").path("/graphql");
|
|
then.status(200)
|
|
.body(r#"{"data": {"addComment": {"clientMutationId": null}}}"#);
|
|
})
|
|
.await;
|
|
|
|
let issue = make_test_issue("In Progress");
|
|
tracker.create_comment(&issue, "Great work!").await.unwrap();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// update_issue_state (GitHubTracker)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn github_tracker_update_issue_state_success() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("organization(login:");
|
|
then.status(200).body(org_project_node_id_response());
|
|
})
|
|
.await;
|
|
// Field query
|
|
server.mock_async(|when, then| {
|
|
when.method("POST").path("/graphql").body_includes("field(name:");
|
|
then.status(200).body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}, {"id": "opt-todo", "name": "Todo"}]}}}}"#);
|
|
}).await;
|
|
server.mock_async(|when, then| {
|
|
when.method("POST").path("/graphql").body_includes("updateProjectV2ItemFieldValue");
|
|
then.status(200).body(r#"{"data": {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "PVTI_item1"}}}}"#);
|
|
}).await;
|
|
|
|
let issue = make_test_issue("In Progress");
|
|
tracker.update_issue_state(&issue, "Done").await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_tracker_update_issue_state_status_not_found() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("organization(login:");
|
|
then.status(200).body(org_project_node_id_response());
|
|
})
|
|
.await;
|
|
// Field query — options don't include "Nonexistent"
|
|
server.mock_async(|when, then| {
|
|
when.method("POST").path("/graphql").body_includes("field(name:");
|
|
then.status(200).body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}]}}}}"#);
|
|
}).await;
|
|
|
|
let issue = make_test_issue("Todo");
|
|
let err = tracker
|
|
.update_issue_state(&issue, "Nonexistent")
|
|
.await
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("Nonexistent"), "got: {err}");
|
|
assert!(err.to_string().contains("not found"), "got: {err}");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// fetch_candidate_issues (GitHubTracker)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn github_tracker_fetch_candidate_issues_single_page() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("organization(login:");
|
|
then.status(200).body(org_project_node_id_response());
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("items(first:");
|
|
then.status(200).body(single_item_response("In Progress"));
|
|
})
|
|
.await;
|
|
|
|
let issues = tracker
|
|
.fetch_candidate_issues(&["In Progress"])
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(issues.len(), 1);
|
|
assert_eq!(issues[0].identifier, "#42");
|
|
assert_eq!(issues[0].state, "In Progress");
|
|
assert_eq!(issues[0].id, "I_issue1");
|
|
assert_eq!(issues[0].project_item_id.as_deref(), Some("PVTI_item1"));
|
|
assert_eq!(issues[0].labels, vec!["bug"]);
|
|
assert!(issues[0].branch_name.is_none());
|
|
assert!(issues[0].priority.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_tracker_fetch_candidate_issues_empty() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("organization(login:");
|
|
then.status(200).body(org_project_node_id_response());
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("items(first:");
|
|
then.status(200).body(empty_items_response());
|
|
})
|
|
.await;
|
|
|
|
let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap();
|
|
assert!(issues.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_tracker_fetch_candidate_issues_status_filtering() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
let items_body = serde_json::json!({
|
|
"data": {
|
|
"node": {
|
|
"items": {
|
|
"nodes": [
|
|
{
|
|
"id": "PVTI_done",
|
|
"fieldValueByName": {"name": "Done"},
|
|
"content": {
|
|
"id": "I_done1", "number": 10, "title": "Done issue",
|
|
"body": null, "url": "https://github.com/owner/repo/issues/10",
|
|
"createdAt": null, "updatedAt": null,
|
|
"assignees": {"nodes": []}, "labels": {"nodes": []}
|
|
}
|
|
},
|
|
{
|
|
"id": "PVTI_inprog",
|
|
"fieldValueByName": {"name": "In Progress"},
|
|
"content": {
|
|
"id": "I_inprog1", "number": 20, "title": "Active issue",
|
|
"body": null, "url": "https://github.com/owner/repo/issues/20",
|
|
"createdAt": null, "updatedAt": null,
|
|
"assignees": {"nodes": []}, "labels": {"nodes": []}
|
|
}
|
|
}
|
|
],
|
|
"pageInfo": {"hasNextPage": false, "endCursor": null}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("organization(login:");
|
|
then.status(200).body(org_project_node_id_response());
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("items(first:");
|
|
then.status(200).body(items_body);
|
|
})
|
|
.await;
|
|
|
|
let issues = tracker
|
|
.fetch_candidate_issues(&["In Progress"])
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(issues.len(), 1);
|
|
assert_eq!(issues[0].identifier, "#20");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// fetch_issues_by_ids (GitHubTracker)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn github_tracker_fetch_issues_by_ids_ordering() {
|
|
let server = httpmock::MockServer::start_async().await;
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker(&server.url(""), pem);
|
|
|
|
// Page returns issues in reverse order of what we request
|
|
let items_body = serde_json::json!({
|
|
"data": {
|
|
"node": {
|
|
"items": {
|
|
"nodes": [
|
|
{
|
|
"id": "PVTI_b",
|
|
"fieldValueByName": {"name": "Todo"},
|
|
"content": {
|
|
"id": "I_b", "number": 2, "title": "B",
|
|
"body": null, "url": "https://github.com/owner/repo/issues/2",
|
|
"createdAt": null, "updatedAt": null,
|
|
"assignees": {"nodes": []}, "labels": {"nodes": []}
|
|
}
|
|
},
|
|
{
|
|
"id": "PVTI_a",
|
|
"fieldValueByName": {"name": "Todo"},
|
|
"content": {
|
|
"id": "I_a", "number": 1, "title": "A",
|
|
"body": null, "url": "https://github.com/owner/repo/issues/1",
|
|
"createdAt": null, "updatedAt": null,
|
|
"assignees": {"nodes": []}, "labels": {"nodes": []}
|
|
}
|
|
}
|
|
],
|
|
"pageInfo": {"hasNextPage": false, "endCursor": null}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("GET").path("/repos/owner/repo/installation");
|
|
then.status(200).body(r#"{"id": 1}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/app/installations/1/access_tokens");
|
|
then.status(201)
|
|
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("organization(login:");
|
|
then.status(200).body(org_project_node_id_response());
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method("POST")
|
|
.path("/graphql")
|
|
.body_includes("items(first:");
|
|
then.status(200).body(items_body);
|
|
})
|
|
.await;
|
|
|
|
// Request in A, B order — should get back in A, B order despite page returning
|
|
// B, A
|
|
let issues = tracker.fetch_issues_by_ids(&["I_a", "I_b"]).await.unwrap();
|
|
|
|
assert_eq!(issues.len(), 2);
|
|
assert_eq!(issues[0].id, "I_a");
|
|
assert_eq!(issues[1].id, "I_b");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_tracker_fetch_issues_by_ids_empty() {
|
|
let pem = test_rsa_key();
|
|
let tracker = mock_github_tracker("http://unused", pem);
|
|
|
|
// Empty input → no HTTP calls at all
|
|
let issues = tracker.fetch_issues_by_ids(&[]).await.unwrap();
|
|
assert!(issues.is_empty());
|
|
}
|
|
}
|