mi6_core/context/
github.rs

1//! GitHub context extraction from Bash tool events.
2//!
3//! This module parses Bash commands and their output to extract GitHub issue/PR
4//! references and worktree information. This provides more accurate context than
5//! branch name parsing alone, capturing:
6//!
7//! - Explicit `gh issue/pr` commands
8//! - GitHub URLs in command output (e.g., from `gh pr create`)
9//! - `Fixes #N` references in PR bodies
10//! - Git worktree operations
11//!
12//! # Fields Populated
13//!
14//! | Field | Description | Sources |
15//! |-------|-------------|---------|
16//! | `github_issue` | Issue number | `gh` commands, URLs, PR body |
17//! | `github_pr` | PR number | `gh` commands, URLs |
18//! | `github_repo` | Repository (`owner/repo`) | `gh --repo` flag, GitHub URLs |
19//! | `git_worktree_path` | Worktree directory path | `git worktree add`, `git -C`, `cd` |
20//! | `git_branch` | Current branch | Set when capturing PR from `gh pr create` |
21//!
22//! # GitHub CLI Command Parsing
23//!
24//! The [`parse_gh_command`] function parses `gh` CLI commands to extract explicit
25//! issue/PR references.
26//!
27//! ## Supported Commands
28//!
29//! ```text
30//! gh issue {view,checks,diff,merge,close,edit,reopen,comment} <number> [--repo owner/repo]
31//! gh pr {view,checks,diff,merge,close,edit,reopen,comment} <number> [--repo owner/repo]
32//! ```
33//!
34//! **Regex:** `gh\s+(issue|pr)\s+(view|checks|diff|merge|close|edit|reopen|comment)\s+(\d+)(?:.*--repo\s+([^\s]+))?`
35//!
36//! ## PR Body References
37//!
38//! When `gh pr create --body "..."` is detected, the body is scanned for issue references:
39//!
40//! ```text
41//! Closes #N
42//! Fixes #N
43//! Resolves #N
44//! ```
45//!
46//! **Regex (case-insensitive):** `(?i)\b(closes?|fixes?|resolves?)\s*#(\d+)`
47//!
48//! ## Examples
49//!
50//! ```text
51//! gh issue view 52                          -> issue: 52
52//! gh issue view 52 --repo paradigmxyz/mi6   -> issue: 52, repo: paradigmxyz/mi6
53//! gh pr checks 86                           -> pr: 86
54//! gh pr view 64 --repo paradigmxyz/mi6      -> pr: 64, repo: paradigmxyz/mi6
55//! gh pr create --title "Fix" --body "Fixes #52"  -> issue: 52
56//! ```
57//!
58//! # GitHub URL Parsing
59//!
60//! The [`parse_github_urls`] function extracts repo, issue, and PR from GitHub URLs
61//! in command output.
62//!
63//! **Regex:** `https://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)`
64//!
65//! ## Examples
66//!
67//! ```text
68//! https://github.com/paradigmxyz/mi6/issues/236  -> issue: 236, repo: paradigmxyz/mi6
69//! https://github.com/paradigmxyz/mi6/pull/237    -> pr: 237, repo: paradigmxyz/mi6
70//! ```
71//!
72//! This is particularly useful for capturing context from `gh pr create` output:
73//! ```text
74//! Created https://github.com/paradigmxyz/mi6/pull/237
75//! ```
76//!
77//! **Note:** URLs are only parsed from output of creation commands (`gh issue create`,
78//! `gh pr create`) to avoid capturing incidental URLs from CI logs or code comments.
79//!
80//! # Worktree and Directory Parsing
81//!
82//! ## Git Worktree Add
83//!
84//! The [`parse_worktree_add`] function extracts path and branch from worktree commands.
85//!
86//! **Regex:** `git\s+worktree\s+add\s+(?:-f\s+)?([^\s]+)(?:.*-b\s+([^\s]+))?`
87//!
88//! ```text
89//! git worktree add ../mi6-issue-42
90//!   -> path: /current/dir/../mi6-issue-42
91//!
92//! git worktree add ../mi6-fix -b fix/issue-42 origin/main
93//!   -> path: /current/dir/../mi6-fix
94//!   -> branch: fix/issue-42
95//! ```
96//!
97//! ## Git -C Path
98//!
99//! The [`parse_git_c_path`] function extracts the path from `git -C <path>` commands.
100//!
101//! **Regex:** `git\s+-C\s+([^\s]+)`
102//!
103//! ## CD Command
104//!
105//! The [`parse_cd_path`] function parses cd commands with quoted/unquoted paths.
106//!
107//! **Regex:** `cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))`
108//!
109//! ## Path Resolution
110//!
111//! All paths are resolved:
112//! - `~` is expanded to `$HOME`
113//! - Relative paths are joined with the session's `cwd`
114//!
115//! # Update Semantics
116//!
117//! Updates use SQL `COALESCE` semantics:
118//! - New non-NULL values overwrite existing values
119//! - NULL values preserve existing values
120//! - "Most recent wins" for non-NULL values
121//!
122//! # Chained Command Support
123//!
124//! Commands are split on `&&`, `||`, and `;` to detect references in compound commands:
125//!
126//! ```text
127//! echo foo && gh issue view 42   -> detected
128//! git status; gh pr view 86      -> detected
129//! ```
130//!
131//! # Limitations
132//!
133//! 1. **Chained commands** - Simple split on delimiters; doesn't handle quoted delimiters
134//! 2. **Path resolution** - Doesn't canonicalize paths (may contain `..`)
135//! 3. **Tool output truncation** - Very long output may be truncated before URL parsing
136//!
137//! # Usage
138//!
139//! ```rust,ignore
140//! use mi6_core::github::{extract_context, GitContextUpdate};
141//! use std::path::Path;
142//!
143//! let cwd = Path::new("/home/user/project");
144//! let update = extract_context(
145//!     "gh issue view 52 --repo paradigmxyz/mi6",
146//!     None,
147//!     cwd,
148//! );
149//!
150//! assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
151//! assert_eq!(update.github_issue, Some(52));
152//! ```
153
154use std::path::Path;
155use std::sync::LazyLock;
156
157use regex::Regex;
158
159/// Git and GitHub context update derived from a Bash tool event.
160///
161/// This struct captures both git-related context (branch, worktree) and
162/// GitHub-specific context (repo, issue, PR) extracted from shell commands
163/// and their output.
164///
165/// Each field is `Some` only if the parsing found a new value.
166/// Use with COALESCE semantics to update session fields.
167#[derive(Debug, Default, Clone, PartialEq, Eq)]
168pub struct GitContextUpdate {
169    /// GitHub repository in `owner/repo` format
170    pub github_repo: Option<String>,
171    /// GitHub issue number
172    pub github_issue: Option<i32>,
173    /// GitHub PR number
174    pub github_pr: Option<i32>,
175    /// Lines added (from git diff --shortstat or gh pr view)
176    pub git_additions: Option<i32>,
177    /// Lines deleted (from git diff --shortstat or gh pr view)
178    pub git_deletions: Option<i32>,
179    /// Absolute path to git worktree
180    pub worktree_path: Option<String>,
181    /// Branch name in the worktree
182    pub worktree_branch: Option<String>,
183    /// Current git branch (set when capturing PR from gh pr create)
184    pub git_branch: Option<String>,
185}
186
187impl GitContextUpdate {
188    /// Returns true if this update has any values
189    pub fn has_values(&self) -> bool {
190        self.github_repo.is_some()
191            || self.github_issue.is_some()
192            || self.github_pr.is_some()
193            || self.git_additions.is_some()
194            || self.git_deletions.is_some()
195            || self.worktree_path.is_some()
196            || self.worktree_branch.is_some()
197            || self.git_branch.is_some()
198    }
199
200    /// Merge another update into this one (other values take precedence)
201    pub fn merge(&mut self, other: GitContextUpdate) {
202        if other.github_repo.is_some() {
203            self.github_repo = other.github_repo;
204        }
205        if other.github_issue.is_some() {
206            self.github_issue = other.github_issue;
207        }
208        if other.github_pr.is_some() {
209            self.github_pr = other.github_pr;
210        }
211        if other.git_additions.is_some() {
212            self.git_additions = other.git_additions;
213        }
214        if other.git_deletions.is_some() {
215            self.git_deletions = other.git_deletions;
216        }
217        if other.worktree_path.is_some() {
218            self.worktree_path = other.worktree_path;
219        }
220        if other.worktree_branch.is_some() {
221            self.worktree_branch = other.worktree_branch;
222        }
223        if other.git_branch.is_some() {
224            self.git_branch = other.git_branch;
225        }
226    }
227}
228
229/// Deprecated: Use [`GitContextUpdate`] instead.
230#[deprecated(since = "0.4.0", note = "Renamed to GitContextUpdate for clarity")]
231pub type SessionContextUpdate = GitContextUpdate;
232
233// Lazy-compiled regex patterns.
234// These patterns are static literals that are verified at compile time,
235// so expect() is safe here.
236
237#[expect(
238    clippy::expect_used,
239    reason = "static regex pattern verified at compile time"
240)]
241static GH_ISSUE_PR_RE: LazyLock<Regex> = LazyLock::new(|| {
242    // gh issue/pr {view,checks,diff,merge,close,edit,reopen,comment} N [--repo R]
243    Regex::new(
244        r"gh\s+(issue|pr)\s+(view|checks|diff|merge|close|edit|reopen|comment)\s+(\d+)(?:.*--repo\s+([^\s]+))?"
245    ).expect("invalid regex")
246});
247
248#[expect(
249    clippy::expect_used,
250    reason = "static regex pattern verified at compile time"
251)]
252static GITHUB_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
253    // https://github.com/owner/repo/issues/N or /pull/N
254    Regex::new(r"https://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)").expect("invalid regex")
255});
256
257#[expect(
258    clippy::expect_used,
259    reason = "static regex pattern verified at compile time"
260)]
261static CLOSES_RE: LazyLock<Regex> = LazyLock::new(|| {
262    // Closes/Fixes/Resolves #N (case-insensitive)
263    Regex::new(r"(?i)\b(closes?|fixes?|resolves?)\s*#(\d+)").expect("invalid regex")
264});
265
266#[expect(
267    clippy::expect_used,
268    reason = "static regex pattern verified at compile time"
269)]
270static GH_PR_CREATE_BODY_RE: LazyLock<Regex> = LazyLock::new(|| {
271    // gh pr create ... --body "..." or --body '...'
272    // Capture the body content for scanning
273    Regex::new(r#"gh\s+pr\s+create\s+.*--body\s+(?:"([^"]*)"|'([^']*)')"#).expect("invalid regex")
274});
275
276#[expect(
277    clippy::expect_used,
278    reason = "static regex pattern verified at compile time"
279)]
280static GIT_WORKTREE_ADD_RE: LazyLock<Regex> = LazyLock::new(|| {
281    // git worktree add [-f] <path> [-b <branch>] [<commit>]
282    Regex::new(r"git\s+worktree\s+add\s+(?:-f\s+)?([^\s]+)(?:.*-b\s+([^\s]+))?")
283        .expect("invalid regex")
284});
285
286#[expect(
287    clippy::expect_used,
288    reason = "static regex pattern verified at compile time"
289)]
290static GIT_C_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
291    // git -C <path> ...
292    Regex::new(r"git\s+-C\s+([^\s]+)").expect("invalid regex")
293});
294
295#[expect(
296    clippy::expect_used,
297    reason = "static regex pattern verified at compile time"
298)]
299static CD_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
300    // cd <path> with optional quotes
301    Regex::new(r#"cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))"#).expect("invalid regex")
302});
303
304#[expect(
305    clippy::expect_used,
306    reason = "static regex pattern verified at compile time"
307)]
308static GH_CREATE_RE: LazyLock<Regex> = LazyLock::new(|| {
309    // gh issue create or gh pr create - commands that output URLs we want to capture
310    Regex::new(r"gh\s+(issue|pr)\s+create").expect("invalid regex")
311});
312
313#[expect(
314    clippy::expect_used,
315    reason = "static regex pattern verified at compile time"
316)]
317static GH_PR_VIEW_ADDITIONS_RE: LazyLock<Regex> = LazyLock::new(|| {
318    // gh pr view output: "additions:	148" or "additions:  148"
319    Regex::new(r"additions:\s+(\d+)").expect("invalid regex")
320});
321
322#[expect(
323    clippy::expect_used,
324    reason = "static regex pattern verified at compile time"
325)]
326static GH_PR_VIEW_DELETIONS_RE: LazyLock<Regex> = LazyLock::new(|| {
327    // gh pr view output: "deletions:	7" or "deletions:  7"
328    Regex::new(r"deletions:\s+(\d+)").expect("invalid regex")
329});
330
331#[expect(
332    clippy::expect_used,
333    reason = "static regex pattern verified at compile time"
334)]
335static GH_PR_VIEW_RE: LazyLock<Regex> = LazyLock::new(|| {
336    // Match "gh pr view" command
337    Regex::new(r"gh\s+pr\s+view\b").expect("invalid regex")
338});
339
340/// Parse a Bash command for gh issue/pr references.
341///
342/// Handles commands like:
343/// - `gh issue view 52`
344/// - `gh pr checks 86 --repo paradigmxyz/mi6`
345/// - `gh issue close 42`
346pub fn parse_gh_command(command: &str) -> GitContextUpdate {
347    let mut update = GitContextUpdate::default();
348
349    // Split command on && || ; to handle chained commands
350    for part in split_chained_commands(command) {
351        if let Some(caps) = GH_ISSUE_PR_RE.captures(part) {
352            let cmd_type = caps.get(1).map(|m| m.as_str());
353            let number_str = caps.get(3).map(|m| m.as_str());
354            let repo = caps.get(4).map(|m| m.as_str().to_string());
355
356            if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
357                match cmd_type {
358                    Some("issue") => update.github_issue = Some(num),
359                    Some("pr") => update.github_pr = Some(num),
360                    _ => {}
361                }
362            }
363
364            if let Some(r) = repo {
365                update.github_repo = Some(r);
366            }
367        }
368
369        // Also check for gh pr create --body "... Fixes #N ..."
370        if let Some(caps) = GH_PR_CREATE_BODY_RE.captures(part) {
371            let body = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str());
372            if let Some(body_text) = body {
373                let issues = parse_closes_references(body_text);
374                if let Some(&first_issue) = issues.first() {
375                    update.github_issue = Some(first_issue);
376                }
377            }
378        }
379    }
380
381    update
382}
383
384/// Parse tool output for GitHub URLs.
385///
386/// Extracts repo, issue, and PR from URLs like:
387/// - `https://github.com/paradigmxyz/mi6/issues/236`
388/// - `https://github.com/paradigmxyz/mi6/pull/237`
389pub fn parse_github_urls(output: &str) -> GitContextUpdate {
390    let mut update = GitContextUpdate::default();
391
392    for caps in GITHUB_URL_RE.captures_iter(output) {
393        let repo = caps.get(1).map(|m| m.as_str().to_string());
394        let url_type = caps.get(2).map(|m| m.as_str());
395        let number_str = caps.get(3).map(|m| m.as_str());
396
397        if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
398            match url_type {
399                Some("issues") => update.github_issue = Some(num),
400                Some("pull") => update.github_pr = Some(num),
401                _ => {}
402            }
403        }
404
405        if let Some(r) = repo {
406            update.github_repo = Some(r);
407        }
408    }
409
410    update
411}
412
413/// Parse PR body text for "Closes #N" references.
414///
415/// Returns a list of issue numbers found.
416pub fn parse_closes_references(body: &str) -> Vec<i32> {
417    CLOSES_RE
418        .captures_iter(body)
419        .filter_map(|caps| caps.get(2).and_then(|m| m.as_str().parse::<i32>().ok()))
420        .collect()
421}
422
423/// Parse git worktree add command.
424///
425/// Extracts path and optional branch from commands like:
426/// - `git worktree add ../foo-issue-42`
427/// - `git worktree add ../foo -b fix/issue-42`
428/// - `git worktree add -f /path/to/worktree`
429pub fn parse_worktree_add(command: &str, cwd: &Path) -> Option<(String, Option<String>)> {
430    for part in split_chained_commands(command) {
431        if let Some(caps) = GIT_WORKTREE_ADD_RE.captures(part) {
432            let path_str = caps.get(1)?.as_str();
433            let branch = caps.get(2).map(|m| m.as_str().to_string());
434
435            // Resolve path relative to cwd
436            let resolved = resolve_path(path_str, cwd);
437            return Some((resolved, branch));
438        }
439    }
440    None
441}
442
443/// Parse `git -C <path>` from a command.
444///
445/// Returns the path argument if found.
446pub fn parse_git_c_path(command: &str, cwd: &Path) -> Option<String> {
447    for part in split_chained_commands(command) {
448        if let Some(caps) = GIT_C_PATH_RE.captures(part) {
449            let path_str = caps.get(1)?.as_str();
450            return Some(resolve_path(path_str, cwd));
451        }
452    }
453    None
454}
455
456/// Parse cd command for directory changes.
457///
458/// Handles quoted and unquoted paths:
459/// - `cd /path/to/dir`
460/// - `cd "/path with spaces/dir"`
461/// - `cd '/path/dir'`
462pub fn parse_cd_path(command: &str, cwd: &Path) -> Option<String> {
463    for part in split_chained_commands(command) {
464        if let Some(caps) = CD_PATH_RE.captures(part) {
465            // Match groups: 1=double-quoted, 2=single-quoted, 3=unquoted
466            let path_str = caps
467                .get(1)
468                .or_else(|| caps.get(2))
469                .or_else(|| caps.get(3))?
470                .as_str();
471            return Some(resolve_path(path_str, cwd));
472        }
473    }
474    None
475}
476
477/// Parse gh pr view output for additions/deletions.
478///
479/// Handles output containing:
480/// ```text
481/// additions:  148
482/// deletions:  7
483/// ```
484pub fn parse_gh_pr_view_stats(output: &str) -> GitContextUpdate {
485    let mut update = GitContextUpdate::default();
486
487    if let Some(caps) = GH_PR_VIEW_ADDITIONS_RE.captures(output)
488        && let Some(m) = caps.get(1)
489        && let Ok(additions) = m.as_str().parse::<i32>()
490    {
491        update.git_additions = Some(additions);
492    }
493
494    if let Some(caps) = GH_PR_VIEW_DELETIONS_RE.captures(output)
495        && let Some(m) = caps.get(1)
496        && let Ok(deletions) = m.as_str().parse::<i32>()
497    {
498        update.git_deletions = Some(deletions);
499    }
500
501    update
502}
503
504/// Check if the command is a gh pr view command.
505///
506/// These commands output additions/deletions stats.
507pub fn is_gh_pr_view_command(command: &str) -> bool {
508    for part in split_chained_commands(command) {
509        if GH_PR_VIEW_RE.is_match(part) {
510            return true;
511        }
512    }
513    false
514}
515
516/// Check if the command is one that creates GitHub resources and outputs URLs.
517///
518/// Only these commands should have their output parsed for GitHub URLs:
519/// - `gh issue create` - outputs the created issue URL
520/// - `gh pr create` - outputs the created PR URL
521///
522/// Other commands (like `gh run view`, `cat`, etc.) may have GitHub URLs in their
523/// output, but these are incidental and shouldn't be treated as session context.
524fn should_parse_output_urls(command: &str) -> bool {
525    for part in split_chained_commands(command) {
526        if GH_CREATE_RE.is_match(part) {
527            return true;
528        }
529    }
530    false
531}
532
533/// Extract all context from a Bash tool event.
534///
535/// Combines command parsing and output parsing to extract:
536/// - GitHub repo, issue, and PR references
537/// - Worktree path and branch
538/// - Diff statistics (additions/deletions)
539///
540/// # Arguments
541/// * `command` - The Bash command (from event payload)
542/// * `output` - The tool output (from event tool_output), if any
543/// * `cwd` - Current working directory for resolving relative paths
544pub fn extract_context(command: &str, output: Option<&str>, cwd: &Path) -> GitContextUpdate {
545    let mut update = GitContextUpdate::default();
546
547    // Parse command for gh commands
548    update.merge(parse_gh_command(command));
549
550    // Parse output for GitHub URLs, but only for creation commands.
551    // This prevents incidental URLs (e.g., in CI logs, code comments) from
552    // being treated as session context.
553    if let Some(out) = output
554        && should_parse_output_urls(command)
555    {
556        update.merge(parse_github_urls(out));
557    }
558
559    // Parse gh pr view output for additions/deletions
560    if let Some(out) = output
561        && is_gh_pr_view_command(command)
562    {
563        update.merge(parse_gh_pr_view_stats(out));
564    }
565
566    // Parse worktree operations
567    if let Some((path, branch)) = parse_worktree_add(command, cwd) {
568        update.worktree_path = Some(path);
569        update.worktree_branch = branch;
570    }
571
572    // Parse git -C path (updates worktree_path)
573    if let Some(path) = parse_git_c_path(command, cwd) {
574        update.worktree_path = Some(path);
575    }
576
577    // Parse cd commands
578    if let Some(path) = parse_cd_path(command, cwd) {
579        update.worktree_path = Some(path);
580    }
581
582    update
583}
584
585/// Split command on `&&`, `||`, and `;` to handle chained commands.
586///
587/// Splits on the earliest delimiter found in the string. This is a simple split
588/// that doesn't handle quoted delimiters.
589///
590/// # Examples
591///
592/// ```ignore
593/// use mi6_core::context::github::split_chained_commands;
594///
595/// let parts = split_chained_commands("echo foo && git checkout main");
596/// assert_eq!(parts, vec!["echo foo", "git checkout main"]);
597///
598/// let parts = split_chained_commands("git status; git pull || echo failed");
599/// assert_eq!(parts, vec!["git status", "git pull", "echo failed"]);
600/// ```
601pub fn split_chained_commands(command: &str) -> Vec<&str> {
602    // Split on && || ; but not inside quotes
603    // For simplicity, we do a basic split that handles most cases
604    let mut parts = Vec::new();
605    let mut current = command;
606
607    // Simple split that works for most shell commands
608    while !current.is_empty() {
609        // Find the earliest delimiter (by position, not by type)
610        let delim_pos = [
611            current.find("&&").map(|p| (p, 2)),
612            current.find("||").map(|p| (p, 2)),
613            current.find(';').map(|p| (p, 1)),
614        ]
615        .into_iter()
616        .flatten()
617        .min_by_key(|(pos, _)| *pos);
618
619        if let Some((pos, len)) = delim_pos {
620            let part = current[..pos].trim();
621            if !part.is_empty() {
622                parts.push(part);
623            }
624            current = &current[pos + len..];
625        } else {
626            let part = current.trim();
627            if !part.is_empty() {
628                parts.push(part);
629            }
630            break;
631        }
632    }
633
634    parts
635}
636
637/// Resolve a path relative to cwd, handling ~ expansion.
638fn resolve_path(path: &str, cwd: &Path) -> String {
639    let path = if path.starts_with('~') {
640        // Expand ~ to home directory
641        if let Ok(home) = std::env::var("HOME") {
642            path.replacen('~', &home, 1)
643        } else {
644            path.to_string()
645        }
646    } else {
647        path.to_string()
648    };
649
650    let path = Path::new(&path);
651    if path.is_absolute() {
652        path.to_string_lossy().into_owned()
653    } else {
654        cwd.join(path).to_string_lossy().into_owned()
655    }
656}
657
658#[cfg(test)]
659#[expect(clippy::unwrap_used, reason = "test code uses unwrap for clarity")]
660mod tests {
661    use super::*;
662    use std::path::PathBuf;
663
664    #[test]
665    fn test_parse_gh_issue_view() {
666        let update = parse_gh_command("gh issue view 52");
667        assert_eq!(update.github_issue, Some(52));
668        assert_eq!(update.github_pr, None);
669        assert_eq!(update.github_repo, None);
670    }
671
672    #[test]
673    fn test_parse_gh_issue_with_repo() {
674        let update = parse_gh_command("gh issue view 52 --repo paradigmxyz/mi6");
675        assert_eq!(update.github_issue, Some(52));
676        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
677    }
678
679    #[test]
680    fn test_parse_gh_pr_checks() {
681        let update = parse_gh_command("gh pr checks 86");
682        assert_eq!(update.github_pr, Some(86));
683        assert_eq!(update.github_issue, None);
684    }
685
686    #[test]
687    fn test_parse_gh_pr_with_repo() {
688        let update = parse_gh_command("gh pr view 64 --repo paradigmxyz/mi6");
689        assert_eq!(update.github_pr, Some(64));
690        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
691    }
692
693    #[test]
694    fn test_parse_github_url_issue() {
695        let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/issues/236");
696        assert_eq!(update.github_issue, Some(236));
697        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
698    }
699
700    #[test]
701    fn test_parse_github_url_pr() {
702        let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/pull/237");
703        assert_eq!(update.github_pr, Some(237));
704        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
705    }
706
707    #[test]
708    fn test_parse_closes_references() {
709        let issues = parse_closes_references("This PR fixes #52 and closes #53");
710        assert_eq!(issues, vec![52, 53]);
711    }
712
713    #[test]
714    fn test_parse_closes_references_case_insensitive() {
715        let issues = parse_closes_references("FIXES #42\nResolves #43\ncloses #44");
716        assert_eq!(issues, vec![42, 43, 44]);
717    }
718
719    #[test]
720    fn test_parse_gh_pr_create_with_fixes() {
721        let update = parse_gh_command(r#"gh pr create --title "Fix bug" --body "Fixes #52""#);
722        assert_eq!(update.github_issue, Some(52));
723    }
724
725    #[test]
726    fn test_parse_worktree_add_simple() {
727        let cwd = PathBuf::from("/test/repos/mi6");
728        let result = parse_worktree_add("git worktree add ../mi6-issue-42", &cwd);
729        assert!(result.is_some());
730        let (path, branch) = result.unwrap();
731        assert_eq!(path, "/test/repos/mi6/../mi6-issue-42");
732        assert_eq!(branch, None);
733    }
734
735    #[test]
736    fn test_parse_worktree_add_with_branch() {
737        let cwd = PathBuf::from("/test/repos/mi6");
738        let result = parse_worktree_add(
739            "git worktree add ../mi6-fix -b fix/issue-42 origin/main",
740            &cwd,
741        );
742        assert!(result.is_some());
743        let (path, branch) = result.unwrap();
744        assert_eq!(path, "/test/repos/mi6/../mi6-fix");
745        assert_eq!(branch, Some("fix/issue-42".to_string()));
746    }
747
748    #[test]
749    fn test_parse_git_c_path() {
750        let cwd = PathBuf::from("/test/repos");
751        let cmd = "git -C /test/worktree status";
752        let result = parse_git_c_path(cmd, &cwd);
753        assert_eq!(result, Some("/test/worktree".to_string()));
754    }
755
756    #[test]
757    fn test_parse_cd_path_unquoted() {
758        let cwd = PathBuf::from("/test");
759        let cmd = "cd /test/path/to/dir";
760        let result = parse_cd_path(cmd, &cwd);
761        assert_eq!(result, Some("/test/path/to/dir".to_string()));
762    }
763
764    #[test]
765    fn test_parse_cd_path_quoted() {
766        let cwd = PathBuf::from("/test");
767        let cmd = r#"cd "/test/path with spaces/dir""#;
768        let result = parse_cd_path(cmd, &cwd);
769        assert_eq!(result, Some("/test/path with spaces/dir".to_string()));
770    }
771
772    #[test]
773    fn test_parse_cd_path_relative() {
774        let cwd = PathBuf::from("/test/repos");
775        let result = parse_cd_path("cd mi6-issue-42", &cwd);
776        assert_eq!(result, Some("/test/repos/mi6-issue-42".to_string()));
777    }
778
779    #[test]
780    fn test_chained_commands() {
781        let update =
782            parse_gh_command("cargo build && gh pr create --title 'Fix' --body 'Fixes #52'");
783        assert_eq!(update.github_issue, Some(52));
784    }
785
786    #[test]
787    fn test_chained_commands_semicolon() {
788        let update = parse_gh_command("echo hello; gh issue view 42");
789        assert_eq!(update.github_issue, Some(42));
790    }
791
792    #[test]
793    fn test_extract_context_combined() {
794        let cwd = PathBuf::from("/test/repos/mi6");
795        let update = extract_context(
796            "gh pr view 86 --repo paradigmxyz/mi6",
797            Some("Some output here"),
798            &cwd,
799        );
800        assert_eq!(update.github_pr, Some(86));
801        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
802    }
803
804    #[test]
805    fn test_extract_context_from_output() {
806        let cwd = PathBuf::from("/test/repos/mi6");
807        let update = extract_context(
808            "gh pr create --title 'Fix' --body 'Some fix'",
809            Some("Created https://github.com/paradigmxyz/mi6/pull/237"),
810            &cwd,
811        );
812        assert_eq!(update.github_pr, Some(237));
813        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
814    }
815
816    #[test]
817    fn test_session_context_update_has_values() {
818        let empty = GitContextUpdate::default();
819        assert!(!empty.has_values());
820
821        let with_issue = GitContextUpdate {
822            github_issue: Some(42),
823            ..Default::default()
824        };
825        assert!(with_issue.has_values());
826
827        // git_branch field also triggers has_values
828        let with_branch = GitContextUpdate {
829            git_branch: Some("feature/test".to_string()),
830            ..Default::default()
831        };
832        assert!(with_branch.has_values());
833    }
834
835    #[test]
836    fn test_session_context_update_merge() {
837        let mut base = GitContextUpdate {
838            github_repo: Some("foo/bar".to_string()),
839            github_issue: Some(1),
840            ..Default::default()
841        };
842
843        let other = GitContextUpdate {
844            github_issue: Some(2),
845            github_pr: Some(42),
846            ..Default::default()
847        };
848
849        base.merge(other);
850
851        assert_eq!(base.github_repo, Some("foo/bar".to_string())); // kept
852        assert_eq!(base.github_issue, Some(2)); // overwritten
853        assert_eq!(base.github_pr, Some(42)); // added
854    }
855
856    #[test]
857    fn test_session_context_update_merge_git_branch() {
858        let mut base = GitContextUpdate {
859            github_pr: Some(42),
860            git_branch: Some("main".to_string()),
861            ..Default::default()
862        };
863
864        let other = GitContextUpdate {
865            git_branch: Some("feature/fix".to_string()),
866            ..Default::default()
867        };
868
869        base.merge(other);
870
871        assert_eq!(base.github_pr, Some(42)); // kept
872        assert_eq!(base.git_branch, Some("feature/fix".to_string())); // overwritten
873    }
874
875    #[test]
876    fn test_should_parse_output_urls() {
877        // Creation commands should have output parsed
878        assert!(should_parse_output_urls("gh pr create --title 'Fix'"));
879        assert!(should_parse_output_urls("gh issue create --title 'Bug'"));
880        assert!(should_parse_output_urls(
881            "cargo build && gh pr create --title 'Fix'"
882        ));
883
884        // Non-creation commands should NOT have output parsed
885        assert!(!should_parse_output_urls("gh run view 12345"));
886        assert!(!should_parse_output_urls("gh pr view 42"));
887        assert!(!should_parse_output_urls("gh issue view 42"));
888        assert!(!should_parse_output_urls("cat README.md"));
889        assert!(!should_parse_output_urls(
890            "gh run download 12345 --name logs"
891        ));
892    }
893
894    #[test]
895    fn test_extract_context_ignores_urls_in_non_creation_output() {
896        // This test reproduces a bug where GitHub URLs in CI logs were incorrectly
897        // extracted as session context. The URL below appeared in a comment within
898        // the dtolnay/rust-toolchain action's shell script.
899        let cwd = PathBuf::from("/test/repos/mi6");
900        let ci_log_output = r#"
901            # GitHub does not enforce `required: true` inputs itself. https://github.com/actions/runner/issues/1070
902            echo "toolchain=$toolchain" >> $GITHUB_OUTPUT
903        "#;
904
905        let update = extract_context("gh run view 12345 --log", Some(ci_log_output), &cwd);
906
907        // Should NOT extract the incidental URL from CI logs
908        assert_eq!(update.github_issue, None);
909        assert_eq!(update.github_repo, None);
910        assert_eq!(update.github_pr, None);
911    }
912
913    #[test]
914    fn test_extract_context_issue_create_output() {
915        let cwd = PathBuf::from("/test/repos/mi6");
916        let update = extract_context(
917            "gh issue create --title 'Bug report'",
918            Some("https://github.com/paradigmxyz/mi6/issues/42"),
919            &cwd,
920        );
921        assert_eq!(update.github_issue, Some(42));
922        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
923    }
924
925    #[test]
926    fn test_parse_gh_pr_view_stats() {
927        let output = r#"title:	feat(theme): add pane backgrounds
928additions:	148
929deletions:	7
930"#;
931        let update = parse_gh_pr_view_stats(output);
932        assert_eq!(update.git_additions, Some(148));
933        assert_eq!(update.git_deletions, Some(7));
934    }
935
936    #[test]
937    fn test_parse_gh_pr_view_stats_with_spaces() {
938        let output = "additions:  50\ndeletions:  25";
939        let update = parse_gh_pr_view_stats(output);
940        assert_eq!(update.git_additions, Some(50));
941        assert_eq!(update.git_deletions, Some(25));
942    }
943
944    #[test]
945    fn test_is_gh_pr_view_command() {
946        assert!(is_gh_pr_view_command("gh pr view 42"));
947        assert!(is_gh_pr_view_command("gh pr view"));
948        assert!(is_gh_pr_view_command("gh pr view --json additions"));
949        assert!(!is_gh_pr_view_command("gh pr create"));
950        assert!(!is_gh_pr_view_command("gh issue view 42"));
951        assert!(!is_gh_pr_view_command("git status"));
952    }
953
954    #[test]
955    fn test_extract_context_gh_pr_view_stats() {
956        let cwd = PathBuf::from("/test/repos/mi6");
957        let output = "additions:\t148\ndeletions:\t7";
958        let update = extract_context("gh pr view 497", Some(output), &cwd);
959        assert_eq!(update.git_additions, Some(148));
960        assert_eq!(update.git_deletions, Some(7));
961        assert_eq!(update.github_pr, Some(497));
962    }
963}