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: Some(pr["base"]["ref"].as_str().unwrap_or("main").to_string()),
531 }),
532 head,
533 graph_ref: None,
534 },
535 steps,
536 meta: Some(meta),
537 })
538 }
539
540 fn commit_to_step(
545 detail: &serde_json::Value,
546 actors: &mut HashMap<String, ActorDefinition>,
547 actor_associations: &mut HashMap<String, String>,
548 ) -> Result<Step> {
549 let sha = detail["sha"].as_str().unwrap_or_default();
550 let short_sha = &sha[..sha.len().min(8)];
551 let step_id = format!("step-{}", short_sha);
552
553 let login = detail["author"]["login"].as_str().unwrap_or("unknown");
555 let actor = format!("human:{}", login);
556 let association = detail["author_association"].as_str();
557 register_actor(actors, actor_associations, &actor, login, association);
558
559 let timestamp = detail["commit"]["committer"]["date"]
561 .as_str()
562 .unwrap_or("1970-01-01T00:00:00Z")
563 .to_string();
564
565 let mut change: HashMap<String, ArtifactChange> = HashMap::new();
567 if let Some(files) = detail["files"].as_array() {
568 for file in files {
569 let filename = file["filename"].as_str().unwrap_or("unknown");
570 if let Some(patch) = file["patch"].as_str() {
571 change.insert(filename.to_string(), ArtifactChange::raw(patch));
572 }
573 }
574 }
575
576 let message = detail["commit"]["message"].as_str().unwrap_or("");
578 let intent = message.lines().next().unwrap_or("").to_string();
579
580 let mut step = Step {
581 step: StepIdentity {
582 id: step_id,
583 parents: vec![],
584 actor,
585 timestamp,
586 },
587 change,
588 meta: None,
589 };
590
591 if !intent.is_empty() {
592 step.meta = Some(StepMeta {
593 intent: Some(intent),
594 source: Some(toolpath::v1::VcsSource {
595 vcs_type: "git".to_string(),
596 revision: sha.to_string(),
597 change_id: None,
598 extra: HashMap::new(),
599 }),
600 ..Default::default()
601 });
602 }
603
604 Ok(step)
605 }
606
607 fn review_comment_to_step(
608 rc: &serde_json::Value,
609 actors: &mut HashMap<String, ActorDefinition>,
610 actor_associations: &mut HashMap<String, String>,
611 ) -> Result<Step> {
612 let id = rc["id"].as_u64().unwrap_or(0);
613 let step_id = format!("step-rc-{}", id);
614
615 let login = rc["user"]["login"].as_str().unwrap_or("unknown");
616 let actor = format!("human:{}", login);
617 let association = rc["author_association"].as_str();
618 register_actor(actors, actor_associations, &actor, login, association);
619
620 let timestamp = rc["created_at"]
621 .as_str()
622 .unwrap_or("1970-01-01T00:00:00Z")
623 .to_string();
624
625 let path = rc["path"].as_str().unwrap_or("unknown");
626 let line = rc["line"]
627 .as_u64()
628 .or_else(|| rc["original_line"].as_u64())
629 .unwrap_or(0);
630 let artifact_uri = format!("review://{}#L{}", path, line);
631
632 let body = rc["body"].as_str().unwrap_or("").to_string();
633 let diff_hunk = rc["diff_hunk"].as_str().map(|s| s.to_string());
634
635 let mut extra = HashMap::new();
636 extra.insert("body".to_string(), serde_json::Value::String(body));
637
638 let change = HashMap::from([(
639 artifact_uri,
640 ArtifactChange {
641 raw: diff_hunk,
642 structural: Some(StructuralChange {
643 change_type: "review.comment".to_string(),
644 extra,
645 }),
646 },
647 )]);
648
649 let meta = if let Some(reply_to) = rc["in_reply_to_id"].as_u64() {
651 let mut step_extra = HashMap::new();
652 let mut gh_extra = serde_json::Map::new();
653 gh_extra.insert("in_reply_to_id".to_string(), serde_json::json!(reply_to));
654 step_extra.insert("github".to_string(), serde_json::Value::Object(gh_extra));
655 Some(StepMeta {
656 extra: step_extra,
657 ..Default::default()
658 })
659 } else {
660 None
661 };
662
663 Ok(Step {
664 step: StepIdentity {
665 id: step_id,
666 parents: vec![],
667 actor,
668 timestamp,
669 },
670 change,
671 meta,
672 })
673 }
674
675 fn pr_comment_to_step(
676 pc: &serde_json::Value,
677 actors: &mut HashMap<String, ActorDefinition>,
678 actor_associations: &mut HashMap<String, String>,
679 ) -> Result<Step> {
680 let id = pc["id"].as_u64().unwrap_or(0);
681 let step_id = format!("step-ic-{}", id);
682
683 let timestamp = pc["created_at"]
684 .as_str()
685 .unwrap_or("1970-01-01T00:00:00Z")
686 .to_string();
687
688 let login = pc["user"]["login"].as_str().unwrap_or("unknown");
689 let actor = format!("human:{}", login);
690 let association = pc["author_association"].as_str();
691 register_actor(actors, actor_associations, &actor, login, association);
692
693 let body = pc["body"].as_str().unwrap_or("").to_string();
694
695 let mut extra = HashMap::new();
696 extra.insert("body".to_string(), serde_json::Value::String(body));
697
698 let change = HashMap::from([(
699 "review://conversation".to_string(),
700 ArtifactChange {
701 raw: None,
702 structural: Some(StructuralChange {
703 change_type: "review.conversation".to_string(),
704 extra,
705 }),
706 },
707 )]);
708
709 Ok(Step {
710 step: StepIdentity {
711 id: step_id,
712 parents: vec![],
713 actor,
714 timestamp,
715 },
716 change,
717 meta: None,
718 })
719 }
720
721 fn review_to_step(
722 review: &serde_json::Value,
723 actors: &mut HashMap<String, ActorDefinition>,
724 actor_associations: &mut HashMap<String, String>,
725 ) -> Result<Step> {
726 let id = review["id"].as_u64().unwrap_or(0);
727 let step_id = format!("step-rv-{}", id);
728
729 let timestamp = review["submitted_at"]
730 .as_str()
731 .unwrap_or("1970-01-01T00:00:00Z")
732 .to_string();
733
734 let login = review["user"]["login"].as_str().unwrap_or("unknown");
735 let actor = format!("human:{}", login);
736 let association = review["author_association"].as_str();
737 register_actor(actors, actor_associations, &actor, login, association);
738
739 let state = review["state"].as_str().unwrap_or("COMMENTED").to_string();
740 let body = review["body"].as_str().unwrap_or("").to_string();
741
742 let mut extra = HashMap::new();
743 extra.insert("state".to_string(), serde_json::Value::String(state));
744
745 let change = HashMap::from([(
746 "review://decision".to_string(),
747 ArtifactChange {
748 raw: if body.is_empty() {
749 None
750 } else {
751 Some(body.clone())
752 },
753 structural: Some(StructuralChange {
754 change_type: "review.decision".to_string(),
755 extra,
756 }),
757 },
758 )]);
759
760 let meta = if !body.is_empty() {
762 let intent = if body.len() > 500 {
763 format!("{}...", &body[..500])
764 } else {
765 body
766 };
767 Some(StepMeta {
768 intent: Some(intent),
769 ..Default::default()
770 })
771 } else {
772 None
773 };
774
775 Ok(Step {
776 step: StepIdentity {
777 id: step_id,
778 parents: vec![],
779 actor,
780 timestamp,
781 },
782 change,
783 meta,
784 })
785 }
786
787 fn check_run_to_step(
788 run: &serde_json::Value,
789 actors: &mut HashMap<String, ActorDefinition>,
790 ) -> Result<Step> {
791 let id = run["id"].as_u64().unwrap_or(0);
792 let step_id = format!("step-ci-{}", id);
793
794 let name = run["name"].as_str().unwrap_or("unknown");
795 let app_slug = run["app"]["slug"].as_str().unwrap_or("ci");
796 let actor = format!("ci:{}", app_slug);
797
798 actors
799 .entry(actor.clone())
800 .or_insert_with(|| ActorDefinition {
801 name: Some(app_slug.to_string()),
802 ..Default::default()
803 });
804
805 let timestamp = run["completed_at"]
806 .as_str()
807 .or_else(|| run["started_at"].as_str())
808 .unwrap_or("1970-01-01T00:00:00Z")
809 .to_string();
810
811 let conclusion = run["conclusion"].as_str().unwrap_or("unknown").to_string();
812
813 let mut extra = HashMap::new();
814 extra.insert(
815 "conclusion".to_string(),
816 serde_json::Value::String(conclusion),
817 );
818 if let Some(html_url) = run["html_url"].as_str() {
819 extra.insert(
820 "url".to_string(),
821 serde_json::Value::String(html_url.to_string()),
822 );
823 }
824
825 let artifact_uri = format!("ci://checks/{}", name);
826 let change = HashMap::from([(
827 artifact_uri,
828 ArtifactChange {
829 raw: None,
830 structural: Some(StructuralChange {
831 change_type: "ci.run".to_string(),
832 extra,
833 }),
834 },
835 )]);
836
837 Ok(Step {
838 step: StepIdentity {
839 id: step_id,
840 parents: vec![],
841 actor,
842 timestamp,
843 },
844 change,
845 meta: None,
846 })
847 }
848
849 fn build_path_meta(
850 pr: &serde_json::Value,
851 actors: &HashMap<String, ActorDefinition>,
852 actor_associations: &HashMap<String, String>,
853 ) -> Result<PathMeta> {
854 let title = pr["title"].as_str().map(|s| s.to_string());
855 let body = pr["body"].as_str().unwrap_or("");
856 let intent = if body.is_empty() {
857 None
858 } else {
859 Some(body.to_string())
860 };
861
862 let issue_numbers = extract_issue_refs(body);
864 let refs: Vec<Ref> = issue_numbers
865 .into_iter()
866 .map(|n| {
867 let owner = pr["base"]["repo"]["owner"]["login"]
868 .as_str()
869 .unwrap_or("unknown");
870 let repo = pr["base"]["repo"]["name"].as_str().unwrap_or("unknown");
871 Ref {
872 rel: "fixes".to_string(),
873 href: format!("https://github.com/{}/{}/issues/{}", owner, repo, n),
874 }
875 })
876 .collect();
877
878 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
880 let mut github_meta = serde_json::Map::new();
881
882 if let Some(number) = pr["number"].as_u64() {
884 github_meta.insert("number".to_string(), serde_json::json!(number));
885 }
886 if let Some(author) = pr["user"]["login"].as_str() {
887 github_meta.insert(
888 "author".to_string(),
889 serde_json::Value::String(author.to_string()),
890 );
891 }
892 if let Some(state) = pr["state"].as_str() {
893 github_meta.insert(
894 "state".to_string(),
895 serde_json::Value::String(state.to_string()),
896 );
897 }
898 if let Some(draft) = pr["draft"].as_bool() {
899 github_meta.insert("draft".to_string(), serde_json::json!(draft));
900 }
901
902 if let Some(merged) = pr["merged"].as_bool() {
904 github_meta.insert("merged".to_string(), serde_json::json!(merged));
905 }
906 if let Some(merged_at) = pr["merged_at"].as_str() {
907 github_meta.insert(
908 "merged_at".to_string(),
909 serde_json::Value::String(merged_at.to_string()),
910 );
911 }
912 if let Some(merged_by) = pr["merged_by"]["login"].as_str() {
913 github_meta.insert(
914 "merged_by".to_string(),
915 serde_json::Value::String(merged_by.to_string()),
916 );
917 }
918
919 if let Some(additions) = pr["additions"].as_u64() {
921 github_meta.insert("additions".to_string(), serde_json::json!(additions));
922 }
923 if let Some(deletions) = pr["deletions"].as_u64() {
924 github_meta.insert("deletions".to_string(), serde_json::json!(deletions));
925 }
926 if let Some(changed_files) = pr["changed_files"].as_u64() {
927 github_meta.insert(
928 "changed_files".to_string(),
929 serde_json::json!(changed_files),
930 );
931 }
932
933 if let Some(labels) = pr["labels"].as_array() {
935 let label_names: Vec<serde_json::Value> = labels
936 .iter()
937 .filter_map(|l| l["name"].as_str())
938 .map(|s| serde_json::Value::String(s.to_string()))
939 .collect();
940 if !label_names.is_empty() {
941 github_meta.insert("labels".to_string(), serde_json::Value::Array(label_names));
942 }
943 }
944
945 if !actor_associations.is_empty() {
947 let assoc_map: serde_json::Map<String, serde_json::Value> = actor_associations
948 .iter()
949 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
950 .collect();
951 github_meta.insert(
952 "actor_associations".to_string(),
953 serde_json::Value::Object(assoc_map),
954 );
955 }
956
957 if !github_meta.is_empty() {
958 extra.insert("github".to_string(), serde_json::Value::Object(github_meta));
959 }
960
961 Ok(PathMeta {
962 title,
963 intent,
964 refs,
965 actors: if actors.is_empty() {
966 None
967 } else {
968 Some(actors.clone())
969 },
970 extra,
971 ..Default::default()
972 })
973 }
974
975 fn register_actor(
980 actors: &mut HashMap<String, ActorDefinition>,
981 actor_associations: &mut HashMap<String, String>,
982 actor_key: &str,
983 login: &str,
984 association: Option<&str>,
985 ) {
986 actors
987 .entry(actor_key.to_string())
988 .or_insert_with(|| ActorDefinition {
989 name: Some(login.to_string()),
990 identities: vec![Identity {
991 system: "github".to_string(),
992 id: login.to_string(),
993 }],
994 ..Default::default()
995 });
996 if let Some(assoc) = association
997 && assoc != "NONE"
998 {
999 actor_associations
1000 .entry(actor_key.to_string())
1001 .or_insert_with(|| assoc.to_string());
1002 }
1003 }
1004
1005 fn str_field(val: &serde_json::Value, key: &str) -> String {
1006 val[key].as_str().unwrap_or("").to_string()
1007 }
1008
1009 #[cfg(test)]
1014 mod tests {
1015 use super::*;
1016
1017 fn sample_pr() -> serde_json::Value {
1018 serde_json::json!({
1019 "number": 42,
1020 "title": "Add feature X",
1021 "body": "This PR adds feature X.\n\nFixes #10\nCloses #20",
1022 "state": "open",
1023 "draft": false,
1024 "merged": false,
1025 "merged_at": null,
1026 "merged_by": null,
1027 "additions": 150,
1028 "deletions": 30,
1029 "changed_files": 5,
1030 "user": { "login": "alice" },
1031 "head": { "ref": "feature-x" },
1032 "base": {
1033 "ref": "main",
1034 "repo": {
1035 "owner": { "login": "acme" },
1036 "name": "widgets"
1037 }
1038 },
1039 "labels": [
1040 { "name": "enhancement" },
1041 { "name": "reviewed" }
1042 ],
1043 "created_at": "2026-01-15T10:00:00Z",
1044 "updated_at": "2026-01-16T14:00:00Z"
1045 })
1046 }
1047
1048 fn sample_commit_detail(
1049 sha: &str,
1050 parent_sha: Option<&str>,
1051 msg: &str,
1052 ) -> serde_json::Value {
1053 let parents: Vec<serde_json::Value> = parent_sha
1054 .into_iter()
1055 .map(|s| serde_json::json!({ "sha": s }))
1056 .collect();
1057 serde_json::json!({
1058 "sha": sha,
1059 "commit": {
1060 "message": msg,
1061 "committer": {
1062 "date": "2026-01-15T12:00:00Z"
1063 }
1064 },
1065 "author": { "login": "alice" },
1066 "parents": parents,
1067 "files": [
1068 {
1069 "filename": "src/main.rs",
1070 "patch": "@@ -1,3 +1,4 @@\n fn main() {\n+ println!(\"hello\");\n }"
1071 }
1072 ]
1073 })
1074 }
1075
1076 fn sample_review_comment(
1077 id: u64,
1078 commit_sha: &str,
1079 path: &str,
1080 line: u64,
1081 ) -> serde_json::Value {
1082 serde_json::json!({
1083 "id": id,
1084 "user": { "login": "bob" },
1085 "commit_id": commit_sha,
1086 "path": path,
1087 "line": line,
1088 "body": "Consider using a constant here.",
1089 "diff_hunk": "@@ -10,6 +10,7 @@\n fn example() {\n+ let x = 42;\n }",
1090 "author_association": "COLLABORATOR",
1091 "created_at": "2026-01-15T14:00:00Z",
1092 "pull_request_review_id": 100,
1093 "in_reply_to_id": null
1094 })
1095 }
1096
1097 fn sample_pr_comment(id: u64) -> serde_json::Value {
1098 serde_json::json!({
1099 "id": id,
1100 "user": { "login": "carol" },
1101 "body": "Looks good overall!",
1102 "author_association": "CONTRIBUTOR",
1103 "created_at": "2026-01-15T16:00:00Z"
1104 })
1105 }
1106
1107 fn sample_review(id: u64, state: &str) -> serde_json::Value {
1108 serde_json::json!({
1109 "id": id,
1110 "user": { "login": "dave" },
1111 "state": state,
1112 "body": "Approved with minor comments.",
1113 "author_association": "MEMBER",
1114 "submitted_at": "2026-01-15T17:00:00Z"
1115 })
1116 }
1117
1118 fn sample_check_run(id: u64, name: &str, conclusion: &str) -> serde_json::Value {
1119 serde_json::json!({
1120 "id": id,
1121 "name": name,
1122 "app": { "slug": "github-actions" },
1123 "conclusion": conclusion,
1124 "html_url": format!("https://github.com/acme/widgets/actions/runs/{}", id),
1125 "completed_at": "2026-01-15T13:00:00Z",
1126 "started_at": "2026-01-15T12:30:00Z"
1127 })
1128 }
1129
1130 #[test]
1131 fn test_commit_to_step() {
1132 let detail = sample_commit_detail("abc12345deadbeef", None, "Initial commit");
1133 let mut actors = HashMap::new();
1134 let mut assoc = HashMap::new();
1135
1136 let step = commit_to_step(&detail, &mut actors, &mut assoc).unwrap();
1137
1138 assert_eq!(step.step.id, "step-abc12345");
1139 assert_eq!(step.step.actor, "human:alice");
1140 assert!(step.step.parents.is_empty());
1141 assert!(step.change.contains_key("src/main.rs"));
1142 assert_eq!(
1143 step.meta.as_ref().unwrap().intent.as_deref(),
1144 Some("Initial commit")
1145 );
1146 assert!(actors.contains_key("human:alice"));
1147 }
1148
1149 #[test]
1150 fn test_review_comment_to_step() {
1151 let rc = sample_review_comment(200, "abc12345deadbeef", "src/main.rs", 42);
1152 let mut actors = HashMap::new();
1153 let mut assoc = HashMap::new();
1154
1155 let step = review_comment_to_step(&rc, &mut actors, &mut assoc).unwrap();
1156
1157 assert_eq!(step.step.id, "step-rc-200");
1158 assert_eq!(step.step.actor, "human:bob");
1159 assert!(step.step.parents.is_empty());
1161 assert!(step.change.contains_key("review://src/main.rs#L42"));
1162 assert!(actors.contains_key("human:bob"));
1163 let change = &step.change["review://src/main.rs#L42"];
1165 assert!(change.raw.is_some());
1166 assert!(change.raw.as_deref().unwrap().contains("let x = 42"));
1167 assert_eq!(
1169 assoc.get("human:bob").map(|s| s.as_str()),
1170 Some("COLLABORATOR")
1171 );
1172 }
1173
1174 #[test]
1175 fn test_pr_comment_to_step() {
1176 let pc = sample_pr_comment(300);
1177 let mut actors = HashMap::new();
1178 let mut assoc = HashMap::new();
1179
1180 let step = pr_comment_to_step(&pc, &mut actors, &mut assoc).unwrap();
1181
1182 assert_eq!(step.step.id, "step-ic-300");
1183 assert_eq!(step.step.actor, "human:carol");
1184 assert!(step.step.parents.is_empty());
1185 assert!(step.change.contains_key("review://conversation"));
1186 let change = &step.change["review://conversation"];
1187 assert!(change.structural.is_some());
1188 let structural = change.structural.as_ref().unwrap();
1189 assert_eq!(structural.change_type, "review.conversation");
1190 assert_eq!(structural.extra["body"], "Looks good overall!");
1191 }
1192
1193 #[test]
1194 fn test_review_to_step() {
1195 let review = sample_review(400, "APPROVED");
1196 let mut actors = HashMap::new();
1197 let mut assoc = HashMap::new();
1198
1199 let step = review_to_step(&review, &mut actors, &mut assoc).unwrap();
1200
1201 assert_eq!(step.step.id, "step-rv-400");
1202 assert_eq!(step.step.actor, "human:dave");
1203 assert!(step.step.parents.is_empty());
1204 assert!(step.change.contains_key("review://decision"));
1205 let change = &step.change["review://decision"];
1206 assert!(change.structural.is_some());
1207 let structural = change.structural.as_ref().unwrap();
1208 assert_eq!(structural.change_type, "review.decision");
1209 assert_eq!(structural.extra["state"], "APPROVED");
1210 assert_eq!(
1212 step.meta.as_ref().unwrap().intent.as_deref(),
1213 Some("Approved with minor comments.")
1214 );
1215 }
1216
1217 #[test]
1218 fn test_check_run_to_step() {
1219 let run = sample_check_run(500, "build", "success");
1220 let mut actors = HashMap::new();
1221
1222 let step = check_run_to_step(&run, &mut actors).unwrap();
1223
1224 assert_eq!(step.step.id, "step-ci-500");
1225 assert_eq!(step.step.actor, "ci:github-actions");
1226 assert!(step.step.parents.is_empty());
1227 assert!(step.change.contains_key("ci://checks/build"));
1228 let change = &step.change["ci://checks/build"];
1229 let structural = change.structural.as_ref().unwrap();
1230 assert_eq!(structural.change_type, "ci.run");
1231 assert_eq!(structural.extra["conclusion"], "success");
1232 assert!(
1234 structural.extra["url"]
1235 .as_str()
1236 .unwrap()
1237 .contains("actions/runs/500")
1238 );
1239 }
1240
1241 #[test]
1242 fn test_build_path_meta() {
1243 let pr = sample_pr();
1244 let mut actors = HashMap::new();
1245 let mut assoc = HashMap::new();
1246 register_actor(
1247 &mut actors,
1248 &mut assoc,
1249 "human:alice",
1250 "alice",
1251 Some("MEMBER"),
1252 );
1253
1254 let meta = build_path_meta(&pr, &actors, &assoc).unwrap();
1255
1256 assert_eq!(meta.title.as_deref(), Some("Add feature X"));
1257 assert!(meta.intent.as_deref().unwrap().contains("feature X"));
1258 assert_eq!(meta.refs.len(), 2);
1259 assert_eq!(meta.refs[0].rel, "fixes");
1260 assert!(meta.refs[0].href.contains("/issues/10"));
1261 assert!(meta.refs[1].href.contains("/issues/20"));
1262 assert!(meta.actors.is_some());
1263
1264 let github = meta.extra.get("github").unwrap();
1266 let labels = github["labels"].as_array().unwrap();
1267 assert_eq!(labels.len(), 2);
1268 assert_eq!(github["state"], "open");
1269 assert_eq!(github["additions"], 150);
1270 assert_eq!(github["deletions"], 30);
1271 assert_eq!(github["changed_files"], 5);
1272 assert_eq!(github["number"], 42);
1273 assert_eq!(github["author"], "alice");
1274 assert_eq!(github["draft"], false);
1275 assert_eq!(github["merged"], false);
1276 assert_eq!(github["actor_associations"]["human:alice"], "MEMBER");
1278 }
1279
1280 #[test]
1281 fn test_derive_from_data_full() {
1282 let pr = sample_pr();
1283 let commit1 = sample_commit_detail("abc12345deadbeef", None, "Initial commit");
1284 let commit2 =
1285 sample_commit_detail("def67890cafebabe", Some("abc12345deadbeef"), "Add tests");
1286 let mut commit2 = commit2;
1288 commit2["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T13:00:00Z");
1289
1290 let review_comments = vec![sample_review_comment(
1291 200,
1292 "abc12345deadbeef",
1293 "src/main.rs",
1294 42,
1295 )];
1296 let pr_comments = vec![sample_pr_comment(300)];
1297 let reviews = vec![sample_review(400, "APPROVED")];
1298
1299 let mut check_runs = HashMap::new();
1300 check_runs.insert(
1301 "abc12345deadbeef".to_string(),
1302 vec![sample_check_run(500, "build", "success")],
1303 );
1304
1305 let config = DeriveConfig {
1306 token: "test".to_string(),
1307 api_url: "https://api.github.com".to_string(),
1308 include_ci: true,
1309 include_comments: true,
1310 };
1311
1312 let data = PrData {
1313 pr: &pr,
1314 commit_details: &[commit1, commit2],
1315 reviews: &reviews,
1316 pr_comments: &pr_comments,
1317 review_comments: &review_comments,
1318 check_runs_by_sha: &check_runs,
1319 };
1320 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1321
1322 assert_eq!(path.path.id, "pr-42");
1323 assert_eq!(path.path.base.as_ref().unwrap().uri, "github:acme/widgets");
1324 assert_eq!(
1325 path.path.base.as_ref().unwrap().ref_str.as_deref(),
1326 Some("main")
1327 );
1328
1329 assert_eq!(path.steps.len(), 6);
1331
1332 assert!(path.steps[0].step.parents.is_empty());
1334 for i in 1..path.steps.len() {
1335 assert!(
1336 path.steps[i].step.timestamp >= path.steps[i - 1].step.timestamp,
1337 "Steps not sorted: {} < {}",
1338 path.steps[i].step.timestamp,
1339 path.steps[i - 1].step.timestamp,
1340 );
1341 assert_eq!(
1342 path.steps[i].step.parents,
1343 vec![path.steps[i - 1].step.id.clone()],
1344 "Step {} should parent off step {}",
1345 path.steps[i].step.id,
1346 path.steps[i - 1].step.id,
1347 );
1348 }
1349
1350 let meta = path.meta.as_ref().unwrap();
1352 assert_eq!(meta.title.as_deref(), Some("Add feature X"));
1353 assert_eq!(meta.refs.len(), 2);
1354 }
1355
1356 #[test]
1357 fn test_derive_from_data_no_ci() {
1358 let pr = sample_pr();
1359 let commit = sample_commit_detail("abc12345deadbeef", None, "Commit");
1360
1361 let config = DeriveConfig {
1362 token: "test".to_string(),
1363 api_url: "https://api.github.com".to_string(),
1364 include_ci: false,
1365 include_comments: false,
1366 };
1367
1368 let data = PrData {
1369 pr: &pr,
1370 commit_details: &[commit],
1371 reviews: &[],
1372 pr_comments: &[],
1373 review_comments: &[],
1374 check_runs_by_sha: &HashMap::new(),
1375 };
1376 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1377
1378 assert_eq!(path.steps.len(), 1);
1380 assert_eq!(path.steps[0].step.id, "step-abc12345");
1381 }
1382
1383 #[test]
1384 fn test_derive_from_data_pending_review_skipped() {
1385 let pr = sample_pr();
1386 let commit = sample_commit_detail("abc12345deadbeef", None, "Commit");
1387 let pending_review = sample_review(999, "PENDING");
1388
1389 let config = DeriveConfig {
1390 token: "test".to_string(),
1391 api_url: "https://api.github.com".to_string(),
1392 include_ci: false,
1393 include_comments: true,
1394 };
1395
1396 let data = PrData {
1397 pr: &pr,
1398 commit_details: &[commit],
1399 reviews: &[pending_review],
1400 pr_comments: &[],
1401 review_comments: &[],
1402 check_runs_by_sha: &HashMap::new(),
1403 };
1404 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1405
1406 assert_eq!(path.steps.len(), 1);
1408 }
1409
1410 #[test]
1411 fn test_parse_next_link() {
1412 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""#;
1413 assert_eq!(
1414 parse_next_link(header),
1415 Some("https://api.github.com/repos/foo/bar/pulls?page=2".to_string())
1416 );
1417
1418 assert_eq!(
1419 parse_next_link(r#"<https://example.com>; rel="prev""#),
1420 None
1421 );
1422 }
1423
1424 #[test]
1425 fn test_str_field() {
1426 let val = serde_json::json!({"name": "hello", "missing": null});
1427 assert_eq!(str_field(&val, "name"), "hello");
1428 assert_eq!(str_field(&val, "missing"), "");
1429 assert_eq!(str_field(&val, "nonexistent"), "");
1430 }
1431
1432 #[test]
1433 fn test_register_actor_idempotent() {
1434 let mut actors = HashMap::new();
1435 let mut assoc = HashMap::new();
1436 register_actor(&mut actors, &mut assoc, "human:alice", "alice", None);
1437 register_actor(&mut actors, &mut assoc, "human:alice", "alice", None);
1438 assert_eq!(actors.len(), 1);
1439 }
1440
1441 #[test]
1442 fn test_ci_steps_chain_inline() {
1443 let pr = sample_pr();
1444 let commit = sample_commit_detail("abc12345deadbeef", None, "Commit");
1445
1446 let mut check_runs = HashMap::new();
1447 check_runs.insert(
1448 "abc12345deadbeef".to_string(),
1449 vec![
1450 sample_check_run(501, "build", "success"),
1451 sample_check_run(502, "test", "success"),
1452 sample_check_run(503, "lint", "success"),
1453 ],
1454 );
1455
1456 let config = DeriveConfig {
1457 token: "test".to_string(),
1458 api_url: "https://api.github.com".to_string(),
1459 include_ci: true,
1460 include_comments: false,
1461 };
1462
1463 let data = PrData {
1464 pr: &pr,
1465 commit_details: &[commit],
1466 reviews: &[],
1467 pr_comments: &[],
1468 review_comments: &[],
1469 check_runs_by_sha: &check_runs,
1470 };
1471 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1472
1473 assert_eq!(path.steps.len(), 4);
1475
1476 assert!(path.steps[0].step.parents.is_empty()); for i in 1..path.steps.len() {
1479 assert_eq!(
1480 path.steps[i].step.parents,
1481 vec![path.steps[i - 1].step.id.clone()]
1482 );
1483 }
1484 }
1485
1486 #[test]
1487 fn test_review_comment_artifact_uri_format() {
1488 let rc = sample_review_comment(700, "abc12345", "src/lib.rs", 100);
1489 let mut actors = HashMap::new();
1490 let mut assoc = HashMap::new();
1491
1492 let step = review_comment_to_step(&rc, &mut actors, &mut assoc).unwrap();
1493
1494 assert!(step.change.contains_key("review://src/lib.rs#L100"));
1495 }
1496
1497 #[test]
1498 fn test_derive_from_data_empty_commits() {
1499 let pr = sample_pr();
1500 let config = DeriveConfig {
1501 token: "test".to_string(),
1502 api_url: "https://api.github.com".to_string(),
1503 include_ci: false,
1504 include_comments: false,
1505 };
1506
1507 let data = PrData {
1508 pr: &pr,
1509 commit_details: &[],
1510 reviews: &[],
1511 pr_comments: &[],
1512 review_comments: &[],
1513 check_runs_by_sha: &HashMap::new(),
1514 };
1515 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1516
1517 assert_eq!(path.path.id, "pr-42");
1518 assert!(path.steps.is_empty());
1519 assert_eq!(path.path.head, "pr-42");
1520 }
1521
1522 #[test]
1523 fn test_review_empty_body() {
1524 let mut review = sample_review(800, "APPROVED");
1525 review["body"] = serde_json::json!("");
1526 let mut actors = HashMap::new();
1527 let mut assoc = HashMap::new();
1528
1529 let step = review_to_step(&review, &mut actors, &mut assoc).unwrap();
1530 let change = &step.change["review://decision"];
1531 assert!(change.raw.is_none());
1532 assert!(change.structural.is_some());
1533 assert!(step.meta.is_none());
1535 }
1536
1537 #[test]
1538 fn test_commit_no_files() {
1539 let detail = serde_json::json!({
1540 "sha": "aabbccdd11223344",
1541 "commit": {
1542 "message": "Empty commit",
1543 "committer": { "date": "2026-01-15T12:00:00Z" }
1544 },
1545 "author": { "login": "alice" },
1546 "parents": [],
1547 "files": []
1548 });
1549 let mut actors = HashMap::new();
1550 let mut assoc = HashMap::new();
1551
1552 let step = commit_to_step(&detail, &mut actors, &mut assoc).unwrap();
1553 assert!(step.change.is_empty());
1554 }
1555
1556 #[test]
1557 fn test_multiple_commits_chain() {
1558 let pr = sample_pr();
1559 let c1 = {
1560 let mut c = sample_commit_detail("1111111100000000", None, "First");
1561 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T10:00:00Z");
1562 c
1563 };
1564 let c2 = {
1565 let mut c =
1566 sample_commit_detail("2222222200000000", Some("1111111100000000"), "Second");
1567 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T11:00:00Z");
1568 c
1569 };
1570 let c3 = {
1571 let mut c =
1572 sample_commit_detail("3333333300000000", Some("2222222200000000"), "Third");
1573 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T12:00:00Z");
1574 c
1575 };
1576
1577 let config = DeriveConfig {
1578 token: "test".to_string(),
1579 api_url: "https://api.github.com".to_string(),
1580 include_ci: false,
1581 include_comments: false,
1582 };
1583
1584 let data = PrData {
1585 pr: &pr,
1586 commit_details: &[c1, c2, c3],
1587 reviews: &[],
1588 pr_comments: &[],
1589 review_comments: &[],
1590 check_runs_by_sha: &HashMap::new(),
1591 };
1592 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1593
1594 assert_eq!(path.steps.len(), 3);
1596 assert!(path.steps[0].step.parents.is_empty());
1597 assert_eq!(path.steps[1].step.parents, vec!["step-11111111"]);
1598 assert_eq!(path.steps[2].step.parents, vec!["step-22222222"]);
1599 assert_eq!(path.path.head, "step-33333333");
1600 }
1601
1602 #[test]
1603 fn test_reply_threading() {
1604 let pr = sample_pr();
1605 let commit = {
1606 let mut c = sample_commit_detail("abc12345deadbeef", None, "Commit");
1607 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T10:00:00Z");
1608 c
1609 };
1610
1611 let rc1 = {
1613 let mut rc = sample_review_comment(200, "abc12345deadbeef", "src/main.rs", 42);
1614 rc["created_at"] = serde_json::json!("2026-01-15T14:00:00Z");
1615 rc
1616 };
1617 let rc2 = serde_json::json!({
1619 "id": 201,
1620 "user": { "login": "alice" },
1621 "commit_id": "abc12345deadbeef",
1622 "path": "src/main.rs",
1623 "line": 42,
1624 "body": "Good point, I'll fix that.",
1625 "diff_hunk": "@@ -10,6 +10,7 @@\n fn example() {\n+ let x = 42;\n }",
1626 "author_association": "CONTRIBUTOR",
1627 "created_at": "2026-01-15T15:00:00Z",
1628 "pull_request_review_id": 100,
1629 "in_reply_to_id": 200
1630 });
1631
1632 let config = DeriveConfig {
1633 token: "test".to_string(),
1634 api_url: "https://api.github.com".to_string(),
1635 include_ci: false,
1636 include_comments: true,
1637 };
1638
1639 let data = PrData {
1640 pr: &pr,
1641 commit_details: &[commit],
1642 reviews: &[],
1643 pr_comments: &[],
1644 review_comments: &[rc1, rc2],
1645 check_runs_by_sha: &HashMap::new(),
1646 };
1647 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1648
1649 assert_eq!(path.steps.len(), 3);
1650
1651 let commit_step = path
1653 .steps
1654 .iter()
1655 .find(|s| s.step.id == "step-abc12345")
1656 .unwrap();
1657 let rc1_step = path
1658 .steps
1659 .iter()
1660 .find(|s| s.step.id == "step-rc-200")
1661 .unwrap();
1662 let rc2_step = path
1663 .steps
1664 .iter()
1665 .find(|s| s.step.id == "step-rc-201")
1666 .unwrap();
1667
1668 assert!(commit_step.step.parents.is_empty());
1670 assert_eq!(rc1_step.step.parents, vec!["step-abc12345"]);
1672 assert_eq!(rc2_step.step.parents, vec!["step-rc-200"]);
1674 assert_eq!(path.path.head, "step-rc-200");
1676 }
1677 }
1678}
1679
1680#[cfg(not(target_os = "emscripten"))]
1682pub use native::{derive_pull_request, list_pull_requests, resolve_token};
1683
1684#[cfg(test)]
1685mod tests {
1686 use super::*;
1687
1688 #[test]
1689 fn test_extract_issue_refs_basic() {
1690 let refs = extract_issue_refs("Fixes #42");
1691 assert_eq!(refs, vec![42]);
1692 }
1693
1694 #[test]
1695 fn test_extract_issue_refs_multiple() {
1696 let refs = extract_issue_refs("Fixes #10 and Closes #20");
1697 assert_eq!(refs, vec![10, 20]);
1698 }
1699
1700 #[test]
1701 fn test_extract_issue_refs_case_insensitive() {
1702 let refs = extract_issue_refs("FIXES #1, closes #2, Resolves #3");
1703 assert_eq!(refs, vec![1, 2, 3]);
1704 }
1705
1706 #[test]
1707 fn test_extract_issue_refs_no_refs() {
1708 let refs = extract_issue_refs("Just a regular PR description.");
1709 assert!(refs.is_empty());
1710 }
1711
1712 #[test]
1713 fn test_extract_issue_refs_dedup() {
1714 let refs = extract_issue_refs("Fixes #5 and also fixes #5");
1715 assert_eq!(refs, vec![5]);
1716 }
1717
1718 #[test]
1719 fn test_extract_issue_refs_multiline() {
1720 let body = "This is a PR.\n\nFixes #100\nCloses #200\n\nSome more text.";
1721 let refs = extract_issue_refs(body);
1722 assert_eq!(refs, vec![100, 200]);
1723 }
1724
1725 #[test]
1726 fn test_derive_config_default() {
1727 let config = DeriveConfig::default();
1728 assert_eq!(config.api_url, "https://api.github.com");
1729 assert!(config.include_ci);
1730 assert!(config.include_comments);
1731 assert!(config.token.is_empty());
1732 }
1733
1734 #[test]
1735 fn test_parse_pr_url_https() {
1736 let pr = parse_pr_url("https://github.com/empathic/toolpath/pull/6").unwrap();
1737 assert_eq!(pr.owner, "empathic");
1738 assert_eq!(pr.repo, "toolpath");
1739 assert_eq!(pr.number, 6);
1740 }
1741
1742 #[test]
1743 fn test_parse_pr_url_no_protocol() {
1744 let pr = parse_pr_url("github.com/empathic/toolpath/pull/42").unwrap();
1745 assert_eq!(pr.owner, "empathic");
1746 assert_eq!(pr.repo, "toolpath");
1747 assert_eq!(pr.number, 42);
1748 }
1749
1750 #[test]
1751 fn test_parse_pr_url_http() {
1752 let pr = parse_pr_url("http://github.com/org/repo/pull/1").unwrap();
1753 assert_eq!(pr.owner, "org");
1754 assert_eq!(pr.repo, "repo");
1755 assert_eq!(pr.number, 1);
1756 }
1757
1758 #[test]
1759 fn test_parse_pr_url_with_trailing_parts() {
1760 let pr = parse_pr_url("https://github.com/org/repo/pull/99/files").unwrap();
1761 assert_eq!(pr.number, 99);
1762 }
1763
1764 #[test]
1765 fn test_parse_pr_url_with_query_string() {
1766 let pr = parse_pr_url("https://github.com/org/repo/pull/5?diff=unified").unwrap();
1767 assert_eq!(pr.number, 5);
1768 }
1769
1770 #[test]
1771 fn test_parse_pr_url_invalid() {
1772 assert!(parse_pr_url("not a url").is_none());
1773 assert!(parse_pr_url("https://github.com/org/repo").is_none());
1774 assert!(parse_pr_url("https://github.com/org/repo/issues/1").is_none());
1775 assert!(parse_pr_url("https://gitlab.com/org/repo/pull/1").is_none());
1776 }
1777}