Skip to main content

github_bot_sdk/client/
project.rs

1// Spec: docs/specs/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          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
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("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    // 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
233/// Domain client for GitHub Projects V2 operations.
234///
235/// Obtained via [`InstallationClient::projects()`]. Cheap to clone (Arc-backed).
236///
237/// See docs/specs/interfaces/project-operations.md
238#[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    // ========================================================================
249    // Project Operations
250    // ========================================================================
251
252    /// List all Projects v2 for an organisation.
253    ///
254    /// See docs/specs/interfaces/project-operations.md
255    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    /// List all Projects v2 for a user.
260    ///
261    /// See docs/specs/interfaces/project-operations.md
262    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    /// Get details about a specific project.
267    ///
268    /// See docs/spec/interfaces/project-operations.md
269    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    /// Add an issue or pull request to a project.
274    ///
275    /// Resolves the project node ID from `owner` + `project_number` (trying organisation
276    /// first, then falling back to user), then calls the `addProjectV2ItemById` GraphQL
277    /// mutation to attach the content.
278    ///
279    /// # Arguments
280    ///
281    /// * `owner`          - Organisation or user login name
282    /// * `project_number` - Project number (unique within owner)
283    /// * `content_node_id` - Node ID of the issue or pull request to add
284    ///
285    /// # Returns
286    ///
287    /// - `Ok(ProjectV2Item)` — the newly created project item
288    /// - `Err(ApiError::NotFound)` — project not found for this owner
289    /// - `Err(ApiError::AuthorizationFailed)` — no write access to the project
290    /// - `Err(ApiError)` — other transport or GraphQL errors
291    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        // content.id is the node ID of the linked issue or PR.
355        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            // GitHub Projects v2 exposes only a single `id` (the global node ID) for
365            // ProjectV2Item objects — there is no separate integer `databaseId`. Both
366            // `id` and `node_id` therefore carry the same value.
367            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    /// Resolve an owner + project number to the project's GraphQL node ID.
376    ///
377    /// Attempts an organisation query first. If the response carries a
378    /// `NOT_FOUND` error the query is retried against the user namespace.
379    /// Returns `ApiError::NotFound` when neither lookup succeeds.
380    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            // Cast to i64: GraphQL Int! is 32-bit signed; realistic project numbers
388            // are well within that range.
389            "number": project_number as i64,
390        });
391
392        // Try organisation first.
393        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                // data.organization.projectV2 was null — fall through to user lookup.
408            }
409            Err(ApiError::NotFound) => {
410                // org not found — fall through to user lookup.
411            }
412            Err(other) => return Err(other),
413        }
414
415        // Fall back to user lookup.
416        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    /// Get all Projects v2 linked to a specific issue.
430    ///
431    /// Queries the GitHub GraphQL API for all Projects v2 that contain the given issue.
432    /// Returns an empty `Vec` when the issue exists but is not linked to any projects.
433    /// Results are fetched in pages of 20; all pages are retrieved automatically and
434    /// returned as a single combined `Vec`.
435    ///
436    /// # Arguments
437    ///
438    /// * `owner` - Repository owner (organisation or user login)
439    /// * `repo`  - Repository name
440    /// * `issue_number` - Issue number
441    ///
442    /// # Returns
443    ///
444    /// - `Ok(Vec<ProjectV2>)` — all projects linked to the issue (may be empty)
445    /// - `Err(ApiError::NotFound)` — repository or issue does not exist
446    /// - `Err(ApiError::AuthenticationFailed)` — token is invalid
447    /// - `Err(ApiError)` — other transport or GraphQL errors
448    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                // Cast to i64: GraphQL Int! is 32-bit signed; realistic issue numbers
462                // are well within that range.
463                "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            // GitHub returns `"issue": null` (not a GraphQL error) when the issue
475            // number does not exist in the repository. Surface this as NotFound so
476            // callers can distinguish it from "issue exists with no projects".
477            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    /// Remove an item from a project.
511    ///
512    /// See docs/spec/interfaces/project-operations.md
513    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;