Skip to main content

github_bot_sdk/client/
project.rs

1// GENERATED FROM: docs/spec/interfaces/project-operations.md
2// GitHub Projects v2 operations
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::client::InstallationClient;
8use crate::error::ApiError;
9
10/// GitHub Projects v2 project.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ProjectV2 {
13    /// Unique project identifier
14    pub id: u64,
15
16    /// Node ID for GraphQL API
17    pub node_id: String,
18
19    /// Project number (unique within owner)
20    pub number: u64,
21
22    /// Project title
23    pub title: String,
24
25    /// Project description
26    pub description: Option<String>,
27
28    /// Project owner (organisation or user)
29    pub owner: ProjectOwner,
30
31    /// Project visibility
32    pub public: bool,
33
34    /// Creation timestamp
35    pub created_at: DateTime<Utc>,
36
37    /// Last update timestamp
38    pub updated_at: DateTime<Utc>,
39
40    /// Project URL
41    pub url: String,
42}
43
44/// Project owner (organisation or user).
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ProjectOwner {
47    /// Owner login name
48    pub login: String,
49
50    /// Owner type
51    #[serde(rename = "type")]
52    pub owner_type: String, // "Organization" or "User"
53
54    /// Owner ID
55    pub id: u64,
56
57    /// Owner node ID
58    pub node_id: String,
59}
60
61/// Item in a GitHub Projects v2 project.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ProjectV2Item {
64    /// Unique item identifier (project-specific)
65    pub id: String,
66
67    /// Node ID for GraphQL API
68    pub node_id: String,
69
70    /// Content type
71    pub content_type: String, // "Issue" or "PullRequest"
72
73    /// Content node ID (issue or PR node ID)
74    pub content_node_id: String,
75
76    /// Creation timestamp
77    pub created_at: DateTime<Utc>,
78
79    /// Last update timestamp
80    pub updated_at: DateTime<Utc>,
81}
82
83/// Request to add an item to a project.
84#[derive(Debug, Clone, Serialize)]
85pub struct AddProjectV2ItemRequest {
86    /// Node ID of the content to add (issue or pull request)
87    pub content_node_id: String,
88}
89
90// ---------------------------------------------------------------------------
91// GraphQL query for get_issue_linked_projects
92// ---------------------------------------------------------------------------
93
94const 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
134/// Map a single GraphQL `projectsV2.nodes` JSON node to a [`ProjectV2`].
135///
136/// Returns `None` when required fields are absent or have unexpected types,
137/// which causes the node to be silently skipped rather than crashing.
138fn 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    // The query always selects `databaseId` on both Organization and User owner fragments;
160    // `unwrap_or(0)` is a defensive default for a query-guaranteed-present field.
161    let owner_id = owner_node
162        .get("databaseId")
163        .and_then(|v| v.as_u64())
164        .unwrap_or(0);
165    // Similarly, `id` (the owner's global node ID) is always selected by the query;
166    // `unwrap_or("")` is a defensive default that is not reached in practice.
167    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
192// ---------------------------------------------------------------------------
193// GraphQL queries and mutations for project item operations
194// ---------------------------------------------------------------------------
195
196const 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    // ========================================================================
235    // Project Operations
236    // ========================================================================
237
238    /// List all Projects v2 for an organisation.
239    ///
240    /// See docs/spec/interfaces/project-operations.md
241    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    /// List all Projects v2 for a user.
246    ///
247    /// See docs/spec/interfaces/project-operations.md
248    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    /// Get details about a specific project.
253    ///
254    /// See docs/spec/interfaces/project-operations.md
255    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    /// Add an issue or pull request to a project.
264    ///
265    /// Resolves the project node ID from `owner` + `project_number` (trying organisation
266    /// first, then falling back to user), then calls the `addProjectV2ItemById` GraphQL
267    /// mutation to attach the content.
268    ///
269    /// # Arguments
270    ///
271    /// * `owner`          - Organisation or user login name
272    /// * `project_number` - Project number (unique within owner)
273    /// * `content_node_id` - Node ID of the issue or pull request to add
274    ///
275    /// # Returns
276    ///
277    /// - `Ok(ProjectV2Item)` — the newly created project item
278    /// - `Err(ApiError::NotFound)` — project not found for this owner
279    /// - `Err(ApiError::AuthorizationFailed)` — no write access to the project
280    /// - `Err(ApiError)` — other transport or GraphQL errors
281    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        // content.id is the node ID of the linked issue or PR.
344        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            // GitHub Projects v2 exposes only a single `id` (the global node ID) for
354            // ProjectV2Item objects — there is no separate integer `databaseId`. Both
355            // `id` and `node_id` therefore carry the same value.
356            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    /// Resolve an owner + project number to the project's GraphQL node ID.
365    ///
366    /// Attempts an organisation query first. If the response carries a
367    /// `NOT_FOUND` error the query is retried against the user namespace.
368    /// Returns `ApiError::NotFound` when neither lookup succeeds.
369    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            // Cast to i64: GraphQL Int! is 32-bit signed; realistic project numbers
377            // are well within that range.
378            "number": project_number as i64,
379        });
380
381        // Try organisation first.
382        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                // data.organization.projectV2 was null — fall through to user lookup.
396            }
397            Err(ApiError::NotFound) => {
398                // org not found — fall through to user lookup.
399            }
400            Err(other) => return Err(other),
401        }
402
403        // Fall back to user lookup.
404        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    /// Get all Projects v2 linked to a specific issue.
417    ///
418    /// Queries the GitHub GraphQL API for all Projects v2 that contain the given issue.
419    /// Returns an empty `Vec` when the issue exists but is not linked to any projects.
420    /// Results are fetched in pages of 20; all pages are retrieved automatically and
421    /// returned as a single combined `Vec`.
422    ///
423    /// # Arguments
424    ///
425    /// * `owner` - Repository owner (organisation or user login)
426    /// * `repo`  - Repository name
427    /// * `issue_number` - Issue number
428    ///
429    /// # Returns
430    ///
431    /// - `Ok(Vec<ProjectV2>)` — all projects linked to the issue (may be empty)
432    /// - `Err(ApiError::NotFound)` — repository or issue does not exist
433    /// - `Err(ApiError::AuthenticationFailed)` — token is invalid
434    /// - `Err(ApiError)` — other transport or GraphQL errors
435    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                // Cast to i64: GraphQL Int! is 32-bit signed; realistic issue numbers
449                // are well within that range.
450                "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            // GitHub returns `"issue": null` (not a GraphQL error) when the issue
461            // number does not exist in the repository. Surface this as NotFound so
462            // callers can distinguish it from "issue exists with no projects".
463            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    /// Remove an item from a project.
497    ///
498    /// See docs/spec/interfaces/project-operations.md
499    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;