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