1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::client::InstallationClient;
8use crate::error::ApiError;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ProjectV2 {
13 pub id: u64,
15
16 pub node_id: String,
18
19 pub number: u64,
21
22 pub title: String,
24
25 pub description: Option<String>,
27
28 pub owner: ProjectOwner,
30
31 pub public: bool,
33
34 pub created_at: DateTime<Utc>,
36
37 pub updated_at: DateTime<Utc>,
39
40 pub url: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ProjectOwner {
47 pub login: String,
49
50 #[serde(rename = "type")]
52 pub owner_type: String, pub id: u64,
56
57 pub node_id: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ProjectV2Item {
64 pub id: String,
66
67 pub node_id: String,
69
70 pub content_type: String, pub content_node_id: String,
75
76 pub created_at: DateTime<Utc>,
78
79 pub updated_at: DateTime<Utc>,
81}
82
83#[derive(Debug, Clone, Serialize)]
85pub struct AddProjectV2ItemRequest {
86 pub content_node_id: String,
88}
89
90const GET_ISSUE_LINKED_PROJECTS_QUERY: &str = r#"
95query GetIssueLinkedProjects($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
96 repository(owner: $owner, name: $repo) {
97 issue(number: $number) {
98 projectsV2(first: 20, after: $cursor) {
99 pageInfo {
100 hasNextPage
101 endCursor
102 }
103 nodes {
104 id
105 databaseId
106 number
107 title
108 description
109 public
110 url
111 createdAt
112 updatedAt
113 owner {
114 ... on Organization {
115 id
116 databaseId
117 login
118 type: __typename
119 }
120 ... on User {
121 id
122 databaseId
123 login
124 type: __typename
125 }
126 }
127 }
128 }
129 }
130 }
131}
132"#;
133
134fn map_project_node(node: &serde_json::Value) -> Option<ProjectV2> {
139 let id = node.get("databaseId")?.as_u64()?;
140 let node_id = node.get("id")?.as_str()?.to_string();
141 let number = node.get("number")?.as_u64()?;
142 let title = node.get("title")?.as_str()?.to_string();
143 let description = node
144 .get("description")
145 .and_then(|d| d.as_str())
146 .map(|s| s.to_string());
147 let public = node.get("public")?.as_bool()?;
148 let url = node.get("url")?.as_str()?.to_string();
149 let created_at: DateTime<Utc> = node.get("createdAt")?.as_str()?.parse().ok()?;
150 let updated_at: DateTime<Utc> = node.get("updatedAt")?.as_str()?.parse().ok()?;
151
152 let owner_node = node.get("owner")?;
153 let owner_login = owner_node.get("login")?.as_str()?.to_string();
154 let owner_type = owner_node
155 .get("type")
156 .and_then(|t| t.as_str())
157 .unwrap_or("User")
158 .to_string();
159 let owner_id = owner_node
162 .get("databaseId")
163 .and_then(|v| v.as_u64())
164 .unwrap_or(0);
165 let owner_node_id = owner_node
168 .get("id")
169 .and_then(|v| v.as_str())
170 .unwrap_or("")
171 .to_string();
172
173 Some(ProjectV2 {
174 id,
175 node_id,
176 number,
177 title,
178 description,
179 owner: ProjectOwner {
180 login: owner_login,
181 owner_type,
182 id: owner_id,
183 node_id: owner_node_id,
184 },
185 public,
186 created_at,
187 updated_at,
188 url,
189 })
190}
191
192const GET_PROJECT_NODE_ID_ORG_QUERY: &str = r#"
197query GetProjectNodeIdOrg($owner: String!, $number: Int!) {
198 organization(login: $owner) {
199 projectV2(number: $number) {
200 id
201 }
202 }
203}
204"#;
205
206const GET_PROJECT_NODE_ID_USER_QUERY: &str = r#"
207query GetProjectNodeIdUser($owner: String!, $number: Int!) {
208 user(login: $owner) {
209 projectV2(number: $number) {
210 id
211 }
212 }
213}
214"#;
215
216const ADD_PROJECT_ITEM_MUTATION: &str = r#"
217mutation AddProjectV2Item($projectId: ID!, $contentId: ID!) {
218 addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
219 item {
220 id
221 type
222 createdAt
223 updatedAt
224 content {
225 ... on Issue { id }
226 ... on PullRequest { id }
227 }
228 }
229 }
230}
231"#;
232
233impl InstallationClient {
234 pub async fn list_organization_projects(&self, _org: &str) -> Result<Vec<ProjectV2>, ApiError> {
242 unimplemented!("See docs/spec/interfaces/project-operations.md")
243 }
244
245 pub async fn list_user_projects(&self, _username: &str) -> Result<Vec<ProjectV2>, ApiError> {
249 unimplemented!("See docs/spec/interfaces/project-operations.md")
250 }
251
252 pub async fn get_project(
256 &self,
257 _owner: &str,
258 _project_number: u64,
259 ) -> Result<ProjectV2, ApiError> {
260 unimplemented!("See docs/spec/interfaces/project-operations.md")
261 }
262
263 pub async fn add_item_to_project(
282 &self,
283 owner: &str,
284 project_number: u64,
285 content_node_id: &str,
286 ) -> Result<ProjectV2Item, ApiError> {
287 let project_node_id = self.get_project_node_id(owner, project_number).await?;
288
289 let variables = serde_json::json!({
290 "projectId": project_node_id,
291 "contentId": content_node_id,
292 });
293
294 let data = self
295 .post_graphql(ADD_PROJECT_ITEM_MUTATION, variables)
296 .await?;
297
298 let item = data
299 .get("addProjectV2ItemById")
300 .and_then(|a| a.get("item"))
301 .ok_or_else(|| ApiError::GraphQlError {
302 message: "addProjectV2ItemById returned no item".to_string(),
303 })?;
304
305 let item_id = item
306 .get("id")
307 .and_then(|v| v.as_str())
308 .ok_or_else(|| ApiError::GraphQlError {
309 message: "project item missing id field".to_string(),
310 })?
311 .to_string();
312
313 let content_type = item
314 .get("type")
315 .and_then(|v| v.as_str())
316 .ok_or_else(|| ApiError::GraphQlError {
317 message: "project item missing type field".to_string(),
318 })?
319 .to_string();
320
321 let created_at: DateTime<Utc> = item
322 .get("createdAt")
323 .and_then(|v| v.as_str())
324 .ok_or_else(|| ApiError::GraphQlError {
325 message: "project item missing createdAt field".to_string(),
326 })?
327 .parse()
328 .map_err(|_| ApiError::GraphQlError {
329 message: "project item createdAt is not a valid timestamp".to_string(),
330 })?;
331
332 let updated_at: DateTime<Utc> = item
333 .get("updatedAt")
334 .and_then(|v| v.as_str())
335 .ok_or_else(|| ApiError::GraphQlError {
336 message: "project item missing updatedAt field".to_string(),
337 })?
338 .parse()
339 .map_err(|_| ApiError::GraphQlError {
340 message: "project item updatedAt is not a valid timestamp".to_string(),
341 })?;
342
343 let linked_content_node_id = item
345 .get("content")
346 .and_then(|c| c.get("id"))
347 .and_then(|v| v.as_str())
348 .unwrap_or(content_node_id)
349 .to_string();
350
351 Ok(ProjectV2Item {
352 id: item_id.clone(),
353 node_id: item_id,
357 content_type,
358 content_node_id: linked_content_node_id,
359 created_at,
360 updated_at,
361 })
362 }
363
364 async fn get_project_node_id(
370 &self,
371 owner: &str,
372 project_number: u64,
373 ) -> Result<String, ApiError> {
374 let variables = serde_json::json!({
375 "owner": owner,
376 "number": project_number as i64,
379 });
380
381 match self
383 .post_graphql(GET_PROJECT_NODE_ID_ORG_QUERY, variables.clone())
384 .await
385 {
386 Ok(data) => {
387 if let Some(id) = data
388 .get("organization")
389 .and_then(|o| o.get("projectV2"))
390 .and_then(|p| p.get("id"))
391 .and_then(|v| v.as_str())
392 {
393 return Ok(id.to_string());
394 }
395 }
397 Err(ApiError::NotFound) => {
398 }
400 Err(other) => return Err(other),
401 }
402
403 let data = self
405 .post_graphql(GET_PROJECT_NODE_ID_USER_QUERY, variables)
406 .await?;
407
408 data.get("user")
409 .and_then(|u| u.get("projectV2"))
410 .and_then(|p| p.get("id"))
411 .and_then(|v| v.as_str())
412 .map(|s| s.to_string())
413 .ok_or(ApiError::NotFound)
414 }
415
416 pub async fn get_issue_linked_projects(
436 &self,
437 owner: &str,
438 repo: &str,
439 issue_number: u64,
440 ) -> Result<Vec<ProjectV2>, ApiError> {
441 let mut all_projects = Vec::new();
442 let mut cursor: Option<String> = None;
443
444 loop {
445 let variables = serde_json::json!({
446 "owner": owner,
447 "repo": repo,
448 "number": issue_number as i64,
451 "cursor": cursor,
452 });
453
454 let data = self
455 .post_graphql(GET_ISSUE_LINKED_PROJECTS_QUERY, variables)
456 .await?;
457
458 let issue_node = data.get("repository").and_then(|r| r.get("issue"));
459
460 if issue_node.is_none_or(|v| v.is_null()) {
464 return Err(ApiError::NotFound);
465 }
466
467 let projects_v2 = match issue_node.and_then(|i| i.get("projectsV2")) {
468 Some(pv2) => pv2,
469 None => break,
470 };
471
472 if let Some(nodes) = projects_v2.get("nodes").and_then(|n| n.as_array()) {
473 all_projects.extend(nodes.iter().filter_map(map_project_node));
474 }
475
476 let has_next_page = projects_v2
477 .get("pageInfo")
478 .and_then(|p| p.get("hasNextPage"))
479 .and_then(|v| v.as_bool())
480 .unwrap_or(false);
481
482 if !has_next_page {
483 break;
484 }
485
486 cursor = projects_v2
487 .get("pageInfo")
488 .and_then(|p| p.get("endCursor"))
489 .and_then(|v| v.as_str())
490 .map(String::from);
491 }
492
493 Ok(all_projects)
494 }
495
496 pub async fn remove_item_from_project(
500 &self,
501 _owner: &str,
502 _project_number: u64,
503 _item_id: &str,
504 ) -> Result<(), ApiError> {
505 unimplemented!("See docs/spec/interfaces/project-operations.md")
506 }
507}
508
509#[cfg(test)]
510#[path = "project_tests.rs"]
511mod tests;