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 shortDescription
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("shortDescription")
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
233#[derive(Debug, Clone)]
239pub struct ProjectsClient {
240 pub(crate) client: InstallationClient,
241}
242
243impl ProjectsClient {
244 pub(crate) fn new(client: InstallationClient) -> Self {
245 Self { client }
246 }
247
248 pub async fn list_for_org(&self, _org: &str) -> Result<Vec<ProjectV2>, ApiError> {
256 unimplemented!("See docs/specs/interfaces/project-operations.md")
257 }
258
259 pub async fn list_for_user(&self, _username: &str) -> Result<Vec<ProjectV2>, ApiError> {
263 unimplemented!("See docs/specs/interfaces/project-operations.md")
264 }
265
266 pub async fn get(&self, _owner: &str, _project_number: u64) -> Result<ProjectV2, ApiError> {
270 unimplemented!("See docs/spec/interfaces/project-operations.md")
271 }
272
273 pub async fn add_item(
292 &self,
293 owner: &str,
294 project_number: u64,
295 content_node_id: &str,
296 ) -> Result<ProjectV2Item, ApiError> {
297 let project_node_id = self.get_project_node_id(owner, project_number).await?;
298
299 let variables = serde_json::json!({
300 "projectId": project_node_id,
301 "contentId": content_node_id,
302 });
303
304 let data = self
305 .client
306 .post_graphql(ADD_PROJECT_ITEM_MUTATION, variables)
307 .await?;
308
309 let item = data
310 .get("addProjectV2ItemById")
311 .and_then(|a| a.get("item"))
312 .ok_or_else(|| ApiError::GraphQlError {
313 message: "addProjectV2ItemById returned no item".to_string(),
314 })?;
315
316 let item_id = item
317 .get("id")
318 .and_then(|v| v.as_str())
319 .ok_or_else(|| ApiError::GraphQlError {
320 message: "project item missing id field".to_string(),
321 })?
322 .to_string();
323
324 let content_type = item
325 .get("type")
326 .and_then(|v| v.as_str())
327 .ok_or_else(|| ApiError::GraphQlError {
328 message: "project item missing type field".to_string(),
329 })?
330 .to_string();
331
332 let created_at: DateTime<Utc> = item
333 .get("createdAt")
334 .and_then(|v| v.as_str())
335 .ok_or_else(|| ApiError::GraphQlError {
336 message: "project item missing createdAt field".to_string(),
337 })?
338 .parse()
339 .map_err(|_| ApiError::GraphQlError {
340 message: "project item createdAt is not a valid timestamp".to_string(),
341 })?;
342
343 let updated_at: DateTime<Utc> = item
344 .get("updatedAt")
345 .and_then(|v| v.as_str())
346 .ok_or_else(|| ApiError::GraphQlError {
347 message: "project item missing updatedAt field".to_string(),
348 })?
349 .parse()
350 .map_err(|_| ApiError::GraphQlError {
351 message: "project item updatedAt is not a valid timestamp".to_string(),
352 })?;
353
354 let linked_content_node_id = item
356 .get("content")
357 .and_then(|c| c.get("id"))
358 .and_then(|v| v.as_str())
359 .unwrap_or(content_node_id)
360 .to_string();
361
362 Ok(ProjectV2Item {
363 id: item_id.clone(),
364 node_id: item_id,
368 content_type,
369 content_node_id: linked_content_node_id,
370 created_at,
371 updated_at,
372 })
373 }
374
375 async fn get_project_node_id(
381 &self,
382 owner: &str,
383 project_number: u64,
384 ) -> Result<String, ApiError> {
385 let variables = serde_json::json!({
386 "owner": owner,
387 "number": project_number as i64,
390 });
391
392 match self
394 .client
395 .post_graphql(GET_PROJECT_NODE_ID_ORG_QUERY, variables.clone())
396 .await
397 {
398 Ok(data) => {
399 if let Some(id) = data
400 .get("organization")
401 .and_then(|o| o.get("projectV2"))
402 .and_then(|p| p.get("id"))
403 .and_then(|v| v.as_str())
404 {
405 return Ok(id.to_string());
406 }
407 }
409 Err(ApiError::NotFound) => {
410 }
412 Err(other) => return Err(other),
413 }
414
415 let data = self
417 .client
418 .post_graphql(GET_PROJECT_NODE_ID_USER_QUERY, variables)
419 .await?;
420
421 data.get("user")
422 .and_then(|u| u.get("projectV2"))
423 .and_then(|p| p.get("id"))
424 .and_then(|v| v.as_str())
425 .map(|s| s.to_string())
426 .ok_or(ApiError::NotFound)
427 }
428
429 pub async fn list_for_issue(
449 &self,
450 owner: &str,
451 repo: &str,
452 issue_number: u64,
453 ) -> Result<Vec<ProjectV2>, ApiError> {
454 let mut all_projects = Vec::new();
455 let mut cursor: Option<String> = None;
456
457 loop {
458 let variables = serde_json::json!({
459 "owner": owner,
460 "repo": repo,
461 "number": issue_number as i64,
464 "cursor": cursor,
465 });
466
467 let data = self
468 .client
469 .post_graphql(GET_ISSUE_LINKED_PROJECTS_QUERY, variables)
470 .await?;
471
472 let issue_node = data.get("repository").and_then(|r| r.get("issue"));
473
474 if issue_node.is_none_or(|v| v.is_null()) {
478 return Err(ApiError::NotFound);
479 }
480
481 let projects_v2 = match issue_node.and_then(|i| i.get("projectsV2")) {
482 Some(pv2) => pv2,
483 None => break,
484 };
485
486 if let Some(nodes) = projects_v2.get("nodes").and_then(|n| n.as_array()) {
487 all_projects.extend(nodes.iter().filter_map(map_project_node));
488 }
489
490 let has_next_page = projects_v2
491 .get("pageInfo")
492 .and_then(|p| p.get("hasNextPage"))
493 .and_then(|v| v.as_bool())
494 .unwrap_or(false);
495
496 if !has_next_page {
497 break;
498 }
499
500 cursor = projects_v2
501 .get("pageInfo")
502 .and_then(|p| p.get("endCursor"))
503 .and_then(|v| v.as_str())
504 .map(String::from);
505 }
506
507 Ok(all_projects)
508 }
509
510 pub async fn remove_item(
514 &self,
515 _owner: &str,
516 _project_number: u64,
517 _item_id: &str,
518 ) -> Result<(), ApiError> {
519 unimplemented!("See docs/spec/interfaces/project-operations.md")
520 }
521}
522
523#[cfg(test)]
524#[path = "project_tests.rs"]
525mod tests;