Skip to main content

gonzalo_ticket_github/
project_source.rs

1//! A [`TicketSource`] over the GitHub **GraphQL** API, reading the org Projects
2//! v2 board. `get`/`fetch_changed` are the read path: the board has no reliable
3//! "changed since" filter, so `fetch_changed` pages the whole board to
4//! completion and returns an empty `next` cursor; cheap re-sync is the ingest
5//! engine's job (content-hash dedup).
6//!
7//! Write-back: `capabilities().push` is `true` and `set_state` moves a card by
8//! resolving the target category back to a board column (via [`StateMapping`],
9//! with per-source overrides from [`GitHubProjectSource::with_write_targets`])
10//! and updating the project's Status single-select field on that issue's item.
11
12use crate::project_mapping::{GqlItems, GqlResponse, item_to_ticket};
13use async_trait::async_trait;
14use gonzalo_domain::{StateCategory, Ticket};
15use gonzalo_ticket::{Capabilities, Cursor, Page, Result, SourceError, StateMapping, TicketSource};
16use serde::Deserialize;
17use std::collections::BTreeMap;
18
19const GRAPHQL_URL: &str = "https://api.github.com/graphql";
20
21/// Resolved coordinates for a single Projects v2 card write.
22#[derive(Debug, PartialEq, Eq)]
23pub(crate) struct ItemCoords {
24    pub item_id: String,
25    pub project_id: String,
26    pub field_id: String,
27    pub option_id: String,
28}
29
30#[derive(Debug, Deserialize)]
31pub(crate) struct ItemLookupResponse {
32    #[serde(default)]
33    pub data: Option<ItemLookupData>,
34    #[serde(default)]
35    pub errors: Vec<crate::project_mapping::GqlError>,
36}
37
38#[derive(Debug, Deserialize)]
39pub(crate) struct ItemLookupData {
40    pub repository: Option<LookupRepo>,
41}
42
43#[derive(Debug, Deserialize)]
44pub(crate) struct LookupRepo {
45    pub issue: Option<LookupIssue>,
46}
47
48#[derive(Debug, Deserialize)]
49pub(crate) struct LookupIssue {
50    #[serde(rename = "projectItems")]
51    pub project_items: LookupItems,
52}
53
54#[derive(Debug, Deserialize)]
55pub(crate) struct LookupItems {
56    pub nodes: Vec<LookupItem>,
57}
58
59#[derive(Debug, Deserialize)]
60pub(crate) struct LookupItem {
61    pub id: String,
62    pub project: LookupProject,
63}
64
65#[derive(Debug, Deserialize)]
66pub(crate) struct LookupProject {
67    pub id: String,
68    pub number: u32,
69    /// The project's Status single-select field (id + options), read from the
70    /// project definition so it is present even when the card has no status set.
71    /// `None` if the project has no such field.
72    pub field: Option<LookupField>,
73}
74
75#[derive(Debug, Deserialize)]
76pub(crate) struct LookupField {
77    pub id: String,
78    pub options: Vec<LookupOption>,
79}
80
81#[derive(Debug, Deserialize)]
82pub(crate) struct LookupOption {
83    pub id: String,
84    pub name: String,
85}
86
87/// Reads issues on an org's Projects v2 board, with their Status column mapped
88/// to a normalized state category via [`StateMapping`].
89pub struct GitHubProjectSource {
90    client: reqwest::Client,
91    endpoint: String,
92    org: String,
93    project_number: u32,
94    token: String,
95    mapping: StateMapping,
96    set_targets: BTreeMap<StateCategory, String>,
97}
98
99impl GitHubProjectSource {
100    /// Create a board source for `org` / project `number`, authenticating with
101    /// `token`, resolving Status via `mapping`.
102    pub fn new(
103        org: impl Into<String>,
104        number: u32,
105        token: impl Into<String>,
106        mapping: StateMapping,
107    ) -> Result<Self> {
108        let client = reqwest::Client::builder()
109            .user_agent("gonzalo-ticket-github")
110            .build()
111            .map_err(be)?;
112        Ok(Self {
113            client,
114            endpoint: GRAPHQL_URL.to_string(),
115            org: org.into(),
116            project_number: number,
117            token: token.into(),
118            mapping,
119            set_targets: BTreeMap::new(),
120        })
121    }
122
123    /// Set the category→column overrides used by `set_state` for boards where
124    /// two columns share a category (the reverse of `state_map` is ambiguous).
125    pub fn with_write_targets(mut self, targets: BTreeMap<StateCategory, String>) -> Self {
126        self.set_targets = targets;
127        self
128    }
129
130    /// POST a GraphQL body and return the parsed JSON, surfacing transport
131    /// errors as `Backend`.
132    async fn post(&self, body: &serde_json::Value) -> Result<serde_json::Value> {
133        self.client
134            .post(&self.endpoint)
135            .bearer_auth(&self.token)
136            .json(body)
137            .send()
138            .await
139            .map_err(be)?
140            .error_for_status()
141            .map_err(be)?
142            .json()
143            .await
144            .map_err(be)
145    }
146
147    /// Page the whole board into a flat list of tickets.
148    async fn fetch_all(&self) -> Result<Vec<Ticket>> {
149        let mut out = Vec::new();
150        let mut after: Option<String> = None;
151        loop {
152            let body = graphql_body(&self.org, self.project_number, after.as_deref());
153            let resp = self
154                .client
155                .post(&self.endpoint)
156                .bearer_auth(&self.token)
157                .json(&body)
158                .send()
159                .await
160                .map_err(be)?
161                .error_for_status()
162                .map_err(be)?;
163            let parsed: GqlResponse = resp.json().await.map_err(be)?;
164            let items = items_or_error(parsed)?;
165            out.extend(
166                items
167                    .nodes
168                    .iter()
169                    .filter_map(|n| item_to_ticket(n, &self.mapping)),
170            );
171            if items.page_info.has_next_page {
172                after = items.page_info.end_cursor;
173                if after.is_none() {
174                    break; // defensive: hasNextPage but no cursor
175                }
176            } else {
177                break;
178            }
179        }
180        Ok(out)
181    }
182}
183
184/// Pull the items page out of a parsed GraphQL response, surfacing any
185/// top-level GraphQL `errors` (GitHub returns these with HTTP 200 for bad
186/// tokens, unknown orgs, or malformed queries) as a `Backend` error rather
187/// than letting a `null` `data` become an opaque deserialize failure.
188pub(crate) fn items_or_error(parsed: GqlResponse) -> Result<GqlItems> {
189    if !parsed.errors.is_empty() {
190        let msg = parsed
191            .errors
192            .iter()
193            .map(|e| e.message.as_str())
194            .collect::<Vec<_>>()
195            .join("; ");
196        return Err(SourceError::Backend(format!("github graphql: {msg}")));
197    }
198    let data = parsed
199        .data
200        .ok_or_else(|| SourceError::Backend("github graphql: response had no data".into()))?;
201    Ok(data.organization.project.items)
202}
203
204/// Build the GraphQL request body (query + variables). Pure, so it is unit-
205/// testable without a network.
206pub(crate) fn graphql_body(org: &str, number: u32, after: Option<&str>) -> serde_json::Value {
207    const QUERY: &str = r#"
208query($org: String!, $number: Int!, $cursor: String) {
209  organization(login: $org) {
210    projectV2(number: $number) {
211      items(first: 100, after: $cursor) {
212        pageInfo { hasNextPage endCursor }
213        nodes {
214          fieldValueByName(name: "Status") {
215            ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
216          }
217          content {
218            __typename
219            ... on Issue {
220              number title body
221              repository { nameWithOwner }
222              labels(first: 20) { nodes { name } }
223              assignees(first: 10) { nodes { login } }
224              author { login }
225            }
226          }
227        }
228      }
229    }
230  }
231}"#;
232    serde_json::json!({
233        "query": QUERY,
234        "variables": { "org": org, "number": number, "cursor": after },
235    })
236}
237
238/// GraphQL body that finds an issue's board item across its projects, with each
239/// project's Status single-select field id + options. Pure / network-free.
240pub(crate) fn project_item_query(owner: &str, repo: &str, number: u64) -> serde_json::Value {
241    const QUERY: &str = r#"
242query($owner: String!, $repo: String!, $number: Int!) {
243  repository(owner: $owner, name: $repo) {
244    issue(number: $number) {
245      projectItems(first: 20) {
246        nodes {
247          id
248          project {
249            id
250            number
251            field(name: "Status") {
252              ... on ProjectV2SingleSelectField { id options { id name } }
253            }
254          }
255        }
256      }
257    }
258  }
259}"#;
260    serde_json::json!({
261        "query": QUERY,
262        "variables": { "owner": owner, "repo": repo, "number": number },
263    })
264}
265
266/// GraphQL body that sets a card's single-select Status option. Pure.
267pub(crate) fn set_option_mutation(
268    project_id: &str,
269    item_id: &str,
270    field_id: &str,
271    option_id: &str,
272) -> serde_json::Value {
273    const QUERY: &str = r#"
274mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
275  updateProjectV2ItemFieldValue(input: {
276    projectId: $project, itemId: $item, fieldId: $field,
277    value: { singleSelectOptionId: $option }
278  }) { projectV2Item { id } }
279}"#;
280    serde_json::json!({
281        "query": QUERY,
282        "variables": {
283            "project": project_id, "item": item_id, "field": field_id, "option": option_id
284        },
285    })
286}
287
288/// Pick the issue's item on the project numbered `project_number`, then resolve
289/// `column` to its single-select option id (case-insensitive). Surfaces GraphQL
290/// `errors` and missing pieces as `Backend`.
291pub(crate) fn resolve_item(
292    resp: ItemLookupResponse,
293    project_number: u32,
294    column: &str,
295) -> Result<ItemCoords> {
296    if !resp.errors.is_empty() {
297        let msg = resp
298            .errors
299            .iter()
300            .map(|e| e.message.as_str())
301            .collect::<Vec<_>>()
302            .join("; ");
303        return Err(SourceError::Backend(format!("github graphql: {msg}")));
304    }
305    let nodes = resp
306        .data
307        .and_then(|d| d.repository)
308        .and_then(|r| r.issue)
309        .map(|i| i.project_items.nodes)
310        .ok_or_else(|| SourceError::Backend("issue not found".into()))?;
311    let item = nodes
312        .into_iter()
313        .find(|n| n.project.number == project_number)
314        .ok_or_else(|| {
315            SourceError::Backend(format!("issue is not on project #{project_number}"))
316        })?;
317    let field = item
318        .project
319        .field
320        .ok_or_else(|| SourceError::Backend("project has no Status single-select field".into()))?;
321    let option = field
322        .options
323        .iter()
324        .find(|o| o.name.eq_ignore_ascii_case(column))
325        .ok_or_else(|| {
326            SourceError::Backend(format!(
327                "no Status option named {column:?} on project #{project_number}"
328            ))
329        })?;
330    Ok(ItemCoords {
331        item_id: item.id,
332        project_id: item.project.id,
333        field_id: field.id,
334        option_id: option.id.clone(),
335    })
336}
337
338#[async_trait]
339impl TicketSource for GitHubProjectSource {
340    fn capabilities(&self) -> Capabilities {
341        Capabilities {
342            push: true,
343            ..Capabilities::default()
344        }
345    }
346
347    async fn fetch_changed(&self, _cursor: &Cursor) -> Result<Page> {
348        let tickets = self.fetch_all().await?;
349        Ok(Page {
350            tickets,
351            next: Cursor::default(),
352        })
353    }
354
355    /// Find a ticket by uid. Note: this scans the **whole board** (every page)
356    /// on each call — intended for occasional single lookups on a small board,
357    /// not for calling in a loop. Bulk consumers should use `fetch_changed`.
358    async fn get(&self, uid: &str) -> Result<Ticket> {
359        self.fetch_all()
360            .await?
361            .into_iter()
362            .find(|t| t.uid == uid)
363            .ok_or_else(|| SourceError::Backend(format!("ticket {uid} not found on board")))
364    }
365
366    async fn set_state(&self, uid: &str, target: StateCategory) -> Result<()> {
367        // Resolve the target category back to a board column. An unmapped
368        // category is a genuine capability gap (`Unsupported`); an ambiguous one
369        // is a fixable misconfiguration, so surface the conflicting columns
370        // (`Backend`) to tell the operator what to put in `set_targets`.
371        let column = self
372            .mapping
373            .column_for(target, &self.set_targets)
374            .map_err(reverse_error)?;
375
376        // uid (owner/repo#number) → parts. `number` here is the ISSUE number
377        // (u64); `self.project_number` is the PROJECT number (u32).
378        let (owner, repo, number) = parse_board_uid(uid)?;
379
380        // Look up the issue's item on this project + the column's option id.
381        let lookup: ItemLookupResponse = serde_json::from_value(
382            self.post(&project_item_query(&owner, &repo, number))
383                .await?,
384        )
385        .map_err(be)?;
386        let coords = resolve_item(lookup, self.project_number, &column)?;
387
388        // Mutate; check the mutation response for top-level GraphQL errors.
389        let resp = self
390            .post(&set_option_mutation(
391                &coords.project_id,
392                &coords.item_id,
393                &coords.field_id,
394                &coords.option_id,
395            ))
396            .await?;
397        if let Some(errors) = resp.get("errors").and_then(|e| e.as_array())
398            && !errors.is_empty()
399        {
400            let msg = errors
401                .iter()
402                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
403                .collect::<Vec<_>>()
404                .join("; ");
405            let msg = if msg.is_empty() {
406                format!("{} error(s) with no message", errors.len())
407            } else {
408                msg
409            };
410            return Err(SourceError::Backend(format!("github graphql: {msg}")));
411        }
412        Ok(())
413    }
414}
415
416fn be<E: std::fmt::Display>(e: E) -> SourceError {
417    SourceError::Backend(e.to_string())
418}
419
420/// Map a reverse-mapping failure to a `SourceError`. `Unmapped` is a capability
421/// gap (no column expresses the category) → `Unsupported`; `Ambiguous` is a
422/// fixable misconfiguration, so carry the conflicting column names → `Backend`.
423fn reverse_error(e: gonzalo_ticket::ReverseError) -> SourceError {
424    match e {
425        gonzalo_ticket::ReverseError::Unmapped(_) => {
426            SourceError::Unsupported("set_state: no column maps to target category")
427        }
428        gonzalo_ticket::ReverseError::Ambiguous(cat, cols) => SourceError::Backend(format!(
429            "set_state: category {cat:?} maps to multiple columns {cols:?}; configure set_targets"
430        )),
431    }
432}
433
434/// Parse a board uid `owner/repo#number` into its parts.
435fn parse_board_uid(uid: &str) -> Result<(String, String, u64)> {
436    let (repo_path, num) = uid
437        .rsplit_once('#')
438        .ok_or_else(|| SourceError::Backend(format!("expected owner/repo#number, got {uid}")))?;
439    let (owner, repo) = repo_path
440        .split_once('/')
441        .ok_or_else(|| SourceError::Backend(format!("expected owner/repo#number, got {uid}")))?;
442    let number = num
443        .parse::<u64>()
444        .map_err(|_| SourceError::Backend(format!("bad issue number in uid {uid}")))?;
445    Ok((owner.to_string(), repo.to_string(), number))
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn mapping() -> StateMapping {
453        StateMapping {
454            signal: gonzalo_ticket::StateSignal::NativeStatus,
455            by_value: BTreeMap::new(),
456            default: StateCategory::Open,
457        }
458    }
459
460    #[test]
461    fn graphql_body_carries_org_number_and_cursor() {
462        let b = graphql_body("caliban-ai", 1, Some("CUR"));
463        assert_eq!(b["variables"]["org"], "caliban-ai");
464        assert_eq!(b["variables"]["number"], 1);
465        assert_eq!(b["variables"]["cursor"], "CUR");
466        assert!(b["query"].as_str().unwrap().contains("projectV2"));
467    }
468
469    #[test]
470    fn null_cursor_serializes_for_first_page() {
471        let b = graphql_body("caliban-ai", 1, None);
472        assert!(b["variables"]["cursor"].is_null());
473    }
474
475    #[test]
476    fn constructs_a_source_from_org_and_number() {
477        let src = GitHubProjectSource::new("caliban-ai", 1, "tok", mapping()).unwrap();
478        assert_eq!(src.org, "caliban-ai");
479        assert_eq!(src.project_number, 1);
480        assert!(src.set_targets.is_empty());
481    }
482
483    #[test]
484    fn graphql_errors_surface_as_backend() {
485        let body = r#"{"data": null, "errors": [{"message": "Bad credentials"}]}"#;
486        let parsed: crate::project_mapping::GqlResponse = serde_json::from_str(body).unwrap();
487        let err = items_or_error(parsed).unwrap_err();
488        match err {
489            gonzalo_ticket::SourceError::Backend(m) => assert!(m.contains("Bad credentials")),
490            other => panic!("expected Backend, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn missing_data_without_errors_is_backend() {
496        let body = r#"{"data": null}"#;
497        let parsed: crate::project_mapping::GqlResponse = serde_json::from_str(body).unwrap();
498        assert!(items_or_error(parsed).is_err());
499    }
500
501    #[test]
502    fn project_item_query_carries_owner_repo_number() {
503        let b = project_item_query("caliban-ai", "gonzalo", 19);
504        assert_eq!(b["variables"]["owner"], "caliban-ai");
505        assert_eq!(b["variables"]["repo"], "gonzalo");
506        assert_eq!(b["variables"]["number"], 19);
507        assert!(b["query"].as_str().unwrap().contains("projectItems"));
508    }
509
510    #[test]
511    fn set_option_mutation_carries_four_ids() {
512        let b = set_option_mutation("PROJ", "ITEM", "FIELD", "OPT");
513        assert_eq!(b["variables"]["project"], "PROJ");
514        assert_eq!(b["variables"]["item"], "ITEM");
515        assert_eq!(b["variables"]["field"], "FIELD");
516        assert_eq!(b["variables"]["option"], "OPT");
517        assert!(
518            b["query"]
519                .as_str()
520                .unwrap()
521                .contains("updateProjectV2ItemFieldValue")
522        );
523    }
524
525    const ITEM_LOOKUP: &str = r#"{
526      "data": { "repository": { "issue": { "projectItems": { "nodes": [
527        {
528          "id": "ITEM_OTHER",
529          "project": {
530            "id": "PROJ_OTHER", "number": 7,
531            "field": { "id": "F_OTHER", "options": [{ "id": "o1", "name": "Done" }] }
532          }
533        },
534        {
535          "id": "ITEM_1",
536          "project": {
537            "id": "PROJ_1", "number": 1,
538            "field": { "id": "FIELD_1", "options": [
539              { "id": "opt_todo", "name": "Todo" },
540              { "id": "opt_ip", "name": "In progress" },
541              { "id": "opt_done", "name": "Done" }
542            ] }
543          }
544        }
545      ] } } } }
546    }"#;
547
548    #[test]
549    fn resolve_item_picks_target_project_and_option() {
550        let resp: ItemLookupResponse = serde_json::from_str(ITEM_LOOKUP).unwrap();
551        let r = resolve_item(resp, 1, "In progress").unwrap();
552        assert_eq!(r.item_id, "ITEM_1");
553        assert_eq!(r.project_id, "PROJ_1");
554        assert_eq!(r.field_id, "FIELD_1");
555        assert_eq!(r.option_id, "opt_ip");
556    }
557
558    #[test]
559    fn resolve_item_matches_option_case_insensitively() {
560        let resp: ItemLookupResponse = serde_json::from_str(ITEM_LOOKUP).unwrap();
561        let r = resolve_item(resp, 1, "in PROGRESS").unwrap();
562        assert_eq!(r.option_id, "opt_ip");
563    }
564
565    #[test]
566    fn resolve_item_errors_when_issue_not_on_board() {
567        let resp: ItemLookupResponse = serde_json::from_str(ITEM_LOOKUP).unwrap();
568        assert!(resolve_item(resp, 99, "Done").is_err());
569    }
570
571    #[test]
572    fn resolve_item_errors_on_unknown_column() {
573        let resp: ItemLookupResponse = serde_json::from_str(ITEM_LOOKUP).unwrap();
574        assert!(resolve_item(resp, 1, "Nonexistent").is_err());
575    }
576
577    #[test]
578    fn board_source_advertises_push_capability() {
579        let src = GitHubProjectSource::new("caliban-ai", 1, "tok", mapping()).unwrap();
580        assert!(src.capabilities().push);
581        assert!(!src.capabilities().comments);
582    }
583
584    #[tokio::test]
585    async fn set_state_rejects_category_with_no_column() {
586        // mapping() has an empty by_value, so no column maps to Done → Unsupported.
587        let src = GitHubProjectSource::new("caliban-ai", 1, "tok", mapping()).unwrap();
588        let err = src
589            .set_state("caliban-ai/gonzalo#1", StateCategory::Done)
590            .await
591            .unwrap_err();
592        assert!(matches!(err, SourceError::Unsupported(_)), "got {err:?}");
593    }
594
595    #[test]
596    fn with_write_targets_stores_overrides() {
597        let mut t = BTreeMap::new();
598        t.insert(StateCategory::Done, "Shipped".to_string());
599        let src = GitHubProjectSource::new("caliban-ai", 1, "tok", mapping())
600            .unwrap()
601            .with_write_targets(t);
602        assert_eq!(
603            src.set_targets
604                .get(&StateCategory::Done)
605                .map(String::as_str),
606            Some("Shipped")
607        );
608    }
609}