1#![doc = include_str!("../README.md")]
2
3pub struct DeriveConfig {
9 pub token: String,
11 pub api_url: String,
13 pub include_ci: bool,
15 pub include_comments: bool,
17}
18
19impl Default for DeriveConfig {
20 fn default() -> Self {
21 Self {
22 token: String::new(),
23 api_url: "https://api.github.com".to_string(),
24 include_ci: true,
25 include_comments: true,
26 }
27 }
28}
29
30#[derive(Debug, Clone)]
32pub struct PullRequestInfo {
33 pub number: u64,
35 pub title: String,
37 pub state: String,
39 pub author: String,
41 pub head_branch: String,
43 pub base_branch: String,
45 pub created_at: String,
47 pub updated_at: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct PrUrl {
58 pub owner: String,
60 pub repo: String,
62 pub number: u64,
64}
65
66pub fn parse_pr_url(url: &str) -> Option<PrUrl> {
89 let rest = url
90 .strip_prefix("https://github.com/")
91 .or_else(|| url.strip_prefix("http://github.com/"))
92 .or_else(|| url.strip_prefix("github.com/"))?;
93 let parts: Vec<&str> = rest.splitn(4, '/').collect();
94 if parts.len() >= 4 && parts[2] == "pull" {
95 let number = parts[3].split(&['/', '?', '#'][..]).next()?.parse().ok()?;
96 Some(PrUrl {
97 owner: parts[0].to_string(),
98 repo: parts[1].to_string(),
99 number,
100 })
101 } else {
102 None
103 }
104}
105
106pub fn extract_issue_refs(body: &str) -> Vec<u64> {
119 let mut refs = Vec::new();
120 let lower = body.to_lowercase();
121 for keyword in &["fixes", "closes", "resolves"] {
122 let mut search_from = 0;
123 while let Some(pos) = lower[search_from..].find(keyword) {
124 let after = search_from + pos + keyword.len();
125 let rest = &body[after..];
127 let rest = rest.trim_start();
128 if let Some(rest) = rest.strip_prefix('#') {
129 let num_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
130 if let Ok(n) = num_str.parse::<u64>()
131 && !refs.contains(&n)
132 {
133 refs.push(n);
134 }
135 }
136 search_from = after;
137 }
138 }
139 refs
140}
141
142#[cfg(not(target_os = "emscripten"))]
147mod native {
148 use anyhow::{Context, Result, bail};
149 use std::collections::HashMap;
150 use toolpath::v1::{
151 ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Ref, Step,
152 StepIdentity, StepMeta, StructuralChange,
153 };
154
155 use super::{DeriveConfig, PullRequestInfo, extract_issue_refs};
156
157 pub fn resolve_token() -> Result<String> {
166 if let Ok(token) = std::env::var("GITHUB_TOKEN")
167 && !token.is_empty()
168 {
169 return Ok(token);
170 }
171
172 let output = std::process::Command::new("gh")
173 .args(["auth", "token"])
174 .output()
175 .context(
176 "Failed to run 'gh auth token'. Set GITHUB_TOKEN or install the GitHub CLI (gh).",
177 )?;
178
179 if output.status.success() {
180 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
181 if !token.is_empty() {
182 return Ok(token);
183 }
184 }
185
186 bail!(
187 "No GitHub token found. Set GITHUB_TOKEN environment variable \
188 or authenticate with 'gh auth login'."
189 )
190 }
191
192 struct GitHubClient {
197 client: reqwest::blocking::Client,
198 token: String,
199 base_url: String,
200 }
201
202 impl GitHubClient {
203 fn new(config: &DeriveConfig) -> Result<Self> {
204 let client = reqwest::blocking::Client::builder()
205 .user_agent("toolpath-github")
206 .build()
207 .context("Failed to build HTTP client")?;
208
209 Ok(Self {
210 client,
211 token: config.token.clone(),
212 base_url: config.api_url.clone(),
213 })
214 }
215
216 fn get_json(&self, endpoint: &str) -> Result<serde_json::Value> {
217 let url = format!("{}{}", self.base_url, endpoint);
218 let resp = self
219 .client
220 .get(&url)
221 .header("Authorization", format!("Bearer {}", self.token))
222 .header("Accept", "application/vnd.github+json")
223 .header("X-GitHub-Api-Version", "2022-11-28")
224 .send()
225 .with_context(|| format!("Request failed: GET {}", url))?;
226
227 let status = resp.status();
228 if !status.is_success() {
229 let body = resp.text().unwrap_or_default();
230 bail!("GitHub API error {}: {}", status, body);
231 }
232
233 resp.json::<serde_json::Value>()
234 .with_context(|| format!("Failed to parse JSON from {}", url))
235 }
236
237 fn get_paginated(&self, endpoint: &str) -> Result<Vec<serde_json::Value>> {
238 let mut all = Vec::new();
239 let mut url = format!("{}{}?per_page=100", self.base_url, endpoint);
240
241 loop {
242 let resp = self
243 .client
244 .get(&url)
245 .header("Authorization", format!("Bearer {}", self.token))
246 .header("Accept", "application/vnd.github+json")
247 .header("X-GitHub-Api-Version", "2022-11-28")
248 .send()
249 .with_context(|| format!("Request failed: GET {}", url))?;
250
251 let status = resp.status();
252 if !status.is_success() {
253 let body = resp.text().unwrap_or_default();
254 bail!("GitHub API error {}: {}", status, body);
255 }
256
257 let next_url = resp
259 .headers()
260 .get("link")
261 .and_then(|v| v.to_str().ok())
262 .and_then(parse_next_link);
263
264 let page: Vec<serde_json::Value> = resp
265 .json()
266 .with_context(|| format!("Failed to parse JSON from {}", url))?;
267
268 all.extend(page);
269
270 match next_url {
271 Some(next) => url = next,
272 None => break,
273 }
274 }
275
276 Ok(all)
277 }
278 }
279
280 fn parse_next_link(header: &str) -> Option<String> {
281 for part in header.split(',') {
282 let part = part.trim();
283 if part.ends_with("rel=\"next\"") {
284 if let Some(start) = part.find('<')
286 && let Some(end) = part.find('>')
287 {
288 return Some(part[start + 1..end].to_string());
289 }
290 }
291 }
292 None
293 }
294
295 pub fn derive_pull_request(
305 owner: &str,
306 repo: &str,
307 pr_number: u64,
308 config: &DeriveConfig,
309 ) -> Result<Path> {
310 let client = GitHubClient::new(config)?;
311 let prefix = format!("/repos/{}/{}", owner, repo);
312
313 let pr = client.get_json(&format!("{}/pulls/{}", prefix, pr_number))?;
315 let commits = client.get_paginated(&format!("{}/pulls/{}/commits", prefix, pr_number))?;
316
317 let mut commit_details = Vec::new();
319 for c in &commits {
320 let sha = c["sha"].as_str().unwrap_or_default();
321 if !sha.is_empty() {
322 let detail = client.get_json(&format!("{}/commits/{}", prefix, sha))?;
323 commit_details.push(detail);
324 }
325 }
326
327 let reviews = if config.include_comments {
328 client.get_paginated(&format!("{}/pulls/{}/reviews", prefix, pr_number))?
329 } else {
330 Vec::new()
331 };
332
333 let pr_comments = if config.include_comments {
334 client.get_paginated(&format!("{}/issues/{}/comments", prefix, pr_number))?
335 } else {
336 Vec::new()
337 };
338
339 let review_comments = if config.include_comments {
340 client.get_paginated(&format!("{}/pulls/{}/comments", prefix, pr_number))?
341 } else {
342 Vec::new()
343 };
344
345 let mut check_runs_by_sha: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
347 if config.include_ci {
348 for c in &commits {
349 let sha = c["sha"].as_str().unwrap_or_default();
350 if !sha.is_empty() {
351 let checks =
352 client.get_json(&format!("{}/commits/{}/check-runs", prefix, sha))?;
353 if let Some(runs) = checks["check_runs"].as_array() {
354 check_runs_by_sha.insert(sha.to_string(), runs.clone());
355 }
356 }
357 }
358 }
359
360 let data = PrData {
361 pr: &pr,
362 commit_details: &commit_details,
363 reviews: &reviews,
364 pr_comments: &pr_comments,
365 review_comments: &review_comments,
366 check_runs_by_sha: &check_runs_by_sha,
367 };
368
369 derive_from_data(&data, owner, repo, config)
370 }
371
372 pub fn list_pull_requests(
374 owner: &str,
375 repo: &str,
376 config: &DeriveConfig,
377 ) -> Result<Vec<PullRequestInfo>> {
378 let client = GitHubClient::new(config)?;
379 let prs = client.get_paginated(&format!("/repos/{}/{}/pulls?state=all", owner, repo))?;
380
381 let mut result = Vec::new();
382 for pr in &prs {
383 result.push(PullRequestInfo {
384 number: pr["number"].as_u64().unwrap_or(0),
385 title: str_field(pr, "title"),
386 state: str_field(pr, "state"),
387 author: pr["user"]["login"]
388 .as_str()
389 .unwrap_or("unknown")
390 .to_string(),
391 head_branch: pr["head"]["ref"].as_str().unwrap_or("unknown").to_string(),
392 base_branch: pr["base"]["ref"].as_str().unwrap_or("unknown").to_string(),
393 created_at: str_field(pr, "created_at"),
394 updated_at: str_field(pr, "updated_at"),
395 });
396 }
397
398 Ok(result)
399 }
400
401 struct PrData<'a> {
406 pr: &'a serde_json::Value,
407 commit_details: &'a [serde_json::Value],
408 reviews: &'a [serde_json::Value],
409 pr_comments: &'a [serde_json::Value],
410 review_comments: &'a [serde_json::Value],
411 check_runs_by_sha: &'a HashMap<String, Vec<serde_json::Value>>,
412 }
413
414 fn derive_from_data(
415 data: &PrData<'_>,
416 owner: &str,
417 repo: &str,
418 config: &DeriveConfig,
419 ) -> Result<Path> {
420 let pr = data.pr;
421 let commit_details = data.commit_details;
422 let reviews = data.reviews;
423 let pr_comments = data.pr_comments;
424 let review_comments = data.review_comments;
425 let check_runs_by_sha = data.check_runs_by_sha;
426 let pr_number = pr["number"].as_u64().unwrap_or(0);
427
428 let mut steps: Vec<Step> = Vec::new();
430 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
431 let mut actor_associations: HashMap<String, String> = HashMap::new();
432
433 for detail in commit_details {
434 let step = commit_to_step(detail, &mut actors, &mut actor_associations)?;
435 steps.push(step);
436 }
437
438 if config.include_comments {
440 for rc in review_comments {
441 let step = review_comment_to_step(rc, &mut actors, &mut actor_associations)?;
442 steps.push(step);
443 }
444
445 for pc in pr_comments {
446 let step = pr_comment_to_step(pc, &mut actors, &mut actor_associations)?;
447 steps.push(step);
448 }
449
450 for review in reviews {
451 let state = review["state"].as_str().unwrap_or("");
452 if state.is_empty() || state == "PENDING" {
453 continue;
454 }
455 let step = review_to_step(review, &mut actors, &mut actor_associations)?;
456 steps.push(step);
457 }
458 }
459
460 if config.include_ci {
462 for runs in check_runs_by_sha.values() {
463 for run in runs {
464 let step = check_run_to_step(run, &mut actors)?;
465 steps.push(step);
466 }
467 }
468 }
469
470 steps.sort_by(|a, b| a.step.timestamp.cmp(&b.step.timestamp));
474
475 let reply_target: HashMap<u64, String> = steps
477 .iter()
478 .filter_map(|s| {
479 let id_str = s.step.id.strip_prefix("step-rc-")?;
480 let github_id: u64 = id_str.parse().ok()?;
481 Some((github_id, s.step.id.clone()))
482 })
483 .collect();
484
485 let reply_parents: HashMap<String, String> = steps
487 .iter()
488 .filter_map(|s| {
489 let reply_to = s
490 .meta
491 .as_ref()?
492 .extra
493 .get("github")?
494 .get("in_reply_to_id")?
495 .as_u64()?;
496 let parent_step = reply_target.get(&reply_to)?;
497 Some((s.step.id.clone(), parent_step.clone()))
498 })
499 .collect();
500
501 let mut prev_id: Option<String> = None;
503 for step in &mut steps {
504 if let Some(parent) = reply_parents.get(&step.step.id) {
505 step.step.parents = vec![parent.clone()];
507 } else if let Some(ref prev) = prev_id {
508 step.step.parents = vec![prev.clone()];
509 } else {
510 step.step.parents = vec![];
511 }
512 if !reply_parents.contains_key(&step.step.id) {
514 prev_id = Some(step.step.id.clone());
515 }
516 }
517
518 let head = prev_id.unwrap_or_else(|| format!("pr-{}", pr_number));
521
522 let meta = build_path_meta(pr, &actors, &actor_associations)?;
524
525 Ok(Path {
526 path: PathIdentity {
527 id: format!("pr-{}", pr_number),
528 base: Some(Base {
529 uri: format!("github:{}/{}", owner, repo),
530 ref_str: pr["base"]["sha"].as_str().map(|s| s.to_string()),
531 branch: pr["base"]["ref"].as_str().map(|s| s.to_string()),
532 }),
533 head,
534 graph_ref: None,
535 },
536 steps,
537 meta: Some(meta),
538 })
539 }
540
541 fn commit_to_step(
546 detail: &serde_json::Value,
547 actors: &mut HashMap<String, ActorDefinition>,
548 actor_associations: &mut HashMap<String, String>,
549 ) -> Result<Step> {
550 let sha = detail["sha"].as_str().unwrap_or_default();
551 let short_sha = &sha[..sha.len().min(8)];
552 let step_id = format!("step-{}", short_sha);
553
554 let login = detail["author"]["login"].as_str().unwrap_or("unknown");
556 let actor = format!("human:{}", login);
557 let association = detail["author_association"].as_str();
558 register_actor(actors, actor_associations, &actor, login, association);
559
560 let timestamp = detail["commit"]["committer"]["date"]
562 .as_str()
563 .unwrap_or("1970-01-01T00:00:00Z")
564 .to_string();
565
566 let mut change: HashMap<String, ArtifactChange> = HashMap::new();
568 if let Some(files) = detail["files"].as_array() {
569 for file in files {
570 let filename = file["filename"].as_str().unwrap_or("unknown");
571 if let Some(patch) = file["patch"].as_str() {
572 change.insert(filename.to_string(), ArtifactChange::raw(patch));
573 }
574 }
575 }
576
577 let message = detail["commit"]["message"].as_str().unwrap_or("");
579 let intent = message.lines().next().unwrap_or("").to_string();
580
581 let mut step = Step {
582 step: StepIdentity {
583 id: step_id,
584 parents: vec![],
585 actor,
586 timestamp,
587 },
588 change,
589 meta: None,
590 };
591
592 if !intent.is_empty() {
593 step.meta = Some(StepMeta {
594 intent: Some(intent),
595 source: Some(toolpath::v1::VcsSource {
596 vcs_type: "git".to_string(),
597 revision: sha.to_string(),
598 change_id: None,
599 extra: HashMap::new(),
600 }),
601 ..Default::default()
602 });
603 }
604
605 Ok(step)
606 }
607
608 fn review_comment_to_step(
609 rc: &serde_json::Value,
610 actors: &mut HashMap<String, ActorDefinition>,
611 actor_associations: &mut HashMap<String, String>,
612 ) -> Result<Step> {
613 let id = rc["id"].as_u64().unwrap_or(0);
614 let step_id = format!("step-rc-{}", id);
615
616 let login = rc["user"]["login"].as_str().unwrap_or("unknown");
617 let actor = format!("human:{}", login);
618 let association = rc["author_association"].as_str();
619 register_actor(actors, actor_associations, &actor, login, association);
620
621 let timestamp = rc["created_at"]
622 .as_str()
623 .unwrap_or("1970-01-01T00:00:00Z")
624 .to_string();
625
626 let path = rc["path"].as_str().unwrap_or("unknown");
627 let line = rc["line"]
628 .as_u64()
629 .or_else(|| rc["original_line"].as_u64())
630 .unwrap_or(0);
631 let artifact_uri = format!("review://{}#L{}", path, line);
632
633 let body = rc["body"].as_str().unwrap_or("").to_string();
634 let diff_hunk = rc["diff_hunk"].as_str().map(|s| s.to_string());
635
636 let mut extra = HashMap::new();
637 extra.insert("body".to_string(), serde_json::Value::String(body));
638
639 let change = HashMap::from([(
640 artifact_uri,
641 ArtifactChange {
642 raw: diff_hunk,
643 structural: Some(StructuralChange {
644 change_type: "review.comment".to_string(),
645 extra,
646 }),
647 },
648 )]);
649
650 let meta = if let Some(reply_to) = rc["in_reply_to_id"].as_u64() {
652 let mut step_extra = HashMap::new();
653 let mut gh_extra = serde_json::Map::new();
654 gh_extra.insert("in_reply_to_id".to_string(), serde_json::json!(reply_to));
655 step_extra.insert("github".to_string(), serde_json::Value::Object(gh_extra));
656 Some(StepMeta {
657 extra: step_extra,
658 ..Default::default()
659 })
660 } else {
661 None
662 };
663
664 Ok(Step {
665 step: StepIdentity {
666 id: step_id,
667 parents: vec![],
668 actor,
669 timestamp,
670 },
671 change,
672 meta,
673 })
674 }
675
676 fn pr_comment_to_step(
677 pc: &serde_json::Value,
678 actors: &mut HashMap<String, ActorDefinition>,
679 actor_associations: &mut HashMap<String, String>,
680 ) -> Result<Step> {
681 let id = pc["id"].as_u64().unwrap_or(0);
682 let step_id = format!("step-ic-{}", id);
683
684 let timestamp = pc["created_at"]
685 .as_str()
686 .unwrap_or("1970-01-01T00:00:00Z")
687 .to_string();
688
689 let login = pc["user"]["login"].as_str().unwrap_or("unknown");
690 let actor = format!("human:{}", login);
691 let association = pc["author_association"].as_str();
692 register_actor(actors, actor_associations, &actor, login, association);
693
694 let body = pc["body"].as_str().unwrap_or("").to_string();
695
696 let mut extra = HashMap::new();
697 extra.insert("body".to_string(), serde_json::Value::String(body));
698
699 let change = HashMap::from([(
700 "review://conversation".to_string(),
701 ArtifactChange {
702 raw: None,
703 structural: Some(StructuralChange {
704 change_type: "review.conversation".to_string(),
705 extra,
706 }),
707 },
708 )]);
709
710 Ok(Step {
711 step: StepIdentity {
712 id: step_id,
713 parents: vec![],
714 actor,
715 timestamp,
716 },
717 change,
718 meta: None,
719 })
720 }
721
722 fn review_to_step(
723 review: &serde_json::Value,
724 actors: &mut HashMap<String, ActorDefinition>,
725 actor_associations: &mut HashMap<String, String>,
726 ) -> Result<Step> {
727 let id = review["id"].as_u64().unwrap_or(0);
728 let step_id = format!("step-rv-{}", id);
729
730 let timestamp = review["submitted_at"]
731 .as_str()
732 .unwrap_or("1970-01-01T00:00:00Z")
733 .to_string();
734
735 let login = review["user"]["login"].as_str().unwrap_or("unknown");
736 let actor = format!("human:{}", login);
737 let association = review["author_association"].as_str();
738 register_actor(actors, actor_associations, &actor, login, association);
739
740 let state = review["state"].as_str().unwrap_or("COMMENTED").to_string();
741 let body = review["body"].as_str().unwrap_or("").to_string();
742
743 let mut extra = HashMap::new();
744 extra.insert("state".to_string(), serde_json::Value::String(state));
745
746 let change = HashMap::from([(
747 "review://decision".to_string(),
748 ArtifactChange {
749 raw: if body.is_empty() {
750 None
751 } else {
752 Some(body.clone())
753 },
754 structural: Some(StructuralChange {
755 change_type: "review.decision".to_string(),
756 extra,
757 }),
758 },
759 )]);
760
761 let meta = if !body.is_empty() {
763 let intent = if body.len() > 500 {
764 format!("{}...", &body[..500])
765 } else {
766 body
767 };
768 Some(StepMeta {
769 intent: Some(intent),
770 ..Default::default()
771 })
772 } else {
773 None
774 };
775
776 Ok(Step {
777 step: StepIdentity {
778 id: step_id,
779 parents: vec![],
780 actor,
781 timestamp,
782 },
783 change,
784 meta,
785 })
786 }
787
788 fn check_run_to_step(
789 run: &serde_json::Value,
790 actors: &mut HashMap<String, ActorDefinition>,
791 ) -> Result<Step> {
792 let id = run["id"].as_u64().unwrap_or(0);
793 let step_id = format!("step-ci-{}", id);
794
795 let name = run["name"].as_str().unwrap_or("unknown");
796 let app_slug = run["app"]["slug"].as_str().unwrap_or("ci");
797 let actor = format!("ci:{}", app_slug);
798
799 actors
800 .entry(actor.clone())
801 .or_insert_with(|| ActorDefinition {
802 name: Some(app_slug.to_string()),
803 ..Default::default()
804 });
805
806 let timestamp = run["completed_at"]
807 .as_str()
808 .or_else(|| run["started_at"].as_str())
809 .unwrap_or("1970-01-01T00:00:00Z")
810 .to_string();
811
812 let conclusion = run["conclusion"].as_str().unwrap_or("unknown").to_string();
813
814 let mut extra = HashMap::new();
815 extra.insert(
816 "conclusion".to_string(),
817 serde_json::Value::String(conclusion),
818 );
819 if let Some(html_url) = run["html_url"].as_str() {
820 extra.insert(
821 "url".to_string(),
822 serde_json::Value::String(html_url.to_string()),
823 );
824 }
825
826 let artifact_uri = format!("ci://checks/{}", name);
827 let change = HashMap::from([(
828 artifact_uri,
829 ArtifactChange {
830 raw: None,
831 structural: Some(StructuralChange {
832 change_type: "ci.run".to_string(),
833 extra,
834 }),
835 },
836 )]);
837
838 Ok(Step {
839 step: StepIdentity {
840 id: step_id,
841 parents: vec![],
842 actor,
843 timestamp,
844 },
845 change,
846 meta: None,
847 })
848 }
849
850 fn build_path_meta(
851 pr: &serde_json::Value,
852 actors: &HashMap<String, ActorDefinition>,
853 actor_associations: &HashMap<String, String>,
854 ) -> Result<PathMeta> {
855 let title = pr["title"].as_str().map(|s| s.to_string());
856 let body = pr["body"].as_str().unwrap_or("");
857 let intent = if body.is_empty() {
858 None
859 } else {
860 Some(body.to_string())
861 };
862
863 let issue_numbers = extract_issue_refs(body);
865 let refs: Vec<Ref> = issue_numbers
866 .into_iter()
867 .map(|n| {
868 let owner = pr["base"]["repo"]["owner"]["login"]
869 .as_str()
870 .unwrap_or("unknown");
871 let repo = pr["base"]["repo"]["name"].as_str().unwrap_or("unknown");
872 Ref {
873 rel: "fixes".to_string(),
874 href: format!("https://github.com/{}/{}/issues/{}", owner, repo, n),
875 }
876 })
877 .collect();
878
879 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
881 let mut github_meta = serde_json::Map::new();
882
883 if let Some(number) = pr["number"].as_u64() {
885 github_meta.insert("number".to_string(), serde_json::json!(number));
886 }
887 if let Some(author) = pr["user"]["login"].as_str() {
888 github_meta.insert(
889 "author".to_string(),
890 serde_json::Value::String(author.to_string()),
891 );
892 }
893 if let Some(state) = pr["state"].as_str() {
894 github_meta.insert(
895 "state".to_string(),
896 serde_json::Value::String(state.to_string()),
897 );
898 }
899 if let Some(draft) = pr["draft"].as_bool() {
900 github_meta.insert("draft".to_string(), serde_json::json!(draft));
901 }
902
903 if let Some(merged) = pr["merged"].as_bool() {
905 github_meta.insert("merged".to_string(), serde_json::json!(merged));
906 }
907 if let Some(merged_at) = pr["merged_at"].as_str() {
908 github_meta.insert(
909 "merged_at".to_string(),
910 serde_json::Value::String(merged_at.to_string()),
911 );
912 }
913 if let Some(merged_by) = pr["merged_by"]["login"].as_str() {
914 github_meta.insert(
915 "merged_by".to_string(),
916 serde_json::Value::String(merged_by.to_string()),
917 );
918 }
919
920 if let Some(additions) = pr["additions"].as_u64() {
922 github_meta.insert("additions".to_string(), serde_json::json!(additions));
923 }
924 if let Some(deletions) = pr["deletions"].as_u64() {
925 github_meta.insert("deletions".to_string(), serde_json::json!(deletions));
926 }
927 if let Some(changed_files) = pr["changed_files"].as_u64() {
928 github_meta.insert(
929 "changed_files".to_string(),
930 serde_json::json!(changed_files),
931 );
932 }
933
934 if let Some(labels) = pr["labels"].as_array() {
936 let label_names: Vec<serde_json::Value> = labels
937 .iter()
938 .filter_map(|l| l["name"].as_str())
939 .map(|s| serde_json::Value::String(s.to_string()))
940 .collect();
941 if !label_names.is_empty() {
942 github_meta.insert("labels".to_string(), serde_json::Value::Array(label_names));
943 }
944 }
945
946 if !actor_associations.is_empty() {
948 let assoc_map: serde_json::Map<String, serde_json::Value> = actor_associations
949 .iter()
950 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
951 .collect();
952 github_meta.insert(
953 "actor_associations".to_string(),
954 serde_json::Value::Object(assoc_map),
955 );
956 }
957
958 if !github_meta.is_empty() {
959 extra.insert("github".to_string(), serde_json::Value::Object(github_meta));
960 }
961
962 Ok(PathMeta {
963 title,
964 intent,
965 refs,
966 actors: if actors.is_empty() {
967 None
968 } else {
969 Some(actors.clone())
970 },
971 extra,
972 ..Default::default()
973 })
974 }
975
976 fn register_actor(
981 actors: &mut HashMap<String, ActorDefinition>,
982 actor_associations: &mut HashMap<String, String>,
983 actor_key: &str,
984 login: &str,
985 association: Option<&str>,
986 ) {
987 actors
988 .entry(actor_key.to_string())
989 .or_insert_with(|| ActorDefinition {
990 name: Some(login.to_string()),
991 identities: vec![Identity {
992 system: "github".to_string(),
993 id: login.to_string(),
994 }],
995 ..Default::default()
996 });
997 if let Some(assoc) = association
998 && assoc != "NONE"
999 {
1000 actor_associations
1001 .entry(actor_key.to_string())
1002 .or_insert_with(|| assoc.to_string());
1003 }
1004 }
1005
1006 fn str_field(val: &serde_json::Value, key: &str) -> String {
1007 val[key].as_str().unwrap_or("").to_string()
1008 }
1009
1010 #[cfg(test)]
1015 mod tests {
1016 use super::*;
1017
1018 fn sample_pr() -> serde_json::Value {
1019 serde_json::json!({
1020 "number": 42,
1021 "title": "Add feature X",
1022 "body": "This PR adds feature X.\n\nFixes #10\nCloses #20",
1023 "state": "open",
1024 "draft": false,
1025 "merged": false,
1026 "merged_at": null,
1027 "merged_by": null,
1028 "additions": 150,
1029 "deletions": 30,
1030 "changed_files": 5,
1031 "user": { "login": "alice" },
1032 "head": { "ref": "feature-x" },
1033 "base": {
1034 "ref": "main",
1035 "sha": "abc123def456",
1036 "repo": {
1037 "owner": { "login": "acme" },
1038 "name": "widgets"
1039 }
1040 },
1041 "labels": [
1042 { "name": "enhancement" },
1043 { "name": "reviewed" }
1044 ],
1045 "created_at": "2026-01-15T10:00:00Z",
1046 "updated_at": "2026-01-16T14:00:00Z"
1047 })
1048 }
1049
1050 fn sample_commit_detail(
1051 sha: &str,
1052 parent_sha: Option<&str>,
1053 msg: &str,
1054 ) -> serde_json::Value {
1055 let parents: Vec<serde_json::Value> = parent_sha
1056 .into_iter()
1057 .map(|s| serde_json::json!({ "sha": s }))
1058 .collect();
1059 serde_json::json!({
1060 "sha": sha,
1061 "commit": {
1062 "message": msg,
1063 "committer": {
1064 "date": "2026-01-15T12:00:00Z"
1065 }
1066 },
1067 "author": { "login": "alice" },
1068 "parents": parents,
1069 "files": [
1070 {
1071 "filename": "src/main.rs",
1072 "patch": "@@ -1,3 +1,4 @@\n fn main() {\n+ println!(\"hello\");\n }"
1073 }
1074 ]
1075 })
1076 }
1077
1078 fn sample_review_comment(
1079 id: u64,
1080 commit_sha: &str,
1081 path: &str,
1082 line: u64,
1083 ) -> serde_json::Value {
1084 serde_json::json!({
1085 "id": id,
1086 "user": { "login": "bob" },
1087 "commit_id": commit_sha,
1088 "path": path,
1089 "line": line,
1090 "body": "Consider using a constant here.",
1091 "diff_hunk": "@@ -10,6 +10,7 @@\n fn example() {\n+ let x = 42;\n }",
1092 "author_association": "COLLABORATOR",
1093 "created_at": "2026-01-15T14:00:00Z",
1094 "pull_request_review_id": 100,
1095 "in_reply_to_id": null
1096 })
1097 }
1098
1099 fn sample_pr_comment(id: u64) -> serde_json::Value {
1100 serde_json::json!({
1101 "id": id,
1102 "user": { "login": "carol" },
1103 "body": "Looks good overall!",
1104 "author_association": "CONTRIBUTOR",
1105 "created_at": "2026-01-15T16:00:00Z"
1106 })
1107 }
1108
1109 fn sample_review(id: u64, state: &str) -> serde_json::Value {
1110 serde_json::json!({
1111 "id": id,
1112 "user": { "login": "dave" },
1113 "state": state,
1114 "body": "Approved with minor comments.",
1115 "author_association": "MEMBER",
1116 "submitted_at": "2026-01-15T17:00:00Z"
1117 })
1118 }
1119
1120 fn sample_check_run(id: u64, name: &str, conclusion: &str) -> serde_json::Value {
1121 serde_json::json!({
1122 "id": id,
1123 "name": name,
1124 "app": { "slug": "github-actions" },
1125 "conclusion": conclusion,
1126 "html_url": format!("https://github.com/acme/widgets/actions/runs/{}", id),
1127 "completed_at": "2026-01-15T13:00:00Z",
1128 "started_at": "2026-01-15T12:30:00Z"
1129 })
1130 }
1131
1132 #[test]
1133 fn test_commit_to_step() {
1134 let detail = sample_commit_detail("abc12345deadbeef", None, "Initial commit");
1135 let mut actors = HashMap::new();
1136 let mut assoc = HashMap::new();
1137
1138 let step = commit_to_step(&detail, &mut actors, &mut assoc).unwrap();
1139
1140 assert_eq!(step.step.id, "step-abc12345");
1141 assert_eq!(step.step.actor, "human:alice");
1142 assert!(step.step.parents.is_empty());
1143 assert!(step.change.contains_key("src/main.rs"));
1144 assert_eq!(
1145 step.meta.as_ref().unwrap().intent.as_deref(),
1146 Some("Initial commit")
1147 );
1148 assert!(actors.contains_key("human:alice"));
1149 }
1150
1151 #[test]
1152 fn test_review_comment_to_step() {
1153 let rc = sample_review_comment(200, "abc12345deadbeef", "src/main.rs", 42);
1154 let mut actors = HashMap::new();
1155 let mut assoc = HashMap::new();
1156
1157 let step = review_comment_to_step(&rc, &mut actors, &mut assoc).unwrap();
1158
1159 assert_eq!(step.step.id, "step-rc-200");
1160 assert_eq!(step.step.actor, "human:bob");
1161 assert!(step.step.parents.is_empty());
1163 assert!(step.change.contains_key("review://src/main.rs#L42"));
1164 assert!(actors.contains_key("human:bob"));
1165 let change = &step.change["review://src/main.rs#L42"];
1167 assert!(change.raw.is_some());
1168 assert!(change.raw.as_deref().unwrap().contains("let x = 42"));
1169 assert_eq!(
1171 assoc.get("human:bob").map(|s| s.as_str()),
1172 Some("COLLABORATOR")
1173 );
1174 }
1175
1176 #[test]
1177 fn test_pr_comment_to_step() {
1178 let pc = sample_pr_comment(300);
1179 let mut actors = HashMap::new();
1180 let mut assoc = HashMap::new();
1181
1182 let step = pr_comment_to_step(&pc, &mut actors, &mut assoc).unwrap();
1183
1184 assert_eq!(step.step.id, "step-ic-300");
1185 assert_eq!(step.step.actor, "human:carol");
1186 assert!(step.step.parents.is_empty());
1187 assert!(step.change.contains_key("review://conversation"));
1188 let change = &step.change["review://conversation"];
1189 assert!(change.structural.is_some());
1190 let structural = change.structural.as_ref().unwrap();
1191 assert_eq!(structural.change_type, "review.conversation");
1192 assert_eq!(structural.extra["body"], "Looks good overall!");
1193 }
1194
1195 #[test]
1196 fn test_review_to_step() {
1197 let review = sample_review(400, "APPROVED");
1198 let mut actors = HashMap::new();
1199 let mut assoc = HashMap::new();
1200
1201 let step = review_to_step(&review, &mut actors, &mut assoc).unwrap();
1202
1203 assert_eq!(step.step.id, "step-rv-400");
1204 assert_eq!(step.step.actor, "human:dave");
1205 assert!(step.step.parents.is_empty());
1206 assert!(step.change.contains_key("review://decision"));
1207 let change = &step.change["review://decision"];
1208 assert!(change.structural.is_some());
1209 let structural = change.structural.as_ref().unwrap();
1210 assert_eq!(structural.change_type, "review.decision");
1211 assert_eq!(structural.extra["state"], "APPROVED");
1212 assert_eq!(
1214 step.meta.as_ref().unwrap().intent.as_deref(),
1215 Some("Approved with minor comments.")
1216 );
1217 }
1218
1219 #[test]
1220 fn test_check_run_to_step() {
1221 let run = sample_check_run(500, "build", "success");
1222 let mut actors = HashMap::new();
1223
1224 let step = check_run_to_step(&run, &mut actors).unwrap();
1225
1226 assert_eq!(step.step.id, "step-ci-500");
1227 assert_eq!(step.step.actor, "ci:github-actions");
1228 assert!(step.step.parents.is_empty());
1229 assert!(step.change.contains_key("ci://checks/build"));
1230 let change = &step.change["ci://checks/build"];
1231 let structural = change.structural.as_ref().unwrap();
1232 assert_eq!(structural.change_type, "ci.run");
1233 assert_eq!(structural.extra["conclusion"], "success");
1234 assert!(
1236 structural.extra["url"]
1237 .as_str()
1238 .unwrap()
1239 .contains("actions/runs/500")
1240 );
1241 }
1242
1243 #[test]
1244 fn test_build_path_meta() {
1245 let pr = sample_pr();
1246 let mut actors = HashMap::new();
1247 let mut assoc = HashMap::new();
1248 register_actor(
1249 &mut actors,
1250 &mut assoc,
1251 "human:alice",
1252 "alice",
1253 Some("MEMBER"),
1254 );
1255
1256 let meta = build_path_meta(&pr, &actors, &assoc).unwrap();
1257
1258 assert_eq!(meta.title.as_deref(), Some("Add feature X"));
1259 assert!(meta.intent.as_deref().unwrap().contains("feature X"));
1260 assert_eq!(meta.refs.len(), 2);
1261 assert_eq!(meta.refs[0].rel, "fixes");
1262 assert!(meta.refs[0].href.contains("/issues/10"));
1263 assert!(meta.refs[1].href.contains("/issues/20"));
1264 assert!(meta.actors.is_some());
1265
1266 let github = meta.extra.get("github").unwrap();
1268 let labels = github["labels"].as_array().unwrap();
1269 assert_eq!(labels.len(), 2);
1270 assert_eq!(github["state"], "open");
1271 assert_eq!(github["additions"], 150);
1272 assert_eq!(github["deletions"], 30);
1273 assert_eq!(github["changed_files"], 5);
1274 assert_eq!(github["number"], 42);
1275 assert_eq!(github["author"], "alice");
1276 assert_eq!(github["draft"], false);
1277 assert_eq!(github["merged"], false);
1278 assert_eq!(github["actor_associations"]["human:alice"], "MEMBER");
1280 }
1281
1282 #[test]
1283 fn test_derive_from_data_full() {
1284 let pr = sample_pr();
1285 let commit1 = sample_commit_detail("abc12345deadbeef", None, "Initial commit");
1286 let commit2 =
1287 sample_commit_detail("def67890cafebabe", Some("abc12345deadbeef"), "Add tests");
1288 let mut commit2 = commit2;
1290 commit2["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T13:00:00Z");
1291
1292 let review_comments = vec![sample_review_comment(
1293 200,
1294 "abc12345deadbeef",
1295 "src/main.rs",
1296 42,
1297 )];
1298 let pr_comments = vec![sample_pr_comment(300)];
1299 let reviews = vec![sample_review(400, "APPROVED")];
1300
1301 let mut check_runs = HashMap::new();
1302 check_runs.insert(
1303 "abc12345deadbeef".to_string(),
1304 vec![sample_check_run(500, "build", "success")],
1305 );
1306
1307 let config = DeriveConfig {
1308 token: "test".to_string(),
1309 api_url: "https://api.github.com".to_string(),
1310 include_ci: true,
1311 include_comments: true,
1312 };
1313
1314 let data = PrData {
1315 pr: &pr,
1316 commit_details: &[commit1, commit2],
1317 reviews: &reviews,
1318 pr_comments: &pr_comments,
1319 review_comments: &review_comments,
1320 check_runs_by_sha: &check_runs,
1321 };
1322 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1323
1324 assert_eq!(path.path.id, "pr-42");
1325 assert_eq!(path.path.base.as_ref().unwrap().uri, "github:acme/widgets");
1326 assert_eq!(
1327 path.path.base.as_ref().unwrap().ref_str.as_deref(),
1328 Some("abc123def456")
1329 );
1330 assert_eq!(
1331 path.path.base.as_ref().unwrap().branch.as_deref(),
1332 Some("main")
1333 );
1334
1335 assert_eq!(path.steps.len(), 6);
1337
1338 assert!(path.steps[0].step.parents.is_empty());
1340 for i in 1..path.steps.len() {
1341 assert!(
1342 path.steps[i].step.timestamp >= path.steps[i - 1].step.timestamp,
1343 "Steps not sorted: {} < {}",
1344 path.steps[i].step.timestamp,
1345 path.steps[i - 1].step.timestamp,
1346 );
1347 assert_eq!(
1348 path.steps[i].step.parents,
1349 vec![path.steps[i - 1].step.id.clone()],
1350 "Step {} should parent off step {}",
1351 path.steps[i].step.id,
1352 path.steps[i - 1].step.id,
1353 );
1354 }
1355
1356 let meta = path.meta.as_ref().unwrap();
1358 assert_eq!(meta.title.as_deref(), Some("Add feature X"));
1359 assert_eq!(meta.refs.len(), 2);
1360 }
1361
1362 #[test]
1363 fn test_derive_from_data_no_ci() {
1364 let pr = sample_pr();
1365 let commit = sample_commit_detail("abc12345deadbeef", None, "Commit");
1366
1367 let config = DeriveConfig {
1368 token: "test".to_string(),
1369 api_url: "https://api.github.com".to_string(),
1370 include_ci: false,
1371 include_comments: false,
1372 };
1373
1374 let data = PrData {
1375 pr: &pr,
1376 commit_details: &[commit],
1377 reviews: &[],
1378 pr_comments: &[],
1379 review_comments: &[],
1380 check_runs_by_sha: &HashMap::new(),
1381 };
1382 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1383
1384 assert_eq!(path.steps.len(), 1);
1386 assert_eq!(path.steps[0].step.id, "step-abc12345");
1387 }
1388
1389 #[test]
1390 fn test_derive_from_data_pending_review_skipped() {
1391 let pr = sample_pr();
1392 let commit = sample_commit_detail("abc12345deadbeef", None, "Commit");
1393 let pending_review = sample_review(999, "PENDING");
1394
1395 let config = DeriveConfig {
1396 token: "test".to_string(),
1397 api_url: "https://api.github.com".to_string(),
1398 include_ci: false,
1399 include_comments: true,
1400 };
1401
1402 let data = PrData {
1403 pr: &pr,
1404 commit_details: &[commit],
1405 reviews: &[pending_review],
1406 pr_comments: &[],
1407 review_comments: &[],
1408 check_runs_by_sha: &HashMap::new(),
1409 };
1410 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1411
1412 assert_eq!(path.steps.len(), 1);
1414 }
1415
1416 #[test]
1417 fn test_parse_next_link() {
1418 let header = r#"<https://api.github.com/repos/foo/bar/pulls?page=2>; rel="next", <https://api.github.com/repos/foo/bar/pulls?page=5>; rel="last""#;
1419 assert_eq!(
1420 parse_next_link(header),
1421 Some("https://api.github.com/repos/foo/bar/pulls?page=2".to_string())
1422 );
1423
1424 assert_eq!(
1425 parse_next_link(r#"<https://example.com>; rel="prev""#),
1426 None
1427 );
1428 }
1429
1430 #[test]
1431 fn test_str_field() {
1432 let val = serde_json::json!({"name": "hello", "missing": null});
1433 assert_eq!(str_field(&val, "name"), "hello");
1434 assert_eq!(str_field(&val, "missing"), "");
1435 assert_eq!(str_field(&val, "nonexistent"), "");
1436 }
1437
1438 #[test]
1439 fn test_register_actor_idempotent() {
1440 let mut actors = HashMap::new();
1441 let mut assoc = HashMap::new();
1442 register_actor(&mut actors, &mut assoc, "human:alice", "alice", None);
1443 register_actor(&mut actors, &mut assoc, "human:alice", "alice", None);
1444 assert_eq!(actors.len(), 1);
1445 }
1446
1447 #[test]
1448 fn test_ci_steps_chain_inline() {
1449 let pr = sample_pr();
1450 let commit = sample_commit_detail("abc12345deadbeef", None, "Commit");
1451
1452 let mut check_runs = HashMap::new();
1453 check_runs.insert(
1454 "abc12345deadbeef".to_string(),
1455 vec![
1456 sample_check_run(501, "build", "success"),
1457 sample_check_run(502, "test", "success"),
1458 sample_check_run(503, "lint", "success"),
1459 ],
1460 );
1461
1462 let config = DeriveConfig {
1463 token: "test".to_string(),
1464 api_url: "https://api.github.com".to_string(),
1465 include_ci: true,
1466 include_comments: false,
1467 };
1468
1469 let data = PrData {
1470 pr: &pr,
1471 commit_details: &[commit],
1472 reviews: &[],
1473 pr_comments: &[],
1474 review_comments: &[],
1475 check_runs_by_sha: &check_runs,
1476 };
1477 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1478
1479 assert_eq!(path.steps.len(), 4);
1481
1482 assert!(path.steps[0].step.parents.is_empty()); for i in 1..path.steps.len() {
1485 assert_eq!(
1486 path.steps[i].step.parents,
1487 vec![path.steps[i - 1].step.id.clone()]
1488 );
1489 }
1490 }
1491
1492 #[test]
1493 fn test_review_comment_artifact_uri_format() {
1494 let rc = sample_review_comment(700, "abc12345", "src/lib.rs", 100);
1495 let mut actors = HashMap::new();
1496 let mut assoc = HashMap::new();
1497
1498 let step = review_comment_to_step(&rc, &mut actors, &mut assoc).unwrap();
1499
1500 assert!(step.change.contains_key("review://src/lib.rs#L100"));
1501 }
1502
1503 #[test]
1504 fn test_derive_from_data_empty_commits() {
1505 let pr = sample_pr();
1506 let config = DeriveConfig {
1507 token: "test".to_string(),
1508 api_url: "https://api.github.com".to_string(),
1509 include_ci: false,
1510 include_comments: false,
1511 };
1512
1513 let data = PrData {
1514 pr: &pr,
1515 commit_details: &[],
1516 reviews: &[],
1517 pr_comments: &[],
1518 review_comments: &[],
1519 check_runs_by_sha: &HashMap::new(),
1520 };
1521 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1522
1523 assert_eq!(path.path.id, "pr-42");
1524 assert!(path.steps.is_empty());
1525 assert_eq!(path.path.head, "pr-42");
1526 }
1527
1528 #[test]
1529 fn test_review_empty_body() {
1530 let mut review = sample_review(800, "APPROVED");
1531 review["body"] = serde_json::json!("");
1532 let mut actors = HashMap::new();
1533 let mut assoc = HashMap::new();
1534
1535 let step = review_to_step(&review, &mut actors, &mut assoc).unwrap();
1536 let change = &step.change["review://decision"];
1537 assert!(change.raw.is_none());
1538 assert!(change.structural.is_some());
1539 assert!(step.meta.is_none());
1541 }
1542
1543 #[test]
1544 fn test_commit_no_files() {
1545 let detail = serde_json::json!({
1546 "sha": "aabbccdd11223344",
1547 "commit": {
1548 "message": "Empty commit",
1549 "committer": { "date": "2026-01-15T12:00:00Z" }
1550 },
1551 "author": { "login": "alice" },
1552 "parents": [],
1553 "files": []
1554 });
1555 let mut actors = HashMap::new();
1556 let mut assoc = HashMap::new();
1557
1558 let step = commit_to_step(&detail, &mut actors, &mut assoc).unwrap();
1559 assert!(step.change.is_empty());
1560 }
1561
1562 #[test]
1563 fn test_multiple_commits_chain() {
1564 let pr = sample_pr();
1565 let c1 = {
1566 let mut c = sample_commit_detail("1111111100000000", None, "First");
1567 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T10:00:00Z");
1568 c
1569 };
1570 let c2 = {
1571 let mut c =
1572 sample_commit_detail("2222222200000000", Some("1111111100000000"), "Second");
1573 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T11:00:00Z");
1574 c
1575 };
1576 let c3 = {
1577 let mut c =
1578 sample_commit_detail("3333333300000000", Some("2222222200000000"), "Third");
1579 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T12:00:00Z");
1580 c
1581 };
1582
1583 let config = DeriveConfig {
1584 token: "test".to_string(),
1585 api_url: "https://api.github.com".to_string(),
1586 include_ci: false,
1587 include_comments: false,
1588 };
1589
1590 let data = PrData {
1591 pr: &pr,
1592 commit_details: &[c1, c2, c3],
1593 reviews: &[],
1594 pr_comments: &[],
1595 review_comments: &[],
1596 check_runs_by_sha: &HashMap::new(),
1597 };
1598 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1599
1600 assert_eq!(path.steps.len(), 3);
1602 assert!(path.steps[0].step.parents.is_empty());
1603 assert_eq!(path.steps[1].step.parents, vec!["step-11111111"]);
1604 assert_eq!(path.steps[2].step.parents, vec!["step-22222222"]);
1605 assert_eq!(path.path.head, "step-33333333");
1606 }
1607
1608 #[test]
1609 fn test_reply_threading() {
1610 let pr = sample_pr();
1611 let commit = {
1612 let mut c = sample_commit_detail("abc12345deadbeef", None, "Commit");
1613 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T10:00:00Z");
1614 c
1615 };
1616
1617 let rc1 = {
1619 let mut rc = sample_review_comment(200, "abc12345deadbeef", "src/main.rs", 42);
1620 rc["created_at"] = serde_json::json!("2026-01-15T14:00:00Z");
1621 rc
1622 };
1623 let rc2 = serde_json::json!({
1625 "id": 201,
1626 "user": { "login": "alice" },
1627 "commit_id": "abc12345deadbeef",
1628 "path": "src/main.rs",
1629 "line": 42,
1630 "body": "Good point, I'll fix that.",
1631 "diff_hunk": "@@ -10,6 +10,7 @@\n fn example() {\n+ let x = 42;\n }",
1632 "author_association": "CONTRIBUTOR",
1633 "created_at": "2026-01-15T15:00:00Z",
1634 "pull_request_review_id": 100,
1635 "in_reply_to_id": 200
1636 });
1637
1638 let config = DeriveConfig {
1639 token: "test".to_string(),
1640 api_url: "https://api.github.com".to_string(),
1641 include_ci: false,
1642 include_comments: true,
1643 };
1644
1645 let data = PrData {
1646 pr: &pr,
1647 commit_details: &[commit],
1648 reviews: &[],
1649 pr_comments: &[],
1650 review_comments: &[rc1, rc2],
1651 check_runs_by_sha: &HashMap::new(),
1652 };
1653 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1654
1655 assert_eq!(path.steps.len(), 3);
1656
1657 let commit_step = path
1659 .steps
1660 .iter()
1661 .find(|s| s.step.id == "step-abc12345")
1662 .unwrap();
1663 let rc1_step = path
1664 .steps
1665 .iter()
1666 .find(|s| s.step.id == "step-rc-200")
1667 .unwrap();
1668 let rc2_step = path
1669 .steps
1670 .iter()
1671 .find(|s| s.step.id == "step-rc-201")
1672 .unwrap();
1673
1674 assert!(commit_step.step.parents.is_empty());
1676 assert_eq!(rc1_step.step.parents, vec!["step-abc12345"]);
1678 assert_eq!(rc2_step.step.parents, vec!["step-rc-200"]);
1680 assert_eq!(path.path.head, "step-rc-200");
1682 }
1683 }
1684}
1685
1686#[cfg(not(target_os = "emscripten"))]
1688pub use native::{derive_pull_request, list_pull_requests, resolve_token};
1689
1690#[cfg(test)]
1691mod tests {
1692 use super::*;
1693
1694 #[test]
1695 fn test_extract_issue_refs_basic() {
1696 let refs = extract_issue_refs("Fixes #42");
1697 assert_eq!(refs, vec![42]);
1698 }
1699
1700 #[test]
1701 fn test_extract_issue_refs_multiple() {
1702 let refs = extract_issue_refs("Fixes #10 and Closes #20");
1703 assert_eq!(refs, vec![10, 20]);
1704 }
1705
1706 #[test]
1707 fn test_extract_issue_refs_case_insensitive() {
1708 let refs = extract_issue_refs("FIXES #1, closes #2, Resolves #3");
1709 assert_eq!(refs, vec![1, 2, 3]);
1710 }
1711
1712 #[test]
1713 fn test_extract_issue_refs_no_refs() {
1714 let refs = extract_issue_refs("Just a regular PR description.");
1715 assert!(refs.is_empty());
1716 }
1717
1718 #[test]
1719 fn test_extract_issue_refs_dedup() {
1720 let refs = extract_issue_refs("Fixes #5 and also fixes #5");
1721 assert_eq!(refs, vec![5]);
1722 }
1723
1724 #[test]
1725 fn test_extract_issue_refs_multiline() {
1726 let body = "This is a PR.\n\nFixes #100\nCloses #200\n\nSome more text.";
1727 let refs = extract_issue_refs(body);
1728 assert_eq!(refs, vec![100, 200]);
1729 }
1730
1731 #[test]
1732 fn test_derive_config_default() {
1733 let config = DeriveConfig::default();
1734 assert_eq!(config.api_url, "https://api.github.com");
1735 assert!(config.include_ci);
1736 assert!(config.include_comments);
1737 assert!(config.token.is_empty());
1738 }
1739
1740 #[test]
1741 fn test_parse_pr_url_https() {
1742 let pr = parse_pr_url("https://github.com/empathic/toolpath/pull/6").unwrap();
1743 assert_eq!(pr.owner, "empathic");
1744 assert_eq!(pr.repo, "toolpath");
1745 assert_eq!(pr.number, 6);
1746 }
1747
1748 #[test]
1749 fn test_parse_pr_url_no_protocol() {
1750 let pr = parse_pr_url("github.com/empathic/toolpath/pull/42").unwrap();
1751 assert_eq!(pr.owner, "empathic");
1752 assert_eq!(pr.repo, "toolpath");
1753 assert_eq!(pr.number, 42);
1754 }
1755
1756 #[test]
1757 fn test_parse_pr_url_http() {
1758 let pr = parse_pr_url("http://github.com/org/repo/pull/1").unwrap();
1759 assert_eq!(pr.owner, "org");
1760 assert_eq!(pr.repo, "repo");
1761 assert_eq!(pr.number, 1);
1762 }
1763
1764 #[test]
1765 fn test_parse_pr_url_with_trailing_parts() {
1766 let pr = parse_pr_url("https://github.com/org/repo/pull/99/files").unwrap();
1767 assert_eq!(pr.number, 99);
1768 }
1769
1770 #[test]
1771 fn test_parse_pr_url_with_query_string() {
1772 let pr = parse_pr_url("https://github.com/org/repo/pull/5?diff=unified").unwrap();
1773 assert_eq!(pr.number, 5);
1774 }
1775
1776 #[test]
1777 fn test_parse_pr_url_invalid() {
1778 assert!(parse_pr_url("not a url").is_none());
1779 assert!(parse_pr_url("https://github.com/org/repo").is_none());
1780 assert!(parse_pr_url("https://github.com/org/repo/issues/1").is_none());
1781 assert!(parse_pr_url("https://gitlab.com/org/repo/pull/1").is_none());
1782 }
1783}