Skip to main content

apm_core/
context.rs

1use std::path::Path;
2use crate::config::Config;
3
4const DEP_COMMIT_CAP: usize = 20;
5
6/// Build a dependency context bundle for a worker prompt.
7///
8/// Returns a Markdown string to prepend to the worker prompt when the ticket
9/// has `depends_on` set.  Returns an empty string when `depends_on` is empty.
10///
11/// Direct dependencies include: ticket id + title, full Approach section, and
12/// a capped commit-subject list.  If a dependency is not yet in a terminal
13/// state, a warning is appended.
14///
15/// Transitive dependencies (deps-of-deps, one level deep) include only
16/// title + one-line Problem summary.
17pub fn build_dependency_bundle(root: &Path, depends_on: &[String], config: &Config) -> String {
18    if depends_on.is_empty() {
19        return String::new();
20    }
21
22    let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)
23        .unwrap_or_default();
24
25    let terminal_ids = config.terminal_state_ids();
26    let default_branch = config.project.default_branch.clone();
27
28    let mut out = String::new();
29    out.push_str("# Dependency Context Bundle\n\n");
30
31    let mut any = false;
32
33    for dep_id in depends_on {
34        let Some(dep) = all_tickets.iter().find(|t| &t.frontmatter.id == dep_id) else {
35            out.push_str(&format!("### Dependency: {dep_id}\n*Ticket not found.*\n\n"));
36            any = true;
37            continue;
38        };
39
40        any = true;
41        let is_terminal = terminal_ids.contains(&dep.frontmatter.state);
42
43        out.push_str(&format!(
44            "### Dependency: {} — {}\n",
45            dep_id, dep.frontmatter.title
46        ));
47        out.push_str(&format!("**State:** {}", dep.frontmatter.state));
48        if !is_terminal {
49            out.push_str(
50                " ⚠️ *This dependency is not yet closed — its API may still change. Tread carefully.*",
51            );
52        }
53        out.push('\n');
54
55        // Full Approach section.
56        if let Ok(doc) = dep.document() {
57            if let Some(approach) = crate::spec::get_section(&doc, "Approach")
58                .filter(|s| !s.is_empty())
59            {
60                out.push_str("\n**Approach:**\n");
61                out.push_str(&approach);
62                out.push('\n');
63            }
64        }
65
66        // Commit subjects landed on the dep branch.
67        let dep_branch = dep
68            .frontmatter
69            .branch
70            .clone()
71            .or_else(|| crate::ticket_fmt::branch_name_from_path(&dep.path))
72            .unwrap_or_else(|| format!("ticket/{dep_id}"));
73        let target = dep
74            .frontmatter
75            .target_branch
76            .as_deref()
77            .unwrap_or(default_branch.as_str());
78        let subjects = commit_subjects(root, target, &dep_branch, DEP_COMMIT_CAP);
79        if !subjects.is_empty() {
80            out.push_str("\n**Commits landed:**\n");
81            for s in &subjects {
82                out.push_str(&format!("- {s}\n"));
83            }
84        }
85
86        // Transitive dependencies — one level deep, title + one-line Problem.
87        if let Some(ref trans_ids) = dep.frontmatter.depends_on {
88            if !trans_ids.is_empty() {
89                out.push_str("\n**Transitive dependencies:**\n");
90                for trans_id in trans_ids {
91                    if let Some(trans) = all_tickets.iter().find(|t| &t.frontmatter.id == trans_id) {
92                        out.push_str(&format!("- **{}:** {}", trans_id, trans.frontmatter.title));
93                        if let Ok(doc) = trans.document() {
94                            if let Some(problem) = crate::spec::get_section(&doc, "Problem") {
95                                if let Some(line) =
96                                    problem.lines().find(|l| !l.trim().is_empty())
97                                {
98                                    out.push_str(&format!(" — {}", line.trim()));
99                                }
100                            }
101                        }
102                        out.push('\n');
103                    } else {
104                        out.push_str(&format!("- **{trans_id}:** *(not found)*\n"));
105                    }
106                }
107            }
108        }
109
110        out.push('\n');
111    }
112
113    if !any {
114        return String::new();
115    }
116
117    out.push_str("***\n");
118    out
119}
120
121/// Return commit subjects on `dep_branch` not reachable from `target`.
122/// Caps at `max_count`.  Returns an empty vec on any git error.
123fn commit_subjects(root: &Path, target: &str, dep_branch: &str, max_count: usize) -> Vec<String> {
124    let dep_ref = if crate::git_util::remote_branch_tip(root, dep_branch).is_some() {
125        format!("origin/{dep_branch}")
126    } else {
127        dep_branch.to_string()
128    };
129    let target_ref = if crate::git_util::remote_branch_tip(root, target).is_some() {
130        format!("origin/{target}")
131    } else {
132        target.to_string()
133    };
134    let range = format!("{target_ref}..{dep_ref}");
135    let max_str = max_count.to_string();
136    let output = crate::git_util::run(
137        root,
138        &["log", "--pretty=%s", &range, &format!("--max-count={max_str}")],
139    )
140    .unwrap_or_default();
141    output
142        .lines()
143        .filter(|l| !l.trim().is_empty())
144        .map(|l| l.to_string())
145        .collect()
146}
147