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
432 for detail in commit_details {
433 let step = commit_to_step(detail, &mut actors)?;
434 steps.push(step);
435 }
436
437 if config.include_comments {
439 for rc in review_comments {
440 let step = review_comment_to_step(rc, &mut actors)?;
441 steps.push(step);
442 }
443
444 for pc in pr_comments {
445 let step = pr_comment_to_step(pc, &mut actors)?;
446 steps.push(step);
447 }
448
449 for review in reviews {
450 let state = review["state"].as_str().unwrap_or("");
451 if state.is_empty() || state == "PENDING" {
452 continue;
453 }
454 let step = review_to_step(review, &mut actors)?;
455 steps.push(step);
456 }
457 }
458
459 if config.include_ci {
461 for runs in check_runs_by_sha.values() {
462 for run in runs {
463 let step = check_run_to_step(run, &mut actors)?;
464 steps.push(step);
465 }
466 }
467 }
468
469 steps.sort_by(|a, b| a.step.timestamp.cmp(&b.step.timestamp));
475
476 let mut prev_id: Option<String> = None;
477 for step in &mut steps {
478 if let Some(ref prev) = prev_id {
479 step.step.parents = vec![prev.clone()];
480 } else {
481 step.step.parents = vec![];
482 }
483 prev_id = Some(step.step.id.clone());
484 }
485
486 let head = steps
488 .last()
489 .map(|s| s.step.id.clone())
490 .unwrap_or_else(|| format!("pr-{}", pr_number));
491
492 let meta = build_path_meta(pr, &actors)?;
494
495 Ok(Path {
496 path: PathIdentity {
497 id: format!("pr-{}", pr_number),
498 base: Some(Base {
499 uri: format!("github:{}/{}", owner, repo),
500 ref_str: Some(pr["base"]["ref"].as_str().unwrap_or("main").to_string()),
501 }),
502 head,
503 },
504 steps,
505 meta: Some(meta),
506 })
507 }
508
509 fn commit_to_step(
514 detail: &serde_json::Value,
515 actors: &mut HashMap<String, ActorDefinition>,
516 ) -> Result<Step> {
517 let sha = detail["sha"].as_str().unwrap_or_default();
518 let short_sha = &sha[..sha.len().min(8)];
519 let step_id = format!("step-{}", short_sha);
520
521 let login = detail["author"]["login"].as_str().unwrap_or("unknown");
523 let actor = format!("human:{}", login);
524 register_actor(actors, &actor, login, None);
525
526 let timestamp = detail["commit"]["committer"]["date"]
528 .as_str()
529 .unwrap_or("1970-01-01T00:00:00Z")
530 .to_string();
531
532 let mut change: HashMap<String, ArtifactChange> = HashMap::new();
534 if let Some(files) = detail["files"].as_array() {
535 for file in files {
536 let filename = file["filename"].as_str().unwrap_or("unknown");
537 if let Some(patch) = file["patch"].as_str() {
538 change.insert(filename.to_string(), ArtifactChange::raw(patch));
539 }
540 }
541 }
542
543 let message = detail["commit"]["message"].as_str().unwrap_or("");
545 let intent = message.lines().next().unwrap_or("").to_string();
546
547 let mut step = Step {
548 step: StepIdentity {
549 id: step_id,
550 parents: vec![],
551 actor,
552 timestamp,
553 },
554 change,
555 meta: None,
556 };
557
558 if !intent.is_empty() {
559 step.meta = Some(StepMeta {
560 intent: Some(intent),
561 source: Some(toolpath::v1::VcsSource {
562 vcs_type: "git".to_string(),
563 revision: sha.to_string(),
564 change_id: None,
565 extra: HashMap::new(),
566 }),
567 ..Default::default()
568 });
569 }
570
571 Ok(step)
572 }
573
574 fn review_comment_to_step(
575 rc: &serde_json::Value,
576 actors: &mut HashMap<String, ActorDefinition>,
577 ) -> Result<Step> {
578 let id = rc["id"].as_u64().unwrap_or(0);
579 let step_id = format!("step-rc-{}", id);
580
581 let login = rc["user"]["login"].as_str().unwrap_or("unknown");
582 let actor = format!("human:{}", login);
583 register_actor(actors, &actor, login, None);
584
585 let timestamp = rc["created_at"]
586 .as_str()
587 .unwrap_or("1970-01-01T00:00:00Z")
588 .to_string();
589
590 let path = rc["path"].as_str().unwrap_or("unknown");
591 let line = rc["line"]
592 .as_u64()
593 .or_else(|| rc["original_line"].as_u64())
594 .unwrap_or(0);
595 let artifact_uri = format!("review://{}#L{}", path, line);
596
597 let body = rc["body"].as_str().unwrap_or("").to_string();
598
599 let mut extra = HashMap::new();
600 extra.insert("body".to_string(), serde_json::Value::String(body));
601
602 let change = HashMap::from([(
603 artifact_uri,
604 ArtifactChange {
605 raw: None,
606 structural: Some(StructuralChange {
607 change_type: "review.comment".to_string(),
608 extra,
609 }),
610 },
611 )]);
612
613 Ok(Step {
614 step: StepIdentity {
615 id: step_id,
616 parents: vec![],
617 actor,
618 timestamp,
619 },
620 change,
621 meta: None,
622 })
623 }
624
625 fn pr_comment_to_step(
626 pc: &serde_json::Value,
627 actors: &mut HashMap<String, ActorDefinition>,
628 ) -> Result<Step> {
629 let id = pc["id"].as_u64().unwrap_or(0);
630 let step_id = format!("step-ic-{}", id);
631
632 let timestamp = pc["created_at"]
633 .as_str()
634 .unwrap_or("1970-01-01T00:00:00Z")
635 .to_string();
636
637 let login = pc["user"]["login"].as_str().unwrap_or("unknown");
638 let actor = format!("human:{}", login);
639 register_actor(actors, &actor, login, None);
640
641 let body = pc["body"].as_str().unwrap_or("").to_string();
642
643 let change = HashMap::from([(
644 "review://conversation".to_string(),
645 ArtifactChange {
646 raw: Some(body),
647 structural: None,
648 },
649 )]);
650
651 Ok(Step {
652 step: StepIdentity {
653 id: step_id,
654 parents: vec![],
655 actor,
656 timestamp,
657 },
658 change,
659 meta: None,
660 })
661 }
662
663 fn review_to_step(
664 review: &serde_json::Value,
665 actors: &mut HashMap<String, ActorDefinition>,
666 ) -> Result<Step> {
667 let id = review["id"].as_u64().unwrap_or(0);
668 let step_id = format!("step-rv-{}", id);
669
670 let timestamp = review["submitted_at"]
671 .as_str()
672 .unwrap_or("1970-01-01T00:00:00Z")
673 .to_string();
674
675 let login = review["user"]["login"].as_str().unwrap_or("unknown");
676 let actor = format!("human:{}", login);
677 register_actor(actors, &actor, login, None);
678
679 let state = review["state"].as_str().unwrap_or("COMMENTED").to_string();
680 let body = review["body"].as_str().unwrap_or("").to_string();
681
682 let mut extra = HashMap::new();
683 extra.insert("state".to_string(), serde_json::Value::String(state));
684
685 let change = HashMap::from([(
686 "review://decision".to_string(),
687 ArtifactChange {
688 raw: if body.is_empty() { None } else { Some(body) },
689 structural: Some(StructuralChange {
690 change_type: "review.decision".to_string(),
691 extra,
692 }),
693 },
694 )]);
695
696 Ok(Step {
697 step: StepIdentity {
698 id: step_id,
699 parents: vec![],
700 actor,
701 timestamp,
702 },
703 change,
704 meta: None,
705 })
706 }
707
708 fn check_run_to_step(
709 run: &serde_json::Value,
710 actors: &mut HashMap<String, ActorDefinition>,
711 ) -> Result<Step> {
712 let id = run["id"].as_u64().unwrap_or(0);
713 let step_id = format!("step-ci-{}", id);
714
715 let name = run["name"].as_str().unwrap_or("unknown");
716 let app_slug = run["app"]["slug"].as_str().unwrap_or("ci");
717 let actor = format!("ci:{}", app_slug);
718
719 actors
720 .entry(actor.clone())
721 .or_insert_with(|| ActorDefinition {
722 name: Some(app_slug.to_string()),
723 ..Default::default()
724 });
725
726 let timestamp = run["completed_at"]
727 .as_str()
728 .or_else(|| run["started_at"].as_str())
729 .unwrap_or("1970-01-01T00:00:00Z")
730 .to_string();
731
732 let conclusion = run["conclusion"].as_str().unwrap_or("unknown").to_string();
733
734 let mut extra = HashMap::new();
735 extra.insert(
736 "conclusion".to_string(),
737 serde_json::Value::String(conclusion),
738 );
739
740 let artifact_uri = format!("ci://checks/{}", name);
741 let change = HashMap::from([(
742 artifact_uri,
743 ArtifactChange {
744 raw: None,
745 structural: Some(StructuralChange {
746 change_type: "ci.run".to_string(),
747 extra,
748 }),
749 },
750 )]);
751
752 Ok(Step {
753 step: StepIdentity {
754 id: step_id,
755 parents: vec![],
756 actor,
757 timestamp,
758 },
759 change,
760 meta: None,
761 })
762 }
763
764 fn build_path_meta(
765 pr: &serde_json::Value,
766 actors: &HashMap<String, ActorDefinition>,
767 ) -> Result<PathMeta> {
768 let title = pr["title"].as_str().map(|s| s.to_string());
769 let body = pr["body"].as_str().unwrap_or("");
770 let intent = if body.is_empty() {
771 None
772 } else {
773 Some(body.to_string())
774 };
775
776 let issue_numbers = extract_issue_refs(body);
778 let refs: Vec<Ref> = issue_numbers
779 .into_iter()
780 .map(|n| {
781 let owner = pr["base"]["repo"]["owner"]["login"]
782 .as_str()
783 .unwrap_or("unknown");
784 let repo = pr["base"]["repo"]["name"].as_str().unwrap_or("unknown");
785 Ref {
786 rel: "fixes".to_string(),
787 href: format!("https://github.com/{}/{}/issues/{}", owner, repo, n),
788 }
789 })
790 .collect();
791
792 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
794 if let Some(labels) = pr["labels"].as_array() {
795 let label_names: Vec<serde_json::Value> = labels
796 .iter()
797 .filter_map(|l| l["name"].as_str())
798 .map(|s| serde_json::Value::String(s.to_string()))
799 .collect();
800 if !label_names.is_empty() {
801 let mut github_meta = serde_json::Map::new();
802 github_meta.insert("labels".to_string(), serde_json::Value::Array(label_names));
803 extra.insert("github".to_string(), serde_json::Value::Object(github_meta));
804 }
805 }
806
807 Ok(PathMeta {
808 title,
809 intent,
810 refs,
811 actors: if actors.is_empty() {
812 None
813 } else {
814 Some(actors.clone())
815 },
816 extra,
817 ..Default::default()
818 })
819 }
820
821 fn register_actor(
826 actors: &mut HashMap<String, ActorDefinition>,
827 actor_key: &str,
828 login: &str,
829 _email: Option<&str>,
830 ) {
831 actors
832 .entry(actor_key.to_string())
833 .or_insert_with(|| ActorDefinition {
834 name: Some(login.to_string()),
835 identities: vec![Identity {
836 system: "github".to_string(),
837 id: login.to_string(),
838 }],
839 ..Default::default()
840 });
841 }
842
843 fn str_field(val: &serde_json::Value, key: &str) -> String {
844 val[key].as_str().unwrap_or("").to_string()
845 }
846
847 #[cfg(test)]
852 mod tests {
853 use super::*;
854
855 fn sample_pr() -> serde_json::Value {
856 serde_json::json!({
857 "number": 42,
858 "title": "Add feature X",
859 "body": "This PR adds feature X.\n\nFixes #10\nCloses #20",
860 "state": "open",
861 "user": { "login": "alice" },
862 "head": { "ref": "feature-x" },
863 "base": {
864 "ref": "main",
865 "repo": {
866 "owner": { "login": "acme" },
867 "name": "widgets"
868 }
869 },
870 "labels": [
871 { "name": "enhancement" },
872 { "name": "reviewed" }
873 ],
874 "created_at": "2026-01-15T10:00:00Z",
875 "updated_at": "2026-01-16T14:00:00Z"
876 })
877 }
878
879 fn sample_commit_detail(
880 sha: &str,
881 parent_sha: Option<&str>,
882 msg: &str,
883 ) -> serde_json::Value {
884 let parents: Vec<serde_json::Value> = parent_sha
885 .into_iter()
886 .map(|s| serde_json::json!({ "sha": s }))
887 .collect();
888 serde_json::json!({
889 "sha": sha,
890 "commit": {
891 "message": msg,
892 "committer": {
893 "date": "2026-01-15T12:00:00Z"
894 }
895 },
896 "author": { "login": "alice" },
897 "parents": parents,
898 "files": [
899 {
900 "filename": "src/main.rs",
901 "patch": "@@ -1,3 +1,4 @@\n fn main() {\n+ println!(\"hello\");\n }"
902 }
903 ]
904 })
905 }
906
907 fn sample_review_comment(
908 id: u64,
909 commit_sha: &str,
910 path: &str,
911 line: u64,
912 ) -> serde_json::Value {
913 serde_json::json!({
914 "id": id,
915 "user": { "login": "bob" },
916 "commit_id": commit_sha,
917 "path": path,
918 "line": line,
919 "body": "Consider using a constant here.",
920 "created_at": "2026-01-15T14:00:00Z",
921 "pull_request_review_id": 100,
922 "in_reply_to_id": null
923 })
924 }
925
926 fn sample_pr_comment(id: u64) -> serde_json::Value {
927 serde_json::json!({
928 "id": id,
929 "user": { "login": "carol" },
930 "body": "Looks good overall!",
931 "created_at": "2026-01-15T16:00:00Z"
932 })
933 }
934
935 fn sample_review(id: u64, state: &str) -> serde_json::Value {
936 serde_json::json!({
937 "id": id,
938 "user": { "login": "dave" },
939 "state": state,
940 "body": "Approved with minor comments.",
941 "submitted_at": "2026-01-15T17:00:00Z"
942 })
943 }
944
945 fn sample_check_run(id: u64, name: &str, conclusion: &str) -> serde_json::Value {
946 serde_json::json!({
947 "id": id,
948 "name": name,
949 "app": { "slug": "github-actions" },
950 "conclusion": conclusion,
951 "completed_at": "2026-01-15T13:00:00Z",
952 "started_at": "2026-01-15T12:30:00Z"
953 })
954 }
955
956 #[test]
957 fn test_commit_to_step() {
958 let detail = sample_commit_detail("abc12345deadbeef", None, "Initial commit");
959 let mut actors = HashMap::new();
960
961 let step = commit_to_step(&detail, &mut actors).unwrap();
962
963 assert_eq!(step.step.id, "step-abc12345");
964 assert_eq!(step.step.actor, "human:alice");
965 assert!(step.step.parents.is_empty());
966 assert!(step.change.contains_key("src/main.rs"));
967 assert_eq!(
968 step.meta.as_ref().unwrap().intent.as_deref(),
969 Some("Initial commit")
970 );
971 assert!(actors.contains_key("human:alice"));
972 }
973
974 #[test]
975 fn test_review_comment_to_step() {
976 let rc = sample_review_comment(200, "abc12345deadbeef", "src/main.rs", 42);
977 let mut actors = HashMap::new();
978
979 let step = review_comment_to_step(&rc, &mut actors).unwrap();
980
981 assert_eq!(step.step.id, "step-rc-200");
982 assert_eq!(step.step.actor, "human:bob");
983 assert!(step.step.parents.is_empty());
985 assert!(step.change.contains_key("review://src/main.rs#L42"));
986 assert!(actors.contains_key("human:bob"));
987 }
988
989 #[test]
990 fn test_pr_comment_to_step() {
991 let pc = sample_pr_comment(300);
992 let mut actors = HashMap::new();
993
994 let step = pr_comment_to_step(&pc, &mut actors).unwrap();
995
996 assert_eq!(step.step.id, "step-ic-300");
997 assert_eq!(step.step.actor, "human:carol");
998 assert!(step.step.parents.is_empty());
999 assert!(step.change.contains_key("review://conversation"));
1000 let change = &step.change["review://conversation"];
1001 assert_eq!(change.raw.as_deref(), Some("Looks good overall!"));
1002 }
1003
1004 #[test]
1005 fn test_review_to_step() {
1006 let review = sample_review(400, "APPROVED");
1007 let mut actors = HashMap::new();
1008
1009 let step = review_to_step(&review, &mut actors).unwrap();
1010
1011 assert_eq!(step.step.id, "step-rv-400");
1012 assert_eq!(step.step.actor, "human:dave");
1013 assert!(step.step.parents.is_empty());
1014 assert!(step.change.contains_key("review://decision"));
1015 let change = &step.change["review://decision"];
1016 assert!(change.structural.is_some());
1017 let structural = change.structural.as_ref().unwrap();
1018 assert_eq!(structural.change_type, "review.decision");
1019 assert_eq!(structural.extra["state"], "APPROVED");
1020 }
1021
1022 #[test]
1023 fn test_check_run_to_step() {
1024 let run = sample_check_run(500, "build", "success");
1025 let mut actors = HashMap::new();
1026
1027 let step = check_run_to_step(&run, &mut actors).unwrap();
1028
1029 assert_eq!(step.step.id, "step-ci-500");
1030 assert_eq!(step.step.actor, "ci:github-actions");
1031 assert!(step.step.parents.is_empty());
1032 assert!(step.change.contains_key("ci://checks/build"));
1033 let change = &step.change["ci://checks/build"];
1034 let structural = change.structural.as_ref().unwrap();
1035 assert_eq!(structural.change_type, "ci.run");
1036 assert_eq!(structural.extra["conclusion"], "success");
1037 }
1038
1039 #[test]
1040 fn test_build_path_meta() {
1041 let pr = sample_pr();
1042 let mut actors = HashMap::new();
1043 register_actor(&mut actors, "human:alice", "alice", None);
1044
1045 let meta = build_path_meta(&pr, &actors).unwrap();
1046
1047 assert_eq!(meta.title.as_deref(), Some("Add feature X"));
1048 assert!(meta.intent.as_deref().unwrap().contains("feature X"));
1049 assert_eq!(meta.refs.len(), 2);
1050 assert_eq!(meta.refs[0].rel, "fixes");
1051 assert!(meta.refs[0].href.contains("/issues/10"));
1052 assert!(meta.refs[1].href.contains("/issues/20"));
1053 assert!(meta.actors.is_some());
1054
1055 let github = meta.extra.get("github").unwrap();
1057 let labels = github["labels"].as_array().unwrap();
1058 assert_eq!(labels.len(), 2);
1059 }
1060
1061 #[test]
1062 fn test_derive_from_data_full() {
1063 let pr = sample_pr();
1064 let commit1 = sample_commit_detail("abc12345deadbeef", None, "Initial commit");
1065 let commit2 =
1066 sample_commit_detail("def67890cafebabe", Some("abc12345deadbeef"), "Add tests");
1067 let mut commit2 = commit2;
1069 commit2["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T13:00:00Z");
1070
1071 let review_comments = vec![sample_review_comment(
1072 200,
1073 "abc12345deadbeef",
1074 "src/main.rs",
1075 42,
1076 )];
1077 let pr_comments = vec![sample_pr_comment(300)];
1078 let reviews = vec![sample_review(400, "APPROVED")];
1079
1080 let mut check_runs = HashMap::new();
1081 check_runs.insert(
1082 "abc12345deadbeef".to_string(),
1083 vec![sample_check_run(500, "build", "success")],
1084 );
1085
1086 let config = DeriveConfig {
1087 token: "test".to_string(),
1088 api_url: "https://api.github.com".to_string(),
1089 include_ci: true,
1090 include_comments: true,
1091 };
1092
1093 let data = PrData {
1094 pr: &pr,
1095 commit_details: &[commit1, commit2],
1096 reviews: &reviews,
1097 pr_comments: &pr_comments,
1098 review_comments: &review_comments,
1099 check_runs_by_sha: &check_runs,
1100 };
1101 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1102
1103 assert_eq!(path.path.id, "pr-42");
1104 assert_eq!(path.path.base.as_ref().unwrap().uri, "github:acme/widgets");
1105 assert_eq!(
1106 path.path.base.as_ref().unwrap().ref_str.as_deref(),
1107 Some("main")
1108 );
1109
1110 assert_eq!(path.steps.len(), 6);
1112
1113 assert!(path.steps[0].step.parents.is_empty());
1115 for i in 1..path.steps.len() {
1116 assert!(
1117 path.steps[i].step.timestamp >= path.steps[i - 1].step.timestamp,
1118 "Steps not sorted: {} < {}",
1119 path.steps[i].step.timestamp,
1120 path.steps[i - 1].step.timestamp,
1121 );
1122 assert_eq!(
1123 path.steps[i].step.parents,
1124 vec![path.steps[i - 1].step.id.clone()],
1125 "Step {} should parent off step {}",
1126 path.steps[i].step.id,
1127 path.steps[i - 1].step.id,
1128 );
1129 }
1130
1131 let meta = path.meta.as_ref().unwrap();
1133 assert_eq!(meta.title.as_deref(), Some("Add feature X"));
1134 assert_eq!(meta.refs.len(), 2);
1135 }
1136
1137 #[test]
1138 fn test_derive_from_data_no_ci() {
1139 let pr = sample_pr();
1140 let commit = sample_commit_detail("abc12345deadbeef", None, "Commit");
1141
1142 let config = DeriveConfig {
1143 token: "test".to_string(),
1144 api_url: "https://api.github.com".to_string(),
1145 include_ci: false,
1146 include_comments: false,
1147 };
1148
1149 let data = PrData {
1150 pr: &pr,
1151 commit_details: &[commit],
1152 reviews: &[],
1153 pr_comments: &[],
1154 review_comments: &[],
1155 check_runs_by_sha: &HashMap::new(),
1156 };
1157 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1158
1159 assert_eq!(path.steps.len(), 1);
1161 assert_eq!(path.steps[0].step.id, "step-abc12345");
1162 }
1163
1164 #[test]
1165 fn test_derive_from_data_pending_review_skipped() {
1166 let pr = sample_pr();
1167 let commit = sample_commit_detail("abc12345deadbeef", None, "Commit");
1168 let pending_review = sample_review(999, "PENDING");
1169
1170 let config = DeriveConfig {
1171 token: "test".to_string(),
1172 api_url: "https://api.github.com".to_string(),
1173 include_ci: false,
1174 include_comments: true,
1175 };
1176
1177 let data = PrData {
1178 pr: &pr,
1179 commit_details: &[commit],
1180 reviews: &[pending_review],
1181 pr_comments: &[],
1182 review_comments: &[],
1183 check_runs_by_sha: &HashMap::new(),
1184 };
1185 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1186
1187 assert_eq!(path.steps.len(), 1);
1189 }
1190
1191 #[test]
1192 fn test_parse_next_link() {
1193 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""#;
1194 assert_eq!(
1195 parse_next_link(header),
1196 Some("https://api.github.com/repos/foo/bar/pulls?page=2".to_string())
1197 );
1198
1199 assert_eq!(
1200 parse_next_link(r#"<https://example.com>; rel="prev""#),
1201 None
1202 );
1203 }
1204
1205 #[test]
1206 fn test_str_field() {
1207 let val = serde_json::json!({"name": "hello", "missing": null});
1208 assert_eq!(str_field(&val, "name"), "hello");
1209 assert_eq!(str_field(&val, "missing"), "");
1210 assert_eq!(str_field(&val, "nonexistent"), "");
1211 }
1212
1213 #[test]
1214 fn test_register_actor_idempotent() {
1215 let mut actors = HashMap::new();
1216 register_actor(&mut actors, "human:alice", "alice", None);
1217 register_actor(&mut actors, "human:alice", "alice", None);
1218 assert_eq!(actors.len(), 1);
1219 }
1220
1221 #[test]
1222 fn test_ci_steps_chain_inline() {
1223 let pr = sample_pr();
1224 let commit = sample_commit_detail("abc12345deadbeef", None, "Commit");
1225
1226 let mut check_runs = HashMap::new();
1227 check_runs.insert(
1228 "abc12345deadbeef".to_string(),
1229 vec![
1230 sample_check_run(501, "build", "success"),
1231 sample_check_run(502, "test", "success"),
1232 sample_check_run(503, "lint", "success"),
1233 ],
1234 );
1235
1236 let config = DeriveConfig {
1237 token: "test".to_string(),
1238 api_url: "https://api.github.com".to_string(),
1239 include_ci: true,
1240 include_comments: false,
1241 };
1242
1243 let data = PrData {
1244 pr: &pr,
1245 commit_details: &[commit],
1246 reviews: &[],
1247 pr_comments: &[],
1248 review_comments: &[],
1249 check_runs_by_sha: &check_runs,
1250 };
1251 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1252
1253 assert_eq!(path.steps.len(), 4);
1255
1256 assert!(path.steps[0].step.parents.is_empty()); for i in 1..path.steps.len() {
1259 assert_eq!(
1260 path.steps[i].step.parents,
1261 vec![path.steps[i - 1].step.id.clone()]
1262 );
1263 }
1264 }
1265
1266 #[test]
1267 fn test_review_comment_artifact_uri_format() {
1268 let rc = sample_review_comment(700, "abc12345", "src/lib.rs", 100);
1269 let mut actors = HashMap::new();
1270
1271 let step = review_comment_to_step(&rc, &mut actors).unwrap();
1272
1273 assert!(step.change.contains_key("review://src/lib.rs#L100"));
1274 }
1275
1276 #[test]
1277 fn test_derive_from_data_empty_commits() {
1278 let pr = sample_pr();
1279 let config = DeriveConfig {
1280 token: "test".to_string(),
1281 api_url: "https://api.github.com".to_string(),
1282 include_ci: false,
1283 include_comments: false,
1284 };
1285
1286 let data = PrData {
1287 pr: &pr,
1288 commit_details: &[],
1289 reviews: &[],
1290 pr_comments: &[],
1291 review_comments: &[],
1292 check_runs_by_sha: &HashMap::new(),
1293 };
1294 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1295
1296 assert_eq!(path.path.id, "pr-42");
1297 assert!(path.steps.is_empty());
1298 assert_eq!(path.path.head, "pr-42");
1299 }
1300
1301 #[test]
1302 fn test_review_empty_body() {
1303 let mut review = sample_review(800, "APPROVED");
1304 review["body"] = serde_json::json!("");
1305 let mut actors = HashMap::new();
1306
1307 let step = review_to_step(&review, &mut actors).unwrap();
1308 let change = &step.change["review://decision"];
1309 assert!(change.raw.is_none());
1310 assert!(change.structural.is_some());
1311 }
1312
1313 #[test]
1314 fn test_commit_no_files() {
1315 let detail = serde_json::json!({
1316 "sha": "aabbccdd11223344",
1317 "commit": {
1318 "message": "Empty commit",
1319 "committer": { "date": "2026-01-15T12:00:00Z" }
1320 },
1321 "author": { "login": "alice" },
1322 "parents": [],
1323 "files": []
1324 });
1325 let mut actors = HashMap::new();
1326
1327 let step = commit_to_step(&detail, &mut actors).unwrap();
1328 assert!(step.change.is_empty());
1329 }
1330
1331 #[test]
1332 fn test_multiple_commits_chain() {
1333 let pr = sample_pr();
1334 let c1 = {
1335 let mut c = sample_commit_detail("1111111100000000", None, "First");
1336 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T10:00:00Z");
1337 c
1338 };
1339 let c2 = {
1340 let mut c =
1341 sample_commit_detail("2222222200000000", Some("1111111100000000"), "Second");
1342 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T11:00:00Z");
1343 c
1344 };
1345 let c3 = {
1346 let mut c =
1347 sample_commit_detail("3333333300000000", Some("2222222200000000"), "Third");
1348 c["commit"]["committer"]["date"] = serde_json::json!("2026-01-15T12:00:00Z");
1349 c
1350 };
1351
1352 let config = DeriveConfig {
1353 token: "test".to_string(),
1354 api_url: "https://api.github.com".to_string(),
1355 include_ci: false,
1356 include_comments: false,
1357 };
1358
1359 let data = PrData {
1360 pr: &pr,
1361 commit_details: &[c1, c2, c3],
1362 reviews: &[],
1363 pr_comments: &[],
1364 review_comments: &[],
1365 check_runs_by_sha: &HashMap::new(),
1366 };
1367 let path = derive_from_data(&data, "acme", "widgets", &config).unwrap();
1368
1369 assert_eq!(path.steps.len(), 3);
1371 assert!(path.steps[0].step.parents.is_empty());
1372 assert_eq!(path.steps[1].step.parents, vec!["step-11111111"]);
1373 assert_eq!(path.steps[2].step.parents, vec!["step-22222222"]);
1374 assert_eq!(path.path.head, "step-33333333");
1375 }
1376 }
1377}
1378
1379#[cfg(not(target_os = "emscripten"))]
1381pub use native::{derive_pull_request, list_pull_requests, resolve_token};
1382
1383#[cfg(test)]
1384mod tests {
1385 use super::*;
1386
1387 #[test]
1388 fn test_extract_issue_refs_basic() {
1389 let refs = extract_issue_refs("Fixes #42");
1390 assert_eq!(refs, vec![42]);
1391 }
1392
1393 #[test]
1394 fn test_extract_issue_refs_multiple() {
1395 let refs = extract_issue_refs("Fixes #10 and Closes #20");
1396 assert_eq!(refs, vec![10, 20]);
1397 }
1398
1399 #[test]
1400 fn test_extract_issue_refs_case_insensitive() {
1401 let refs = extract_issue_refs("FIXES #1, closes #2, Resolves #3");
1402 assert_eq!(refs, vec![1, 2, 3]);
1403 }
1404
1405 #[test]
1406 fn test_extract_issue_refs_no_refs() {
1407 let refs = extract_issue_refs("Just a regular PR description.");
1408 assert!(refs.is_empty());
1409 }
1410
1411 #[test]
1412 fn test_extract_issue_refs_dedup() {
1413 let refs = extract_issue_refs("Fixes #5 and also fixes #5");
1414 assert_eq!(refs, vec![5]);
1415 }
1416
1417 #[test]
1418 fn test_extract_issue_refs_multiline() {
1419 let body = "This is a PR.\n\nFixes #100\nCloses #200\n\nSome more text.";
1420 let refs = extract_issue_refs(body);
1421 assert_eq!(refs, vec![100, 200]);
1422 }
1423
1424 #[test]
1425 fn test_derive_config_default() {
1426 let config = DeriveConfig::default();
1427 assert_eq!(config.api_url, "https://api.github.com");
1428 assert!(config.include_ci);
1429 assert!(config.include_comments);
1430 assert!(config.token.is_empty());
1431 }
1432
1433 #[test]
1434 fn test_parse_pr_url_https() {
1435 let pr = parse_pr_url("https://github.com/empathic/toolpath/pull/6").unwrap();
1436 assert_eq!(pr.owner, "empathic");
1437 assert_eq!(pr.repo, "toolpath");
1438 assert_eq!(pr.number, 6);
1439 }
1440
1441 #[test]
1442 fn test_parse_pr_url_no_protocol() {
1443 let pr = parse_pr_url("github.com/empathic/toolpath/pull/42").unwrap();
1444 assert_eq!(pr.owner, "empathic");
1445 assert_eq!(pr.repo, "toolpath");
1446 assert_eq!(pr.number, 42);
1447 }
1448
1449 #[test]
1450 fn test_parse_pr_url_http() {
1451 let pr = parse_pr_url("http://github.com/org/repo/pull/1").unwrap();
1452 assert_eq!(pr.owner, "org");
1453 assert_eq!(pr.repo, "repo");
1454 assert_eq!(pr.number, 1);
1455 }
1456
1457 #[test]
1458 fn test_parse_pr_url_with_trailing_parts() {
1459 let pr = parse_pr_url("https://github.com/org/repo/pull/99/files").unwrap();
1460 assert_eq!(pr.number, 99);
1461 }
1462
1463 #[test]
1464 fn test_parse_pr_url_with_query_string() {
1465 let pr = parse_pr_url("https://github.com/org/repo/pull/5?diff=unified").unwrap();
1466 assert_eq!(pr.number, 5);
1467 }
1468
1469 #[test]
1470 fn test_parse_pr_url_invalid() {
1471 assert!(parse_pr_url("not a url").is_none());
1472 assert!(parse_pr_url("https://github.com/org/repo").is_none());
1473 assert!(parse_pr_url("https://github.com/org/repo/issues/1").is_none());
1474 assert!(parse_pr_url("https://gitlab.com/org/repo/pull/1").is_none());
1475 }
1476}