1use 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#[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 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
88pub 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 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 pub fn with_write_targets(mut self, targets: BTreeMap<StateCategory, String>) -> Self {
127 self.set_targets = targets;
128 self
129 }
130
131 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 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; }
177 } else {
178 break;
179 }
180 }
181 Ok(out)
182 }
183}
184
185pub(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
205pub(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
239pub(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
267pub(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
289pub(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 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 let column = self
373 .mapping
374 .column_for(target, &self.set_targets)
375 .map_err(reverse_error)?;
376
377 let (owner, repo, number) = parse_board_uid(uid)?;
380
381 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 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
421fn 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
435fn 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 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}