github-bot-sdk 0.2.1

A comprehensive Rust SDK for GitHub App integration with authentication, webhooks, and API client
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
// Spec: docs/specs/interfaces/project-operations.md
// GitHub Projects v2 operations

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::client::InstallationClient;
use crate::error::ApiError;

/// GitHub Projects v2 project.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectV2 {
    /// Unique project identifier
    pub id: u64,

    /// Node ID for GraphQL API
    pub node_id: String,

    /// Project number (unique within owner)
    pub number: u64,

    /// Project title
    pub title: String,

    /// Project description
    pub description: Option<String>,

    /// Project owner (organisation or user)
    pub owner: ProjectOwner,

    /// Project visibility
    pub public: bool,

    /// Creation timestamp
    pub created_at: DateTime<Utc>,

    /// Last update timestamp
    pub updated_at: DateTime<Utc>,

    /// Project URL
    pub url: String,
}

/// Project owner (organisation or user).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectOwner {
    /// Owner login name
    pub login: String,

    /// Owner type
    #[serde(rename = "type")]
    pub owner_type: String, // "Organization" or "User"

    /// Owner ID
    pub id: u64,

    /// Owner node ID
    pub node_id: String,
}

/// Item in a GitHub Projects v2 project.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectV2Item {
    /// Unique item identifier (project-specific)
    pub id: String,

    /// Node ID for GraphQL API
    pub node_id: String,

    /// Content type
    pub content_type: String, // "Issue" or "PullRequest"

    /// Content node ID (issue or PR node ID)
    pub content_node_id: String,

    /// Creation timestamp
    pub created_at: DateTime<Utc>,

    /// Last update timestamp
    pub updated_at: DateTime<Utc>,
}

/// Request to add an item to a project.
#[derive(Debug, Clone, Serialize)]
pub struct AddProjectV2ItemRequest {
    /// Node ID of the content to add (issue or pull request)
    pub content_node_id: String,
}

// ---------------------------------------------------------------------------
// GraphQL query for get_issue_linked_projects
// ---------------------------------------------------------------------------

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
            }
          }
        }
      }
    }
  }
}
"#;

/// Map a single GraphQL `projectsV2.nodes` JSON node to a [`ProjectV2`].
///
/// Returns `None` when required fields are absent or have unexpected types,
/// which causes the node to be silently skipped rather than crashing.
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();
    // The query always selects `databaseId` on both Organization and User owner fragments;
    // `unwrap_or(0)` is a defensive default for a query-guaranteed-present field.
    let owner_id = owner_node
        .get("databaseId")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    // Similarly, `id` (the owner's global node ID) is always selected by the query;
    // `unwrap_or("")` is a defensive default that is not reached in practice.
    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,
    })
}

// ---------------------------------------------------------------------------
// GraphQL queries and mutations for project item operations
// ---------------------------------------------------------------------------

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 }
      }
    }
  }
}
"#;

/// Domain client for GitHub Projects V2 operations.
///
/// Obtained via [`InstallationClient::projects()`]. Cheap to clone (Arc-backed).
///
/// See docs/specs/interfaces/project-operations.md
#[derive(Debug, Clone)]
pub struct ProjectsClient {
    pub(crate) client: InstallationClient,
}

impl ProjectsClient {
    pub(crate) fn new(client: InstallationClient) -> Self {
        Self { client }
    }

    // ========================================================================
    // Project Operations
    // ========================================================================

    /// List all Projects v2 for an organisation.
    ///
    /// See docs/specs/interfaces/project-operations.md
    pub async fn list_for_org(&self, _org: &str) -> Result<Vec<ProjectV2>, ApiError> {
        unimplemented!("See docs/specs/interfaces/project-operations.md")
    }

    /// List all Projects v2 for a user.
    ///
    /// 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")
    }

    /// Get details about a specific project.
    ///
    /// See docs/spec/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")
    }

    /// Add an issue or pull request to a project.
    ///
    /// Resolves the project node ID from `owner` + `project_number` (trying organisation
    /// first, then falling back to user), then calls the `addProjectV2ItemById` GraphQL
    /// mutation to attach the content.
    ///
    /// # Arguments
    ///
    /// * `owner`          - Organisation or user login name
    /// * `project_number` - Project number (unique within owner)
    /// * `content_node_id` - Node ID of the issue or pull request to add
    ///
    /// # Returns
    ///
    /// - `Ok(ProjectV2Item)` — the newly created project item
    /// - `Err(ApiError::NotFound)` — project not found for this owner
    /// - `Err(ApiError::AuthorizationFailed)` — no write access to the project
    /// - `Err(ApiError)` — other transport or GraphQL errors
    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(),
            })?;

        // content.id is the node ID of the linked issue or PR.
        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(),
            // GitHub Projects v2 exposes only a single `id` (the global node ID) for
            // ProjectV2Item objects — there is no separate integer `databaseId`. Both
            // `id` and `node_id` therefore carry the same value.
            node_id: item_id,
            content_type,
            content_node_id: linked_content_node_id,
            created_at,
            updated_at,
        })
    }

    /// Resolve an owner + project number to the project's GraphQL node ID.
    ///
    /// Attempts an organisation query first. If the response carries a
    /// `NOT_FOUND` error the query is retried against the user namespace.
    /// Returns `ApiError::NotFound` when neither lookup succeeds.
    async fn get_project_node_id(
        &self,
        owner: &str,
        project_number: u64,
    ) -> Result<String, ApiError> {
        let variables = serde_json::json!({
            "owner": owner,
            // Cast to i64: GraphQL Int! is 32-bit signed; realistic project numbers
            // are well within that range.
            "number": project_number as i64,
        });

        // Try organisation first.
        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());
                }
                // data.organization.projectV2 was null — fall through to user lookup.
            }
            Err(ApiError::NotFound) => {
                // org not found — fall through to user lookup.
            }
            Err(other) => return Err(other),
        }

        // Fall back to user lookup.
        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)
    }

    /// Get all Projects v2 linked to a specific issue.
    ///
    /// Queries the GitHub GraphQL API for all Projects v2 that contain the given issue.
    /// Returns an empty `Vec` when the issue exists but is not linked to any projects.
    /// Results are fetched in pages of 20; all pages are retrieved automatically and
    /// returned as a single combined `Vec`.
    ///
    /// # Arguments
    ///
    /// * `owner` - Repository owner (organisation or user login)
    /// * `repo`  - Repository name
    /// * `issue_number` - Issue number
    ///
    /// # Returns
    ///
    /// - `Ok(Vec<ProjectV2>)` — all projects linked to the issue (may be empty)
    /// - `Err(ApiError::NotFound)` — repository or issue does not exist
    /// - `Err(ApiError::AuthenticationFailed)` — token is invalid
    /// - `Err(ApiError)` — other transport or GraphQL errors
    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,
                // Cast to i64: GraphQL Int! is 32-bit signed; realistic issue numbers
                // are well within that range.
                "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"));

            // GitHub returns `"issue": null` (not a GraphQL error) when the issue
            // number does not exist in the repository. Surface this as NotFound so
            // callers can distinguish it from "issue exists with no projects".
            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)
    }

    /// Remove an item from a project.
    ///
    /// See docs/spec/interfaces/project-operations.md
    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;