Skip to main content

toolpath_github/
lib.rs

1#![doc = include_str!("../README.md")]
2
3// ============================================================================
4// Public configuration and types (available on all targets)
5// ============================================================================
6
7/// Configuration for deriving Toolpath documents from a GitHub pull request.
8pub struct DeriveConfig {
9    /// GitHub API token.
10    pub token: String,
11    /// GitHub API base URL (default: `https://api.github.com`).
12    pub api_url: String,
13    /// Include CI check runs as Steps (default: true).
14    pub include_ci: bool,
15    /// Include reviews and comments as Steps (default: true).
16    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/// Summary information about a pull request.
31#[derive(Debug, Clone)]
32pub struct PullRequestInfo {
33    /// PR number.
34    pub number: u64,
35    /// PR title.
36    pub title: String,
37    /// PR state (open, closed, merged).
38    pub state: String,
39    /// PR author login.
40    pub author: String,
41    /// Head branch name.
42    pub head_branch: String,
43    /// Base branch name.
44    pub base_branch: String,
45    /// ISO 8601 creation timestamp.
46    pub created_at: String,
47    /// ISO 8601 last update timestamp.
48    pub updated_at: String,
49}
50
51// ============================================================================
52// Public pure-data helpers (available on all targets)
53// ============================================================================
54
55/// Parsed components of a GitHub PR URL.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct PrUrl {
58    /// Repository owner.
59    pub owner: String,
60    /// Repository name.
61    pub repo: String,
62    /// Pull request number.
63    pub number: u64,
64}
65
66/// Parse a GitHub PR URL into its components.
67///
68/// Accepts URLs like `https://github.com/owner/repo/pull/42` or
69/// `github.com/owner/repo/pull/42` (without protocol prefix).
70/// Returns `None` if the URL doesn't match the expected format.
71///
72/// # Examples
73///
74/// ```
75/// use toolpath_github::parse_pr_url;
76///
77/// let pr = parse_pr_url("https://github.com/empathic/toolpath/pull/6").unwrap();
78/// assert_eq!(pr.owner, "empathic");
79/// assert_eq!(pr.repo, "toolpath");
80/// assert_eq!(pr.number, 6);
81///
82/// // Works without protocol prefix too
83/// let pr = parse_pr_url("github.com/empathic/toolpath/pull/6").unwrap();
84/// assert_eq!(pr.number, 6);
85///
86/// assert!(parse_pr_url("not a url").is_none());
87/// ```
88pub 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
106/// Extract issue references from PR body text.
107///
108/// Recognizes "Fixes #N", "Closes #N", "Resolves #N" (case-insensitive).
109///
110/// # Examples
111///
112/// ```
113/// use toolpath_github::extract_issue_refs;
114///
115/// let refs = extract_issue_refs("This PR fixes #42 and closes #99.");
116/// assert_eq!(refs, vec![42, 99]);
117/// ```
118pub 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            // Skip optional whitespace and '#'
126            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// ============================================================================
143// reqwest-dependent code (native targets only)
144// ============================================================================
145
146#[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    // ====================================================================
158    // Auth
159    // ====================================================================
160
161    /// Resolve a GitHub API token.
162    ///
163    /// Checks `GITHUB_TOKEN` environment variable first, then falls back to
164    /// `gh auth token` subprocess. Returns an error if neither works.
165    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    // ====================================================================
193    // API Client
194    // ====================================================================
195
196    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                // Parse Link header for next page
258                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                // Extract URL between < and >
285                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    // ====================================================================
296    // Public API
297    // ====================================================================
298
299    /// Derive a Toolpath [`Path`] from a GitHub pull request.
300    ///
301    /// Fetches PR metadata, commits, reviews, comments, and CI checks from the
302    /// GitHub API, then maps them into a Toolpath Path document where every
303    /// event becomes a Step in the DAG.
304    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        // Fetch all data
314        let pr = client.get_json(&format!("{}/pulls/{}", prefix, pr_number))?;
315        let commits = client.get_paginated(&format!("{}/pulls/{}/commits", prefix, pr_number))?;
316
317        // Fetch full commit details (for file patches)
318        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        // Fetch CI checks for each commit
346        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    /// List open pull requests for a repository.
373    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    // ====================================================================
402    // Pure derivation (testable without network)
403    // ====================================================================
404
405    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        // ── Commit steps ─────────────────────────────────────────────
429        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        // ── Review comment steps ─────────────────────────────────────
438        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        // ── CI check steps ───────────────────────────────────────────
460        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        // ── Sort by timestamp, then chain into a single trunk ────────
470        // Everything in a PR is part of one timeline. Commits, comments,
471        // reviews, and CI checks all chain linearly — none are dead ends
472        // or alternate explorations. Sort by time, then re-parent each
473        // step to point at the previous one.
474        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        // ── Build path head ──────────────────────────────────────────
487        let head = steps
488            .last()
489            .map(|s| s.step.id.clone())
490            .unwrap_or_else(|| format!("pr-{}", pr_number));
491
492        // ── Build path metadata ──────────────────────────────────────
493        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    // ====================================================================
510    // Mapping helpers
511    // ====================================================================
512
513    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        // Actor
522        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        // Timestamp
527        let timestamp = detail["commit"]["committer"]["date"]
528            .as_str()
529            .unwrap_or("1970-01-01T00:00:00Z")
530            .to_string();
531
532        // Changes: per-file raw diffs
533        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        // Intent: first line of commit message
544        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        // Parse issue refs
777        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        // Labels in extra
793        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    // ====================================================================
822    // Helpers
823    // ====================================================================
824
825    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    // ====================================================================
848    // Tests
849    // ====================================================================
850
851    #[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            // Parents are empty — set later by the trunk chain pass
984            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            // Labels in extra
1056            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            // Fix second commit timestamp to be after first
1068            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            // Should have 2 commits + 1 review comment + 1 PR comment + 1 review + 1 CI = 6 steps
1111            assert_eq!(path.steps.len(), 6);
1112
1113            // All steps form a single trunk chain sorted by timestamp
1114            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            // Path meta
1132            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            // Only commit steps
1160            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            // Only commit step, pending review skipped
1188            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            // 1 commit + 3 CI steps = 4 steps on a single trunk
1254            assert_eq!(path.steps.len(), 4);
1255
1256            // All steps chain linearly by timestamp
1257            assert!(path.steps[0].step.parents.is_empty()); // first step: no parent
1258            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            // Trunk chain: each step parents off the previous by timestamp
1370            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// Re-export native-only functions at crate root for API compatibility
1380#[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}