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