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