use crate::config::ProjectConfig;
use crate::scm::Issue;
use crate::types::Session;
pub fn build_prompt(
session: &Session,
project: Option<&ProjectConfig>,
issue_context: Option<&str>,
template_context: Option<&str>,
) -> String {
if let Some(override_prompt) = session.initial_prompt_override.as_deref() {
return override_prompt.to_owned();
}
let mut sections: Vec<String> = Vec::new();
sections.push(format_session_context(session, project));
if let Some(ctx) = issue_context {
sections.push(ctx.to_string());
}
if let Some(t) = template_context {
if !t.trim().is_empty() {
sections.push(t.to_string());
}
}
let is_issue_first = session.issue_id.is_some();
sections.push(format_task_directive(session, is_issue_first));
sections.join("\n\n")
}
pub fn format_issue_context(issue: &Issue) -> String {
let mut lines = vec![format!("## Issue: #{} — {}", issue.id, issue.title)];
if !issue.url.is_empty() {
lines.push(format!("URL: {}", issue.url));
}
lines.push(format!("State: {}", issue_state_str(issue.state)));
if let Some(ref milestone) = issue.milestone {
if !milestone.trim().is_empty() {
lines.push(format!("Milestone: {milestone}"));
}
}
let (priority, context_labels, other_labels) = classify_labels(&issue.labels);
if let Some(p) = priority {
lines.push(format!("Priority: {p}"));
}
if !context_labels.is_empty() {
lines.push(format!("Context: {}", context_labels.join(", ")));
}
if !other_labels.is_empty() {
lines.push(format!("Labels: {}", other_labels.join(", ")));
}
if let Some(ref assignee) = issue.assignee {
lines.push(format!("Assignee: {assignee}"));
}
if !issue.description.is_empty() {
lines.push(String::new());
lines.push(issue.description.clone());
}
lines.join("\n")
}
fn issue_state_str(s: crate::scm::IssueState) -> &'static str {
match s {
crate::scm::IssueState::Open => "open",
crate::scm::IssueState::InProgress => "in_progress",
crate::scm::IssueState::Closed => "closed",
crate::scm::IssueState::Cancelled => "cancelled",
}
}
fn classify_labels(labels: &[String]) -> (Option<String>, Vec<String>, Vec<String>) {
let mut priority: Option<String> = None;
let mut context: Vec<String> = Vec::new();
let mut other: Vec<String> = Vec::new();
for raw in labels {
let l = raw.trim();
if l.is_empty() {
continue;
}
let norm = l.to_ascii_lowercase();
if matches!(norm.as_str(), "p0" | "p1" | "p2" | "p3") {
priority = Some(norm.clone());
continue;
}
if norm == "priority:high" || norm == "priority/high" || norm == "high" {
priority = Some("high".to_string());
continue;
}
if norm == "priority:medium" || norm == "priority/medium" || norm == "medium" {
if priority.is_none() {
priority = Some("medium".to_string());
}
continue;
}
if norm == "priority:low" || norm == "priority/low" || norm == "low" {
if priority.is_none() {
priority = Some("low".to_string());
}
continue;
}
if norm.starts_with("area/") || norm.starts_with("kind/") || norm.starts_with("type/") {
context.push(l.to_string());
continue;
}
if matches!(
norm.as_str(),
"bug" | "feature" | "enhancement" | "refactor" | "docs" | "test" | "chore"
) {
context.push(l.to_string());
continue;
}
other.push(l.to_string());
}
context.sort();
other.sort();
(priority, context, other)
}
fn format_session_context(session: &Session, project: Option<&ProjectConfig>) -> String {
let mut lines = vec![format!("You are working on branch `{}`.", session.branch)];
if let Some(proj) = project {
lines.push(format!("Repository: {}", proj.repo));
lines.push(format!("Default branch: {}", proj.default_branch));
}
if let Some(ref id) = session.issue_id {
let url_part = session
.issue_url
.as_deref()
.map(|u| format!(" — {u}"))
.unwrap_or_default();
lines.push(format!("Issue: #{id}{url_part}"));
}
lines.join("\n")
}
fn format_task_directive(session: &Session, is_issue_first: bool) -> String {
if is_issue_first {
"Use the dev-lifecycle workflow to turn the issue above into \
concrete requirements and a plan, then implement the required \
changes, verify with tests and linting, push your branch, and \
open a pull request."
.to_string()
} else {
session.task.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{SessionId, SessionStatus};
use std::collections::HashMap;
fn base_session() -> Session {
Session {
id: SessionId("test-prompt-builder".into()),
project_id: "my-app".into(),
status: SessionStatus::Working,
agent: "claude-code".into(),
agent_config: None,
branch: "ao-abc123-feat-issue-42".into(),
task: "Fix the login bug".into(),
workspace_path: None,
runtime_handle: None,
runtime: "tmux".into(),
activity: None,
created_at: 0,
cost: None,
issue_id: None,
issue_url: None,
claimed_pr_number: None,
claimed_pr_url: None,
initial_prompt_override: None,
spawned_by: None,
last_merge_conflict_dispatched: None,
last_review_backlog_fingerprint: None,
}
}
fn sample_project() -> ProjectConfig {
ProjectConfig {
name: None,
repo: "acme/widgets".into(),
path: "/home/user/widgets".into(),
default_branch: "main".into(),
session_prefix: None,
branch_namespace: None,
runtime: None,
agent: None,
workspace: None,
tracker: None,
scm: None,
symlinks: vec![],
post_create: vec![],
agent_config: None,
orchestrator: None,
worker: None,
reactions: HashMap::new(),
agent_rules: None,
agent_rules_file: None,
orchestrator_rules: None,
orchestrator_session_strategy: None,
opencode_issue_session_strategy: None,
}
}
fn sample_issue() -> Issue {
Issue {
id: "42".into(),
title: "Add dark mode".into(),
description: "Users keep asking for dark mode support.".into(),
url: "https://github.com/acme/widgets/issues/42".into(),
state: crate::scm::IssueState::Open,
labels: vec!["feature".into(), "ui".into()],
assignee: Some("bob".into()),
milestone: Some("Q2".into()),
}
}
#[test]
fn initial_prompt_override_short_circuits_full_prompt() {
let mut session = base_session();
session.initial_prompt_override = Some("OVERRIDE ONLY".into());
session.issue_id = Some("99".into());
let proj = sample_project();
let issue_ctx = format_issue_context(&sample_issue());
let prompt = build_prompt(&session, Some(&proj), Some(&issue_ctx), Some("template"));
assert_eq!(prompt, "OVERRIDE ONLY");
}
#[test]
fn task_first_no_project_returns_branch_and_task() {
let session = base_session();
let prompt = build_prompt(&session, None, None, None);
assert!(prompt.contains("branch `ao-abc123-feat-issue-42`"));
assert!(prompt.contains("Fix the login bug"));
assert!(!prompt.contains("## Issue"));
}
#[test]
fn task_first_with_project_includes_repo_context() {
let session = base_session();
let proj = sample_project();
let prompt = build_prompt(&session, Some(&proj), None, None);
assert!(prompt.contains("acme/widgets"));
assert!(prompt.contains("Default branch: main"));
assert!(prompt.contains("Fix the login bug"));
}
#[test]
fn issue_first_full_context() {
let mut session = base_session();
session.issue_id = Some("42".into());
session.issue_url = Some("https://github.com/acme/widgets/issues/42".into());
let proj = sample_project();
let issue = sample_issue();
let issue_ctx = format_issue_context(&issue);
let prompt = build_prompt(&session, Some(&proj), Some(&issue_ctx), None);
assert!(prompt.contains("branch `ao-abc123-feat-issue-42`"));
assert!(prompt.contains("acme/widgets"));
assert!(prompt.contains("Issue: #42"));
assert!(prompt.contains("## Issue: #42 — Add dark mode"));
assert!(prompt.contains("State: open"));
assert!(prompt.contains("Milestone: Q2"));
assert!(prompt.contains("Context: feature"));
assert!(prompt.contains("Labels: ui"));
assert!(prompt.contains("Assignee: bob"));
assert!(prompt.contains("Users keep asking"));
assert!(prompt.contains("push your branch"));
assert!(prompt.contains("open a pull request"));
assert!(prompt.contains("dev-lifecycle"));
}
#[test]
fn issue_first_without_project_still_works() {
let mut session = base_session();
session.issue_id = Some("42".into());
let issue = sample_issue();
let issue_ctx = format_issue_context(&issue);
let prompt = build_prompt(&session, None, Some(&issue_ctx), None);
assert!(prompt.contains("## Issue: #42 — Add dark mode"));
assert!(prompt.contains("open a pull request"));
assert!(!prompt.contains("Repository:"));
assert!(!prompt.contains("Default branch:"));
}
#[test]
fn issue_context_includes_all_fields() {
let issue = sample_issue();
let ctx = format_issue_context(&issue);
assert!(ctx.contains("## Issue: #42 — Add dark mode"));
assert!(ctx.contains("https://github.com/acme/widgets/issues/42"));
assert!(ctx.contains("State: open"));
assert!(ctx.contains("Milestone: Q2"));
assert!(ctx.contains("Context: feature"));
assert!(ctx.contains("Labels: ui"));
assert!(ctx.contains("Assignee: bob"));
assert!(ctx.contains("Users keep asking"));
}
#[test]
fn issue_context_minimal_issue() {
let issue = Issue {
id: "7".into(),
title: "Fix typo".into(),
description: String::new(),
url: String::new(),
state: crate::scm::IssueState::Open,
labels: vec![],
assignee: None,
milestone: None,
};
let ctx = format_issue_context(&issue);
assert!(ctx.contains("## Issue: #7 — Fix typo"));
assert!(!ctx.contains("URL:"));
assert!(ctx.contains("State: open"));
assert!(!ctx.contains("Milestone:"));
assert!(!ctx.contains("Labels:"));
assert!(!ctx.contains("Assignee:"));
}
#[test]
fn session_context_with_issue_url() {
let mut session = base_session();
session.issue_id = Some("42".into());
session.issue_url = Some("https://github.com/acme/widgets/issues/42".into());
let ctx = format_session_context(&session, None);
assert!(ctx.contains("Issue: #42 — https://github.com/acme/widgets/issues/42"));
}
#[test]
fn session_context_issue_without_url() {
let mut session = base_session();
session.issue_id = Some("7".into());
let ctx = format_session_context(&session, None);
assert!(ctx.contains("Issue: #7"));
assert!(!ctx.contains(" — "));
}
#[test]
fn task_directive_issue_first_instructs_pr() {
let session = base_session();
let directive = format_task_directive(&session, true);
assert!(directive.contains("open a pull request"));
assert!(!directive.contains("Fix the login bug"));
}
#[test]
fn task_directive_prompt_first_returns_raw_task() {
let session = base_session();
let directive = format_task_directive(&session, false);
assert_eq!(directive, "Fix the login bug");
}
}