Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ EXAMPLES:
task_id: String,
},

/// Link a GitHub PR to a task so the TUI can display and open it
#[command(after_help = "\
EXAMPLES:
agman link-pr backend--fix-login https://github.com/acme/backend/pull/42
agman link-pr backend--fix-login 42 --author alice
agman link-pr backend--fix-login --from-sidecar")]
LinkPr {
/// Task identifier (repo--branch format, or just branch if unambiguous)
task_id: String,
/// PR number or URL. A number is resolved through the task repo's origin remote.
pr: Option<String>,
/// Mark this PR as owned by the task engineer (default)
#[arg(long, default_value_t = true, conflicts_with = "not_owned")]
owned: bool,
/// Mark this PR as external/not owned by the task engineer
#[arg(long, default_value_t = false)]
not_owned: bool,
/// GitHub author/login for the PR
#[arg(long)]
author: Option<String>,
/// Overwrite a different existing linked PR
#[arg(long, default_value_t = false)]
force: bool,
/// Read the PR reference from a legacy .pr-link sidecar
#[arg(long, default_value_t = false)]
from_sidecar: bool,
},

/// Read the agent log for a task
TaskLog {
/// Task identifier (repo--branch format)
Expand Down
46 changes: 46 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,24 @@ fn main() -> Result<()> {

Some(Commands::TaskInfo { task_id }) => cmd_task_info(&config, &task_id),

Some(Commands::LinkPr {
task_id,
pr,
owned,
not_owned,
author,
force,
from_sidecar,
}) => cmd_link_pr(
&config,
&task_id,
pr.as_deref(),
owned && !not_owned,
author,
force,
from_sidecar,
),

Some(Commands::TaskLog { task_id, tail }) => cmd_task_log(&config, &task_id, tail),

Some(Commands::CreateAgent {
Expand Down Expand Up @@ -565,6 +583,34 @@ fn cmd_task_info(config: &Config, task_id: &str) -> Result<()> {
Ok(())
}

fn cmd_link_pr(
config: &Config,
task_id: &str,
pr: Option<&str>,
owned: bool,
author: Option<String>,
force: bool,
from_sidecar: bool,
) -> Result<()> {
let linked = if from_sidecar {
if pr.is_some() {
anyhow::bail!("pass either a PR reference or --from-sidecar, not both");
}
use_cases::link_task_pr_from_sidecar(config, task_id, owned, author, force)?
} else {
let pr = pr.ok_or_else(|| {
anyhow::anyhow!("missing PR reference; pass a PR number, PR URL, or --from-sidecar")
})?;
use_cases::link_task_pr(config, task_id, pr, owned, author, force)?
};

println!(
"Task '{}' linked to PR #{}: {}",
task_id, linked.number, linked.url
);
Ok(())
}

fn cmd_task_log(config: &Config, task_id: &str, tail: usize) -> Result<()> {
let text = use_cases::get_task_log_tail(config, task_id, tail)?;
if text.is_empty() {
Expand Down
182 changes: 181 additions & 1 deletion src/use_cases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::harness::{
use crate::inbox;
use crate::project::Project;
use crate::repo_stats::RepoStats;
use crate::task::Task;
use crate::task::{LinkedPr, Task};
use crate::tmux::Tmux;

/// Required external tools that must be on $PATH (harness binary excluded —
Expand Down Expand Up @@ -474,6 +474,172 @@ pub fn set_linked_pr(
task.set_linked_pr(pr_number, url, owned, author)
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrReference {
Number(u64),
Url { number: u64, url: String },
}

pub fn parse_pr_reference(value: &str) -> Result<PrReference> {
let trimmed = value.trim();
if trimmed.is_empty() {
bail!("PR reference cannot be empty");
}

if trimmed.chars().all(|c| c.is_ascii_digit()) {
let number = trimmed
.parse::<u64>()
.with_context(|| format!("invalid PR number: {trimmed}"))?;
if number == 0 {
bail!("PR number must be greater than zero");
}
return Ok(PrReference::Number(number));
}

let without_fragment = trimmed.split('#').next().unwrap_or(trimmed);
let without_query = without_fragment
.split('?')
.next()
.unwrap_or(without_fragment);
let url = without_query.trim_end_matches('/');
let prefix = "https://github.com/";
let rest = url
.strip_prefix(prefix)
.ok_or_else(|| anyhow::anyhow!("PR URL must start with {prefix}"))?;
let parts: Vec<&str> = rest.split('/').collect();
if parts.len() != 4 || parts[2] != "pull" {
bail!("PR URL must look like https://github.com/<owner>/<repo>/pull/<number>");
}
if parts[0].is_empty() || parts[1].is_empty() {
bail!("PR URL must include a GitHub owner and repo");
}
let number = parts[3]
.parse::<u64>()
.with_context(|| format!("invalid PR number in URL: {}", parts[3]))?;
if number == 0 {
bail!("PR number must be greater than zero");
}

Ok(PrReference::Url {
number,
url: format!("{prefix}{}/{}/pull/{}", parts[0], parts[1], number),
})
}

pub fn link_task_pr(
config: &Config,
task_id: &str,
pr_reference: &str,
owned: bool,
author: Option<String>,
force: bool,
) -> Result<LinkedPr> {
let mut task = Task::load_by_id(config, task_id)?;
let reference = parse_pr_reference(pr_reference)?;
link_task_pr_reference(&mut task, reference, owned, author, force)
}

pub fn link_task_pr_from_sidecar(
config: &Config,
task_id: &str,
owned: bool,
author: Option<String>,
force: bool,
) -> Result<LinkedPr> {
let mut task = Task::load_by_id(config, task_id)?;
let reference = parse_pr_reference(&read_pr_reference_sidecar(&task)?)?;
link_task_pr_reference(&mut task, reference, owned, author, force)
}

fn link_task_pr_reference(
task: &mut Task,
reference: PrReference,
owned: bool,
author: Option<String>,
force: bool,
) -> Result<LinkedPr> {
let (number, url) = match reference {
PrReference::Number(number) => {
let repo = task.meta.repos.first().ok_or_else(|| {
anyhow::anyhow!("task '{}' has no repo worktree", task.meta.task_id())
})?;
let remote_url = Git::get_remote_url(&repo.worktree_path).with_context(|| {
format!(
"cannot build URL for PR #{number}; task repo '{}' has no usable origin remote",
repo.repo_name
)
})?;
let (owner, repo) = git::parse_github_owner_repo(&remote_url)
.ok_or_else(|| anyhow::anyhow!("Not a GitHub remote: {}", remote_url))?;
(
number,
format!("https://github.com/{owner}/{repo}/pull/{number}"),
)
}
PrReference::Url { number, url } => (number, url),
};

if let Some(existing) = &task.meta.linked_pr {
let same_pr = existing.url == url;
if !same_pr && !force {
bail!(
"task '{}' is already linked to PR #{} ({}); pass --force to overwrite",
task.meta.task_id(),
existing.number,
existing.url
);
}
}

task.set_linked_pr(number, url, owned, author)?;
Ok(task
.meta
.linked_pr
.clone()
.expect("linked_pr should be set after save"))
}

fn read_pr_reference_sidecar(task: &Task) -> Result<String> {
let mut candidates = vec![task.dir.join(".pr-link")];
if let Some(repo) = task.meta.repos.first() {
candidates.push(repo.worktree_path.join(".pr-link"));
}

for path in candidates {
if !path.exists() {
continue;
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let mut first_valid = None;
for line in content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
match parse_pr_reference(line) {
Ok(PrReference::Url { .. }) => return Ok(line.to_string()),
Ok(PrReference::Number(_)) if first_valid.is_none() => {
first_valid = Some(line.to_string());
}
Ok(PrReference::Number(_)) | Err(_) => {}
}
}
if let Some(line) = first_valid {
return Ok(line);
}
bail!(
"{} does not contain a valid PR number or URL",
path.display()
);
}

bail!(
"no .pr-link sidecar found for task '{}' (checked task dir and primary worktree)",
task.meta.task_id()
)
}

/// Migrate old-format `meta.json` files in-place to the new multi-repo format.
///
/// Old format had `repo_name`, `tmux_session`, `worktree_path` at the top level.
Expand Down Expand Up @@ -4134,6 +4300,9 @@ pub fn get_task_info_text(config: &Config, task_id: &str) -> Result<String> {
if let Some(ref project) = task.meta.project {
out.push_str(&format!("Project: {}\n", project));
}
if let Some(ref pr) = task.meta.linked_pr {
out.push_str(&format!("PR: #{} {}\n", pr.number, pr.url));
}
out.push_str(&format!("Created: {}\n", task.meta.created_at));
out.push_str(&format!("Updated: {}\n", task.meta.updated_at));

Expand Down Expand Up @@ -4626,6 +4795,15 @@ AGMAN_MSG
```

Keep updates concise and concrete: what changed, what you verified, what remains, and any blocker that needs a PM decision.

## Pull Requests

After creating or finding a PR for this task, run:
```
agman link-pr {{TASK_ID}} <PR URL or number>
```

Include the PR URL in your completion report. Inbox messages alone do not link PRs into the agman TUI; `agman link-pr` updates the task metadata the TUI reads.
"#;

const DEFAULT_REVIEWER_PROMPT_TEMPLATE: &str = r#"You are a code reviewer agent for project "{{PROJECT_NAME}}", named "{{REVIEWER_NAME}}".
Expand Down Expand Up @@ -4753,6 +4931,7 @@ const DEFAULT_PM_PROMPT_TEMPLATE: &str = r#"You are the Project Manager (PM) for
- agman list-pm-tasks {{PROJECT_NAME}}
- agman task-info <task-id>
- agman task-log <task-id> --tail 100
- agman link-pr <task-id> <PR URL or number>

### Agent Management
- agman create-agent --kind <researcher|operator|reviewer|tester> --name <name> --project {{PROJECT_NAME}} --description "<description>"
Expand All @@ -4772,6 +4951,7 @@ AGMAN_MSG

- When given work, suggest a task plan to the requester and wait for confirmation before creating tasks.
- Direct implementation, rebase, push, PR, CI, and review-addressing work by messaging the task's attached Engineer through the inbox.
- When an Engineer reports a PR that is not visible in the TUI, ask them to run `agman link-pr <task-id> <PR URL or number>`; PR URLs in inbox messages alone do not update task metadata.
- When the CEO asks a question, answer it — do not treat it as an implicit instruction to take action.
- If a task fails, analyze the logs and either retry or escalate.
- Never run long commands yourself — always spawn a task for implementation work.
Expand Down
17 changes: 17 additions & 0 deletions tests/cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ fn cli_help_exposes_agent_commands() {
assert!(stdout.contains("move-agent"));
assert!(stdout.contains("detach-agent"));
assert!(stdout.contains("send-message"));
assert!(stdout.contains("link-pr"));
}

#[test]
Expand All @@ -32,3 +33,19 @@ fn cli_attach_agent_help_exposes_pm_facing_syntax() {
));
assert!(stdout.contains("--role-label"));
}

#[test]
fn cli_link_pr_help_exposes_task_pr_linking_syntax() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_agman"))
.args(["link-pr", "--help"])
.output()
.expect("failed to run agman link-pr --help");

assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("help output should be utf8");

assert!(stdout.contains("agman link-pr backend--fix-login"));
assert!(stdout.contains("--from-sidecar"));
assert!(stdout.contains("--force"));
assert!(stdout.contains("--not-owned"));
}
Loading
Loading