1use 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#[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 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
87pub 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 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 pub fn with_write_targets(mut self, targets: BTreeMap<StateCategory, String>) -> Self {
126 self.set_targets = targets;
127 self
128 }
129
130 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 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; }
176 } else {
177 break;
178 }
179 }
180 Ok(out)
181 }
182}
183
184pub(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
204pub(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
238pub(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
266pub(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
288pub(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 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 let column = self
372 .mapping
373 .column_for(target, &self.set_targets)
374 .map_err(reverse_error)?;
375
376 let (owner, repo, number) = parse_board_uid(uid)?;
379
380 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 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
420fn 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
434fn 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 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}