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