use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::client::InstallationClient;
use crate::error::ApiError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectV2 {
pub id: u64,
pub node_id: String,
pub number: u64,
pub title: String,
pub description: Option<String>,
pub owner: ProjectOwner,
pub public: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectOwner {
pub login: String,
#[serde(rename = "type")]
pub owner_type: String,
pub id: u64,
pub node_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectV2Item {
pub id: String,
pub node_id: String,
pub content_type: String,
pub content_node_id: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AddProjectV2ItemRequest {
pub content_node_id: String,
}
const GET_ISSUE_LINKED_PROJECTS_QUERY: &str = r#"
query GetIssueLinkedProjects($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
projectsV2(first: 20, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
databaseId
number
title
shortDescription
public
url
createdAt
updatedAt
owner {
... on Organization {
id
databaseId
login
type: __typename
}
... on User {
id
databaseId
login
type: __typename
}
}
}
}
}
}
}
"#;
fn map_project_node(node: &serde_json::Value) -> Option<ProjectV2> {
let id = node.get("databaseId")?.as_u64()?;
let node_id = node.get("id")?.as_str()?.to_string();
let number = node.get("number")?.as_u64()?;
let title = node.get("title")?.as_str()?.to_string();
let description = node
.get("shortDescription")
.and_then(|d| d.as_str())
.map(|s| s.to_string());
let public = node.get("public")?.as_bool()?;
let url = node.get("url")?.as_str()?.to_string();
let created_at: DateTime<Utc> = node.get("createdAt")?.as_str()?.parse().ok()?;
let updated_at: DateTime<Utc> = node.get("updatedAt")?.as_str()?.parse().ok()?;
let owner_node = node.get("owner")?;
let owner_login = owner_node.get("login")?.as_str()?.to_string();
let owner_type = owner_node
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("User")
.to_string();
let owner_id = owner_node
.get("databaseId")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let owner_node_id = owner_node
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(ProjectV2 {
id,
node_id,
number,
title,
description,
owner: ProjectOwner {
login: owner_login,
owner_type,
id: owner_id,
node_id: owner_node_id,
},
public,
created_at,
updated_at,
url,
})
}
const GET_PROJECT_NODE_ID_ORG_QUERY: &str = r#"
query GetProjectNodeIdOrg($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
}
}
}
"#;
const GET_PROJECT_NODE_ID_USER_QUERY: &str = r#"
query GetProjectNodeIdUser($owner: String!, $number: Int!) {
user(login: $owner) {
projectV2(number: $number) {
id
}
}
}
"#;
const ADD_PROJECT_ITEM_MUTATION: &str = r#"
mutation AddProjectV2Item($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
item {
id
type
createdAt
updatedAt
content {
... on Issue { id }
... on PullRequest { id }
}
}
}
}
"#;
#[derive(Debug, Clone)]
pub struct ProjectsClient {
pub(crate) client: InstallationClient,
}
impl ProjectsClient {
pub(crate) fn new(client: InstallationClient) -> Self {
Self { client }
}
pub async fn list_for_org(&self, _org: &str) -> Result<Vec<ProjectV2>, ApiError> {
unimplemented!("See docs/specs/interfaces/project-operations.md")
}
pub async fn list_for_user(&self, _username: &str) -> Result<Vec<ProjectV2>, ApiError> {
unimplemented!("See docs/specs/interfaces/project-operations.md")
}
pub async fn get(&self, _owner: &str, _project_number: u64) -> Result<ProjectV2, ApiError> {
unimplemented!("See docs/spec/interfaces/project-operations.md")
}
pub async fn add_item(
&self,
owner: &str,
project_number: u64,
content_node_id: &str,
) -> Result<ProjectV2Item, ApiError> {
let project_node_id = self.get_project_node_id(owner, project_number).await?;
let variables = serde_json::json!({
"projectId": project_node_id,
"contentId": content_node_id,
});
let data = self
.client
.post_graphql(ADD_PROJECT_ITEM_MUTATION, variables)
.await?;
let item = data
.get("addProjectV2ItemById")
.and_then(|a| a.get("item"))
.ok_or_else(|| ApiError::GraphQlError {
message: "addProjectV2ItemById returned no item".to_string(),
})?;
let item_id = item
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| ApiError::GraphQlError {
message: "project item missing id field".to_string(),
})?
.to_string();
let content_type = item
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| ApiError::GraphQlError {
message: "project item missing type field".to_string(),
})?
.to_string();
let created_at: DateTime<Utc> = item
.get("createdAt")
.and_then(|v| v.as_str())
.ok_or_else(|| ApiError::GraphQlError {
message: "project item missing createdAt field".to_string(),
})?
.parse()
.map_err(|_| ApiError::GraphQlError {
message: "project item createdAt is not a valid timestamp".to_string(),
})?;
let updated_at: DateTime<Utc> = item
.get("updatedAt")
.and_then(|v| v.as_str())
.ok_or_else(|| ApiError::GraphQlError {
message: "project item missing updatedAt field".to_string(),
})?
.parse()
.map_err(|_| ApiError::GraphQlError {
message: "project item updatedAt is not a valid timestamp".to_string(),
})?;
let linked_content_node_id = item
.get("content")
.and_then(|c| c.get("id"))
.and_then(|v| v.as_str())
.unwrap_or(content_node_id)
.to_string();
Ok(ProjectV2Item {
id: item_id.clone(),
node_id: item_id,
content_type,
content_node_id: linked_content_node_id,
created_at,
updated_at,
})
}
async fn get_project_node_id(
&self,
owner: &str,
project_number: u64,
) -> Result<String, ApiError> {
let variables = serde_json::json!({
"owner": owner,
"number": project_number as i64,
});
match self
.client
.post_graphql(GET_PROJECT_NODE_ID_ORG_QUERY, variables.clone())
.await
{
Ok(data) => {
if let Some(id) = data
.get("organization")
.and_then(|o| o.get("projectV2"))
.and_then(|p| p.get("id"))
.and_then(|v| v.as_str())
{
return Ok(id.to_string());
}
}
Err(ApiError::NotFound) => {
}
Err(other) => return Err(other),
}
let data = self
.client
.post_graphql(GET_PROJECT_NODE_ID_USER_QUERY, variables)
.await?;
data.get("user")
.and_then(|u| u.get("projectV2"))
.and_then(|p| p.get("id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or(ApiError::NotFound)
}
pub async fn list_for_issue(
&self,
owner: &str,
repo: &str,
issue_number: u64,
) -> Result<Vec<ProjectV2>, ApiError> {
let mut all_projects = Vec::new();
let mut cursor: Option<String> = None;
loop {
let variables = serde_json::json!({
"owner": owner,
"repo": repo,
"number": issue_number as i64,
"cursor": cursor,
});
let data = self
.client
.post_graphql(GET_ISSUE_LINKED_PROJECTS_QUERY, variables)
.await?;
let issue_node = data.get("repository").and_then(|r| r.get("issue"));
if issue_node.is_none_or(|v| v.is_null()) {
return Err(ApiError::NotFound);
}
let projects_v2 = match issue_node.and_then(|i| i.get("projectsV2")) {
Some(pv2) => pv2,
None => break,
};
if let Some(nodes) = projects_v2.get("nodes").and_then(|n| n.as_array()) {
all_projects.extend(nodes.iter().filter_map(map_project_node));
}
let has_next_page = projects_v2
.get("pageInfo")
.and_then(|p| p.get("hasNextPage"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !has_next_page {
break;
}
cursor = projects_v2
.get("pageInfo")
.and_then(|p| p.get("endCursor"))
.and_then(|v| v.as_str())
.map(String::from);
}
Ok(all_projects)
}
pub async fn remove_item(
&self,
_owner: &str,
_project_number: u64,
_item_id: &str,
) -> Result<(), ApiError> {
unimplemented!("See docs/spec/interfaces/project-operations.md")
}
}
#[cfg(test)]
#[path = "project_tests.rs"]
mod tests;