Skip to content

Commit 8ca9b99

Browse files
committed
feat: add built-in /triage command for GitHub issue triage
Replaces the user-level triage skill with a first-class slash command. /triage [focus] lists untriaged open issues via gh, classifies them, autonomously fixes the safe ones with verification, and reports back. Works locally and over the remote transport, with help text and tests.
1 parent c3ae8a8 commit 8ca9b99

5 files changed

Lines changed: 91 additions & 3 deletions

File tree

crates/jcode-tui/src/tui/app/commands.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,6 +1726,12 @@ pub(super) fn handle_session_command(app: &mut App, trimmed: &str) -> bool {
17261726
return true;
17271727
}
17281728

1729+
if trimmed == "/triage" || trimmed.starts_with("/triage ") {
1730+
let rest = trimmed.strip_prefix("/triage").unwrap_or_default();
1731+
handle_triage_command_local(app, rest);
1732+
return true;
1733+
}
1734+
17291735
if trimmed == "/resume" || trimmed == "/sessions" || trimmed == "/session" {
17301736
app.open_session_picker();
17311737
app.record_keybinding_slow(super::shortcut_hints::LearnableAction::Resume);
@@ -2195,6 +2201,47 @@ pub(super) fn build_remote_release_prompt() -> String {
21952201
)
21962202
}
21972203

2204+
pub(super) fn build_triage_prompt(focus: &str) -> String {
2205+
let mut prompt = String::from(
2206+
"Triage the open GitHub issues for the repository in the current working directory, then autonomously fix the ones that are safe to fix. \
2207+
Hard rules: every public comment, issue reply, or PR description you post MUST end with a clear agent attribution line like '--- *— Jcode agent (automated triage), on behalf of @<repo-owner>*' so it can never be mistaken for the human. \
2208+
Never close an issue as wontfix/invalid without user confirmation (closing as completed is fine only after a verified fix). Be brief, friendly, and factual toward reporters. Prefer a branch + PR unless the repo's established norm is committing directly to the default branch. \
2209+
Workflow: (1) Collect: verify gh auth status, identify the repo with gh repo view, list open issues newest-first (gh issue list --state open --limit 50 --json number,title,labels,createdAt,author,comments,body), focus on untriaged ones (no labels or no maintainer/agent comment), and read each candidate fully with gh issue view <n> --comments. \
2210+
(2) Classify each into exactly one bucket and track them in a todo list: auto-fix (clear, reproducible, low-risk, verifiable), needs-info (comment asking for the specific missing details), needs-human (design decisions, breaking changes, security-sensitive, large refactors), duplicate (link the original, do not close without confirmation unless unambiguous), or question/support (answer directly if verifiable against the code or docs). Apply existing labels only (check gh label list first, never invent labels). \
2211+
(3) Fix the auto-fix bucket: locate the root cause first (demote to needs-human if you cannot pin it confidently), implement the smallest correct fix with a test when practical, verify with builds/tests before claiming anything, commit referencing the issue (fix: <summary> (fixes #<n>)), and comment on the issue describing what was done, signed. If there are more than about 3 auto-fix issues, consider spawning swarm workers, one per issue, and synthesize their reports. \
2212+
(4) Report back with a compact table of issue number, title, bucket, and action taken, plus links to commits/PRs and one-line recommendations for the needs-human issues.",
2213+
);
2214+
let focus = focus.trim();
2215+
if !focus.is_empty() {
2216+
prompt.push_str(" Additional focus from the user: ");
2217+
prompt.push_str(focus);
2218+
}
2219+
prompt
2220+
}
2221+
2222+
pub(super) fn triage_launch_notice(interrupted: bool) -> String {
2223+
if interrupted {
2224+
"👉 Interrupting and starting GitHub issue triage...".to_string()
2225+
} else {
2226+
"🚀 Starting GitHub issue triage...".to_string()
2227+
}
2228+
}
2229+
2230+
fn handle_triage_command_local(app: &mut App, rest: &str) {
2231+
let prompt = build_triage_prompt(rest);
2232+
if app.is_processing {
2233+
super::commands_improve::interrupt_and_queue_synthetic_message(
2234+
app,
2235+
prompt,
2236+
"Interrupting for /triage...",
2237+
triage_launch_notice(true),
2238+
);
2239+
} else {
2240+
app.push_display_message(DisplayMessage::system(triage_launch_notice(false)));
2241+
super::commands_improve::start_synthetic_user_turn(app, prompt);
2242+
}
2243+
}
2244+
21982245
pub(super) fn commit_launch_notice(interrupted: bool) -> String {
21992246
if interrupted {
22002247
"👉 Interrupting and starting logical commits...".to_string()

crates/jcode-tui/src/tui/app/input_help.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ impl App {
7070
"remote-release" => {
7171
"/remote-release\nSame as /commit-push, then push the release tag without running any local build.\n\nThe agent picks the semver bump, updates Cargo.toml/Cargo.lock and the changelog, commits and pushes, then runs scripts/quick-release.sh --remote. GitHub Actions builds, signs, checksums, and publishes every platform; the release remains a draft until the remote gates pass."
7272
}
73+
"triage" => {
74+
"/triage [focus]\nTriage open GitHub issues for the current repo, then autonomously fix the safe ones.\n\nThe agent lists untriaged issues with gh, classifies each (auto-fix, needs-info, needs-human, duplicate, question), applies existing labels, fixes and verifies the clear-cut bugs, and reports back with a summary table. Every public comment is clearly signed as the Jcode agent, and issues are never closed as wontfix/invalid without your confirmation.\n\nOptional focus text narrows the triage, for example /triage only crash reports."
75+
}
7376
"catchup" => {
7477
"/catchup\nOpen the Catch Up picker for finished sessions that need attention.\n\n/catchup next\nTeleport to the next session needing attention and open a Catch Up brief in the side panel.\n\n/catchup list\nAlias for opening the picker."
7578
}

crates/jcode-tui/src/tui/app/remote/key_handling.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,14 +1919,21 @@ async fn handle_remote_key_internal(
19191919
|| trimmed == "/remote-release"
19201920
|| trimmed == "/cut-release"
19211921
|| trimmed == "/commit-push-release"
1922+
|| trimmed == "/triage"
1923+
|| trimmed.starts_with("/triage ")
19221924
{
1925+
let is_triage = trimmed == "/triage" || trimmed.starts_with("/triage ");
19231926
let is_fast_release = matches!(
19241927
trimmed,
19251928
"/fast-release" | "/cut-release" | "/commit-push-release"
19261929
);
19271930
let is_remote_release = trimmed == "/remote-release";
19281931
let is_push = trimmed != "/commit";
1929-
let prompt = if is_fast_release {
1932+
let prompt = if is_triage {
1933+
app_mod::commands::build_triage_prompt(
1934+
trimmed.strip_prefix("/triage").unwrap_or_default(),
1935+
)
1936+
} else if is_fast_release {
19301937
app_mod::commands::build_fast_release_prompt()
19311938
} else if is_remote_release {
19321939
app_mod::commands::build_remote_release_prompt()
@@ -1936,7 +1943,9 @@ async fn handle_remote_key_internal(
19361943
app_mod::commands::build_commit_prompt()
19371944
};
19381945
let launch_notice = |interrupted: bool| {
1939-
if is_fast_release {
1946+
if is_triage {
1947+
app_mod::commands::triage_launch_notice(interrupted)
1948+
} else if is_fast_release {
19401949
app_mod::commands::fast_release_launch_notice(interrupted)
19411950
} else if is_remote_release {
19421951
app_mod::commands::remote_release_launch_notice(interrupted)
@@ -1946,7 +1955,9 @@ async fn handle_remote_key_internal(
19461955
app_mod::commands::commit_launch_notice(interrupted)
19471956
}
19481957
};
1949-
let cmd_label = if is_fast_release {
1958+
let cmd_label = if is_triage {
1959+
"/triage"
1960+
} else if is_fast_release {
19501961
"/fast-release"
19511962
} else if is_remote_release {
19521963
"/remote-release"

crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ const REGISTERED_COMMANDS: &[RegisteredCommand] = &[
7979
),
8080
RegisteredCommand::hidden("/cut-release", "Alias for /fast-release"),
8181
RegisteredCommand::hidden("/commit-push-release", "Alias for /cut-release"),
82+
RegisteredCommand::public(
83+
"/triage",
84+
"Triage new GitHub issues and autonomously fix the safe ones",
85+
),
8286
RegisteredCommand::public("/transcript", "Open the current session transcript file"),
8387
RegisteredCommand::public("/subagent-model", "Show/change subagent model policy"),
8488
RegisteredCommand::public("/autoreview", "Show/toggle automatic end-of-turn review"),

crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,29 @@ fn test_fast_release_command_starts_synthetic_user_turn() {
600600
.contains("Starting logical commits + push + fast local release"));
601601
}
602602

603+
#[test]
604+
fn test_triage_command_starts_synthetic_user_turn() {
605+
let mut app = create_test_app();
606+
app.input = "/triage".to_string();
607+
app.submit_input();
608+
609+
assert!(app.is_processing);
610+
assert!(app.pending_turn);
611+
let notice = app
612+
.display_messages()
613+
.last()
614+
.expect("missing launch notice");
615+
assert_eq!(notice.role, "system");
616+
assert!(notice.content.contains("Starting GitHub issue triage"));
617+
}
618+
619+
#[test]
620+
fn test_triage_command_includes_focus_in_prompt() {
621+
let prompt = crate::tui::app::commands::build_triage_prompt(" only crash reports");
622+
assert!(prompt.contains("Triage the open GitHub issues"));
623+
assert!(prompt.contains("Additional focus from the user: only crash reports"));
624+
}
625+
603626
#[test]
604627
fn test_cut_release_alias_starts_fast_release_turn() {
605628
let mut app = create_test_app();

0 commit comments

Comments
 (0)