From c4cc74e86800e5147cbe98ed6c09a5c048429b01 Mon Sep 17 00:00:00 2001 From: willamhou Date: Sat, 23 May 2026 17:45:11 +0800 Subject: [PATCH 1/6] Add hosted workflow fixture --- docs/hosted-workflow-fixture.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/hosted-workflow-fixture.md diff --git a/docs/hosted-workflow-fixture.md b/docs/hosted-workflow-fixture.md new file mode 100644 index 0000000..62abd74 --- /dev/null +++ b/docs/hosted-workflow-fixture.md @@ -0,0 +1,7 @@ +# Hosted Workflow Fixture + +This temporary fixture exists only to prove the hosted DeepSeekCode GitHub +review/write workflows can run against a real pull request. + +Requested state: after +Current state: before From 95b58f8b86911ec69a775555655a680bf5e9a82d Mon Sep 17 00:00:00 2001 From: willamhou Date: Sat, 23 May 2026 19:18:54 +0800 Subject: [PATCH 2/6] Fix hosted GitHub PR bridge --- .github/workflows/deepseek-code-review.yml | 1 + .github/workflows/deepseek-code-write.yml | 1 + src/cli/commands/pr.rs | 10 +-- src/integrations/github.rs | 34 ++++++++- src/tools/github.rs | 87 ++++++++++++++++++++-- 5 files changed, 118 insertions(+), 15 deletions(-) diff --git a/.github/workflows/deepseek-code-review.yml b/.github/workflows/deepseek-code-review.yml index 1652a21..3b2a6e6 100644 --- a/.github/workflows/deepseek-code-review.yml +++ b/.github/workflows/deepseek-code-review.yml @@ -41,6 +41,7 @@ jobs: - name: Run DeepSeekCode GitHub Action bridge if: ${{ env.DEEPSEEK_API_KEY != '' }} env: + GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }} run: cargo run --locked -- github action --mode review --post --trigger "@deepseek" diff --git a/.github/workflows/deepseek-code-write.yml b/.github/workflows/deepseek-code-write.yml index 16bbb0d..522b47d 100644 --- a/.github/workflows/deepseek-code-write.yml +++ b/.github/workflows/deepseek-code-write.yml @@ -30,6 +30,7 @@ jobs: env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} steps: - name: Checkout DeepSeekCode uses: actions/checkout@v4 diff --git a/src/cli/commands/pr.rs b/src/cli/commands/pr.rs index 9f5cd38..bc0444b 100644 --- a/src/cli/commands/pr.rs +++ b/src/cli/commands/pr.rs @@ -298,7 +298,7 @@ fn run_review(config: AppConfig, reference: &str, post: bool, out: Option<&str>) let result = runtime.run_with( context, AgentLoopOptions { - steps: 4, + steps: 6, initial_observations: observations, ..AgentLoopOptions::default() }, @@ -326,7 +326,7 @@ fn build_review_body(pr: &PrContext, planner_output: &str) -> String { fn build_review_task_text(pr: &PrContext) -> String { format!( - "Review pull request #{} '{}' on {}/{}. Highlight correctness risks, security concerns, and style violations. Output a markdown report.", + "Review pull request #{} '{}' in repository {} on branch {}. Use the provided PR diff and changed-file observations first. Highlight correctness risks, security concerns, and style violations. Output a markdown report.", pr.number, pr.title, pr.repo, pr.branch ) } @@ -427,7 +427,7 @@ fn run_patch( runtime.run_with( context, AgentLoopOptions { - steps: 4, + steps: 8, initial_observations: observations, ..AgentLoopOptions::default() }, @@ -449,8 +449,8 @@ fn run_patch( fn build_patch_task_text(pr: &PrContext) -> String { format!( - "Address review feedback or apply the requested change in PR #{} '{}'. PR diff is the current head; propose minimal additional changes.", - pr.number, pr.title + "Address review feedback or apply the requested change in PR #{} '{}' in repository {} on branch {}. Use the provided PR diff observation and the current checkout first; when the requested change is clear, edit files directly, then run focused validation.", + pr.number, pr.title, pr.repo, pr.branch ) } diff --git a/src/integrations/github.rs b/src/integrations/github.rs index 4d61c7c..5390713 100644 --- a/src/integrations/github.rs +++ b/src/integrations/github.rs @@ -267,6 +267,18 @@ fn run_gh(args: &[&str]) -> AppResult { run_capture_stdout("gh", args) } +fn pr_comment_args(repo: &str, number: u64, body_file: &str) -> Vec { + vec![ + "pr".to_string(), + "comment".to_string(), + number.to_string(), + "--repo".to_string(), + repo.to_string(), + "--body-file".to_string(), + body_file.to_string(), + ] +} + #[derive(Debug, Clone, PartialEq, Eq)] struct GhPrSelector { arg: String, @@ -405,9 +417,10 @@ pub fn post_pr_comment(repo: &str, number: u64, body: &str) -> AppResult<()> { file.flush()?; drop(file); - let target = format!("{repo}#{number}"); let path_str = path.to_string_lossy().into_owned(); - let result = run_gh(&["pr", "comment", &target, "--body-file", &path_str]).map(|_| ()); + let args = pr_comment_args(repo, number, &path_str); + let arg_refs = args.iter().map(String::as_str).collect::>(); + let result = run_gh(&arg_refs).map(|_| ()); let _ = std::fs::remove_file(&path); result } @@ -553,6 +566,23 @@ mod tests { assert_eq!(selector.repo, None); } + #[test] + fn pr_comment_args_use_number_with_repo_flag() { + let args = pr_comment_args("willamhou/DeepSeekCode", 10, "/tmp/body.md"); + assert_eq!( + args, + vec![ + "pr", + "comment", + "10", + "--repo", + "willamhou/DeepSeekCode", + "--body-file", + "/tmp/body.md" + ] + ); + } + #[test] fn parse_pr_view_extracts_metadata() { let body = r#"{ diff --git a/src/tools/github.rs b/src/tools/github.rs index e8c5793..15b3054 100644 --- a/src/tools/github.rs +++ b/src/tools/github.rs @@ -24,7 +24,8 @@ impl Tool for GithubPrContextTool { } fn execute(&self, input: ToolInput) -> AppResult { - let number = required_reference(&input, "github_pr_context")?; + let target = pr_target_from_input(&input, "github_pr_context")?; + let number = target.number; let max_chars = parse_usize_arg(&input, "max_chars", DEFAULT_MAX_CHARS).clamp(1, HARD_MAX_CHARS); let mut args = vec![ @@ -34,7 +35,7 @@ impl Tool for GithubPrContextTool { "--json".to_string(), "number,title,state,author,body,comments,reviews,reviewDecision,statusCheckRollup,baseRefName,headRefName,headRefOid,baseRefOid,files,url,createdAt,updatedAt".to_string(), ]; - append_repo_args(&mut args, &input); + append_repo_arg(&mut args, target.repo.as_deref()); let raw = run_gh(&args)?; let root = parse_root_object(&raw)?; let number = root @@ -84,7 +85,7 @@ impl Tool for GithubPrContextTool { number.clone(), "--patch".to_string(), ]; - append_repo_args(&mut diff_args, &input); + append_repo_arg(&mut diff_args, target.repo.as_deref()); let diff = run_gh(&diff_args)?; summary.push_str("diff:\n"); summary.push_str(&clip(&diff, diff_chars)); @@ -335,6 +336,43 @@ fn required_reference(input: &ToolInput, tool_name: &str) -> AppResult { .ok_or_else(|| app_error(format!("{tool_name} requires `number`"))) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct GithubPrTarget { + number: String, + repo: Option, +} + +fn pr_target_from_input(input: &ToolInput, tool_name: &str) -> AppResult { + let reference = required_reference(input, tool_name)?; + let repo_arg = optional_repo_arg(input); + if let Some((repo, number)) = split_qualified_pr_reference(&reference) { + if let Some(repo_arg) = repo_arg.as_deref() { + if repo_arg != repo { + return Err(app_error(format!( + "{tool_name} received conflicting repositories `{repo}` and `{repo_arg}`" + ))); + } + } + return Ok(GithubPrTarget { + number: number.to_string(), + repo: Some(repo.to_string()), + }); + } + Ok(GithubPrTarget { + number: reference, + repo: repo_arg, + }) +} + +fn split_qualified_pr_reference(reference: &str) -> Option<(&str, &str)> { + let trimmed = reference.trim(); + let (repo, number) = trimmed.split_once('#')?; + if repo.split('/').count() != 2 || !number.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + Some((repo, number)) +} + fn validate_positive_number(value: &str, key: &str) -> AppResult<()> { let parsed = value .trim() @@ -623,15 +661,23 @@ fn require_nonempty_json_array_field( } fn append_repo_args(args: &mut Vec, input: &ToolInput) { - if let Some(repo) = input + append_repo_arg(args, optional_repo_arg(input).as_deref()); +} + +fn append_repo_arg(args: &mut Vec, repo: Option<&str>) { + if let Some(repo) = repo { + args.push("-R".to_string()); + args.push(repo.to_string()); + } +} + +fn optional_repo_arg(input: &ToolInput) -> Option { + input .get("repo") .or_else(|| input.get("repository")) .map(str::trim) .filter(|value| !value.is_empty()) - { - args.push("-R".to_string()); - args.push(repo.to_string()); - } + .map(str::to_string) } fn run_gh(args: &[String]) -> AppResult { @@ -805,6 +851,31 @@ exit 2 assert!(output.summary.contains("diff --git")); } + #[test] + fn pr_target_from_input_splits_qualified_reference() { + let target = pr_target_from_input( + &ToolInput::new().with_arg("ref", "willamhou/DeepSeekCode#10"), + "github_pr_context", + ) + .unwrap(); + + assert_eq!(target.number, "10"); + assert_eq!(target.repo.as_deref(), Some("willamhou/DeepSeekCode")); + } + + #[test] + fn pr_target_from_input_rejects_conflicting_repo() { + let err = pr_target_from_input( + &ToolInput::new() + .with_arg("ref", "willamhou/DeepSeekCode#10") + .with_arg("repo", "other/repo"), + "github_pr_context", + ) + .unwrap_err(); + + assert!(err.to_string().contains("conflicting repositories")); + } + #[cfg(unix)] #[test] fn github_issue_context_reads_issue_from_gh() { From 7ece0f33e16d419a7ae77badebb56d545d112274 Mon Sep 17 00:00:00 2001 From: willamhou Date: Sat, 23 May 2026 19:28:06 +0800 Subject: [PATCH 3/6] Preserve GitHub action patch request --- src/cli/app.rs | 7 +++ src/cli/commands/github.rs | 118 +++++++++++++++++++++++++++++++++++-- src/cli/commands/pr.rs | 62 +++++++++++++++---- 3 files changed, 171 insertions(+), 16 deletions(-) diff --git a/src/cli/app.rs b/src/cli/app.rs index 73a45f6..4ca5f27 100644 --- a/src/cli/app.rs +++ b/src/cli/app.rs @@ -83,10 +83,12 @@ pub enum PrAction { Fix { reference: String, job: Option, + request: Option, benchmark_gate: bool, }, Patch { reference: String, + request: Option, commit: bool, benchmark_gate: bool, }, @@ -587,6 +589,7 @@ pub fn parse_pr_subcommand(args: Vec) -> Result { Ok(PrAction::Fix { reference, job, + request: None, benchmark_gate, }) } @@ -611,6 +614,7 @@ pub fn parse_pr_subcommand(args: Vec) -> Result { } Ok(PrAction::Patch { reference, + request: None, commit, benchmark_gate, }) @@ -6720,10 +6724,12 @@ mod tests { PrAction::Fix { reference, job, + request, benchmark_gate, } => { assert_eq!(reference, "owner/repo#7"); assert_eq!(job.as_deref(), Some("test-rust")); + assert_eq!(request, None); assert!(benchmark_gate); } _ => panic!("expected fix"), @@ -6788,6 +6794,7 @@ mod tests { PrAction::Patch { commit: true, benchmark_gate: true, + request: None, ref reference, } if reference == "5" )); diff --git a/src/cli/commands/github.rs b/src/cli/commands/github.rs index 1bcf2c9..1ceb7fd 100644 --- a/src/cli/commands/github.rs +++ b/src/cli/commands/github.rs @@ -18,6 +18,7 @@ struct GithubActionTarget { repo: String, number: u64, mode: GithubActionMode, + request: Option, trigger_matched: bool, reason: String, } @@ -132,10 +133,12 @@ fn run_action(args: GithubActionArgs) -> AppResult<()> { GithubActionMode::Fix => PrAction::Fix { reference, job: args.job, + request: target.request.clone(), benchmark_gate: false, }, GithubActionMode::Patch => PrAction::Patch { reference, + request: target.request.clone(), commit: args.commit, benchmark_gate: false, }, @@ -400,6 +403,14 @@ fn github_background_task_prompt( if let Some(job) = job.map(str::trim).filter(|job| !job.is_empty()) { lines.push(format!("- CI job focus: {job}")); } + if let Some(request) = target + .request + .as_deref() + .map(str::trim) + .filter(|request| !request.is_empty()) + { + lines.push(format!("- Requested action: {request}")); + } lines.push(String::new()); match target.mode { GithubActionMode::Review => lines.push( @@ -664,6 +675,7 @@ fn github_action_target_from_event( repo, number, mode: resolve_action_mode(requested_mode, ""), + request: None, trigger_matched: true, reason: "pull_request events review without comment trigger".to_string(), }) @@ -680,11 +692,14 @@ fn github_action_target_from_event( .and_then(|comment| string_field(comment, "body").ok()) .unwrap_or(""); let matched = trigger_matches(comment_body, trigger, allow_untriggered)?; + let command = command_after_trigger(comment_body, trigger); + let mode = resolve_action_mode(requested_mode, command); Ok(GithubActionTarget { event_name: event_name.to_string(), repo, number: u64_field(issue, "number")?, - mode: resolve_action_mode(requested_mode, command_after_trigger(comment_body, trigger)), + mode, + request: action_request_from_command(command, mode), trigger_matched: matched, reason: "issue_comment trigger matched on pull request".to_string(), }) @@ -696,11 +711,14 @@ fn github_action_target_from_event( .and_then(|comment| string_field(comment, "body").ok()) .unwrap_or(""); let matched = trigger_matches(comment_body, trigger, allow_untriggered)?; + let command = command_after_trigger(comment_body, trigger); + let mode = resolve_action_mode(requested_mode, command); Ok(GithubActionTarget { event_name: event_name.to_string(), repo, number: u64_field(pull_request, "number")?, - mode: resolve_action_mode(requested_mode, command_after_trigger(comment_body, trigger)), + mode, + request: action_request_from_command(command, mode), trigger_matched: matched, reason: "pull_request_review_comment trigger matched".to_string(), }) @@ -712,11 +730,14 @@ fn github_action_target_from_event( .and_then(|review| string_field(review, "body").ok()) .unwrap_or(""); let matched = trigger_matches(review_body, trigger, allow_untriggered)?; + let command = command_after_trigger(review_body, trigger); + let mode = resolve_action_mode(requested_mode, command); Ok(GithubActionTarget { event_name: event_name.to_string(), repo, number: u64_field(pull_request, "number")?, - mode: resolve_action_mode(requested_mode, command_after_trigger(review_body, trigger)), + mode, + request: action_request_from_command(command, mode), trigger_matched: matched, reason: "pull_request_review trigger matched".to_string(), }) @@ -759,6 +780,46 @@ fn command_after_trigger<'a>(body: &'a str, trigger: &str) -> &'a str { body.get(index + trigger.len()..).unwrap_or("").trim() } +fn action_request_from_command(command: &str, mode: GithubActionMode) -> Option { + let command = command.trim(); + if command.is_empty() { + return None; + } + let request = match leading_action_word(command) { + Some((word, rest_index)) if action_words_for_mode(mode).contains(&word.as_str()) => command + .get(rest_index..) + .unwrap_or("") + .trim_start_matches(|ch: char| ch.is_whitespace() || matches!(ch, ':' | '-' | ',')) + .trim(), + _ => command, + }; + if request.is_empty() { + None + } else { + Some(request.to_string()) + } +} + +fn leading_action_word(command: &str) -> Option<(String, usize)> { + let start = command + .char_indices() + .find_map(|(index, ch)| ch.is_ascii_alphanumeric().then_some(index))?; + let end = command[start..] + .char_indices() + .find_map(|(index, ch)| (!ch.is_ascii_alphanumeric()).then_some(start + index)) + .unwrap_or(command.len()); + Some((command[start..end].to_ascii_lowercase(), end)) +} + +fn action_words_for_mode(mode: GithubActionMode) -> &'static [&'static str] { + match mode { + GithubActionMode::Fix => &["fix", "repair"], + GithubActionMode::Patch => &["patch", "apply"], + GithubActionMode::Review => &["review"], + GithubActionMode::Auto => &[], + } +} + fn repository_full_name(root: &std::collections::BTreeMap) -> AppResult { let repository = object_field(root, "repository")?; string_field(repository, "full_name").map(str::to_string) @@ -802,14 +863,19 @@ fn u64_field(map: &std::collections::BTreeMap, key: &str) -> } fn render_action_target_json(target: &GithubActionTarget, post: bool) -> String { + let request = match target.request.as_deref() { + Some(request) => format!("\"{}\"", json_escape(request)), + None => "null".to_string(), + }; format!( - "{{\"kind\":\"deepseek.github_action_target.v1\",\"event\":\"{}\",\"repo\":\"{}\",\"number\":{},\"reference\":\"{}#{}\",\"mode\":\"{}\",\"post\":{},\"trigger_matched\":{},\"reason\":\"{}\"}}", + "{{\"kind\":\"deepseek.github_action_target.v1\",\"event\":\"{}\",\"repo\":\"{}\",\"number\":{},\"reference\":\"{}#{}\",\"mode\":\"{}\",\"request\":{},\"post\":{},\"trigger_matched\":{},\"reason\":\"{}\"}}", json_escape(&target.event_name), json_escape(&target.repo), target.number, json_escape(&target.repo), target.number, github_action_mode_label(target.mode), + request, post, target.trigger_matched, json_escape(&target.reason) @@ -1012,6 +1078,43 @@ mod tests { assert_eq!(fix.mode, GithubActionMode::Fix); assert_eq!(patch.mode, GithubActionMode::Patch); + assert_eq!(fix.request.as_deref(), Some("the failing CI")); + assert_eq!(patch.request.as_deref(), Some("this follow-up")); + } + + #[test] + fn issue_comment_captures_patch_request_after_command_word() { + let target = github_action_target_from_event( + "issue_comment", + &issue_comment_event( + "@deepseek patch change docs/hosted-workflow-fixture.md Current state to after", + ), + GithubActionMode::Auto, + "@deepseek", + false, + ) + .unwrap(); + + assert_eq!(target.mode, GithubActionMode::Patch); + assert_eq!( + target.request.as_deref(), + Some("change docs/hosted-workflow-fixture.md Current state to after") + ); + } + + #[test] + fn issue_comment_empty_action_command_has_no_request() { + let target = github_action_target_from_event( + "issue_comment", + &issue_comment_event("@deepseek patch"), + GithubActionMode::Auto, + "@deepseek", + false, + ) + .unwrap(); + + assert_eq!(target.mode, GithubActionMode::Patch); + assert_eq!(target.request, None); } #[test] @@ -1083,6 +1186,7 @@ mod tests { repo: "owner/repo".to_string(), number: 7, mode: GithubActionMode::Patch, + request: Some("tighten validation".to_string()), trigger_matched: true, reason: "ok".to_string(), }; @@ -1090,6 +1194,7 @@ mod tests { assert!(rendered.contains("\"reference\":\"owner/repo#7\"")); assert!(rendered.contains("\"mode\":\"patch\"")); + assert!(rendered.contains("\"request\":\"tighten validation\"")); assert!(rendered.contains("\"post\":true")); } @@ -1100,6 +1205,7 @@ mod tests { repo: "owner/repo".to_string(), number: 7, mode: GithubActionMode::Review, + request: None, trigger_matched: true, reason: "ok".to_string(), }; @@ -1117,6 +1223,7 @@ mod tests { repo: "owner/repo".to_string(), number: 7, mode: GithubActionMode::Patch, + request: None, trigger_matched: true, reason: "ok".to_string(), }; @@ -1132,6 +1239,7 @@ mod tests { repo: "owner/repo".to_string(), number: 11, mode: GithubActionMode::Fix, + request: None, trigger_matched: true, reason: "ok".to_string(), }; @@ -1260,6 +1368,7 @@ mod tests { repo: "owner/repo".to_string(), number: 11, mode: GithubActionMode::Fix, + request: Some("fix the background task".to_string()), trigger_matched: true, reason: "issue_comment trigger matched on pull request".to_string(), }; @@ -1269,6 +1378,7 @@ mod tests { assert!(prompt.contains("owner/repo#11")); assert!(prompt.contains("Mode: fix")); + assert!(prompt.contains("Requested action: fix the background task")); assert!(prompt.contains("CI job focus: test-ci")); assert!(prompt.contains("isolated task worktree")); assert!(prompt.contains("Do not push")); diff --git a/src/cli/commands/pr.rs b/src/cli/commands/pr.rs index bc0444b..b93400e 100644 --- a/src/cli/commands/pr.rs +++ b/src/cli/commands/pr.rs @@ -37,13 +37,27 @@ fn run_model_backed_action(config: AppConfig, action: PrAction) -> AppResult<()> PrAction::Fix { reference, job, + request, benchmark_gate, - } => run_fix(config, &reference, job.as_deref(), benchmark_gate), + } => run_fix( + config, + &reference, + job.as_deref(), + request.as_deref(), + benchmark_gate, + ), PrAction::Patch { reference, + request, commit, benchmark_gate, - } => run_patch(config, &reference, commit, benchmark_gate), + } => run_patch( + config, + &reference, + request.as_deref(), + commit, + benchmark_gate, + ), PrAction::LiveStatus { .. } => unreachable!("handled before loading model config"), } } @@ -350,6 +364,7 @@ fn run_fix( config: AppConfig, reference: &str, job_filter: Option<&str>, + request: Option<&str>, benchmark_gate: bool, ) -> AppResult<()> { ensure_gh_auth()?; @@ -365,7 +380,7 @@ fn run_fix( } }; - let task = build_fix_task_text(&pr, &failure); + let task = build_fix_task_text(&pr, &failure, request); let context = TaskContext::new(task, None); let observations = vec![Observation::ok("run_shell", failure.log_tail.clone())]; @@ -389,23 +404,26 @@ fn run_fix( Ok(()) } -fn build_fix_task_text(pr: &PrContext, failure: &CiFailure) -> String { +fn build_fix_task_text(pr: &PrContext, failure: &CiFailure, request: Option<&str>) -> String { let step_clause = failure .failed_step .as_ref() .map(|step| format!(" at step `{step}`")) .unwrap_or_default(); - format!( + let mut text = format!( "CI job `{job}` (run #{run_id}) on PR #{number} failed{step_clause}. Reproduce locally, fix the root cause, and rerun the failing test. Failed log tail follows.", job = failure.job_name, run_id = failure.run_id, number = pr.number, - ) + ); + append_requested_action(&mut text, request); + text } fn run_patch( config: AppConfig, reference: &str, + request: Option<&str>, commit: bool, benchmark_gate: bool, ) -> AppResult<()> { @@ -419,7 +437,7 @@ fn run_patch( )); } - let task = build_patch_task_text(&pr); + let task = build_patch_task_text(&pr, request); let context = TaskContext::new(task, None); let observations = vec![Observation::ok("git_diff", pr.diff.clone())]; @@ -447,11 +465,20 @@ fn run_patch( Ok(()) } -fn build_patch_task_text(pr: &PrContext) -> String { - format!( +fn build_patch_task_text(pr: &PrContext, request: Option<&str>) -> String { + let mut text = format!( "Address review feedback or apply the requested change in PR #{} '{}' in repository {} on branch {}. Use the provided PR diff observation and the current checkout first; when the requested change is clear, edit files directly, then run focused validation.", pr.number, pr.title, pr.repo, pr.branch - ) + ); + append_requested_action(&mut text, request); + text +} + +fn append_requested_action(text: &mut String, request: Option<&str>) { + if let Some(request) = request.map(str::trim).filter(|request| !request.is_empty()) { + text.push_str("\n\nRequested action from GitHub comment: "); + text.push_str(request); + } } fn run_git(args: &[&str]) -> AppResult<()> { @@ -517,7 +544,7 @@ mod tests { #[test] fn fix_task_text_includes_run_id_and_step() { - let text = build_fix_task_text(&fixture_pr(12, "Some PR"), &fixture_failure()); + let text = build_fix_task_text(&fixture_pr(12, "Some PR"), &fixture_failure(), None); assert!(text.contains("run #555")); assert!(text.contains("test-rust")); assert!(text.contains("cargo test")); @@ -526,11 +553,22 @@ mod tests { #[test] fn patch_task_text_mentions_pr_number_and_title() { - let text = build_patch_task_text(&fixture_pr(9, "Tighten retry loop")); + let text = build_patch_task_text(&fixture_pr(9, "Tighten retry loop"), None); assert!(text.contains("#9")); assert!(text.contains("Tighten retry loop")); } + #[test] + fn patch_task_text_includes_github_comment_request() { + let text = build_patch_task_text( + &fixture_pr(9, "Tighten retry loop"), + Some("change docs/hosted-workflow-fixture.md to after"), + ); + + assert!(text.contains("Requested action from GitHub comment")); + assert!(text.contains("change docs/hosted-workflow-fixture.md to after")); + } + #[test] fn live_status_reports_readiness_without_write_requirement() { let mut pr = fixture_pr(42, "Route benchmark command"); From 8c99d7ddbcd7a9eb5e65b271ee6d180302628ee4 Mon Sep 17 00:00:00 2001 From: willamhou Date: Sat, 23 May 2026 19:31:38 +0800 Subject: [PATCH 4/6] Accept GitHub Actions token env for gh auth --- src/integrations/github.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/integrations/github.rs b/src/integrations/github.rs index 5390713..a077985 100644 --- a/src/integrations/github.rs +++ b/src/integrations/github.rs @@ -253,6 +253,9 @@ pub fn parse_failed_job_from_run(body: &str, job_name: &str) -> AppResult<(u64, use crate::util::process::{run_capture, run_capture_stdout}; pub fn ensure_gh_auth() -> AppResult<()> { + if gh_env_token_present() { + return Ok(()); + } let captured = run_capture("gh", &["auth", "status"])?; if !captured.success { return Err(crate::error::policy_denied(format!( @@ -263,6 +266,17 @@ pub fn ensure_gh_auth() -> AppResult<()> { Ok(()) } +fn gh_env_token_present() -> bool { + gh_env_token_present_with(|name| std::env::var(name).ok()) +} + +fn gh_env_token_present_with(mut get_var: impl FnMut(&str) -> Option) -> bool { + ["GH_TOKEN", "GITHUB_TOKEN"] + .iter() + .filter_map(|name| get_var(name)) + .any(|value| !value.trim().is_empty()) +} + fn run_gh(args: &[&str]) -> AppResult { run_capture_stdout("gh", args) } @@ -510,6 +524,22 @@ mod tests { assert!(parse_pr_ref("owner/repo#abc").is_err()); } + #[test] + fn gh_env_token_present_accepts_github_actions_tokens() { + assert!(gh_env_token_present_with(|name| match name { + "GH_TOKEN" => Some(" token ".to_string()), + _ => None, + })); + assert!(gh_env_token_present_with(|name| match name { + "GITHUB_TOKEN" => Some("token".to_string()), + _ => None, + })); + assert!(!gh_env_token_present_with(|name| match name { + "GH_TOKEN" => Some(" ".to_string()), + _ => None, + })); + } + #[test] fn parses_url_with_query_string() { let parsed = From 729c331b95cc2bb5e2ad25b823ec28ef16539fd4 Mon Sep 17 00:00:00 2001 From: willamhou Date: Sat, 23 May 2026 19:41:17 +0800 Subject: [PATCH 5/6] Apply exact PR patch requests directly --- src/cli/commands/pr.rs | 190 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/src/cli/commands/pr.rs b/src/cli/commands/pr.rs index b93400e..ef6dd10 100644 --- a/src/cli/commands/pr.rs +++ b/src/cli/commands/pr.rs @@ -11,6 +11,7 @@ use crate::integrations::github::{ }; use crate::model::protocol::Observation; use crate::util::json::json_escape; +use std::path::{Component, Path, PathBuf}; pub fn run(action: PrAction) -> AppResult<()> { match action { @@ -437,6 +438,20 @@ fn run_patch( )); } + if let Some(edit) = request.and_then(parse_direct_replacement_request) { + let changed = apply_direct_replacement(&edit)?; + if changed { + println!("applied requested replacement in {}", edit.path.display()); + } else { + println!( + "requested replacement already present in {}", + edit.path.display() + ); + } + finish_patch(&config, &pr, commit, benchmark_gate)?; + return Ok(()); + } + let task = build_patch_task_text(&pr, request); let context = TaskContext::new(task, None); let observations = vec![Observation::ok("git_diff", pr.diff.clone())]; @@ -451,6 +466,15 @@ fn run_patch( }, )?; + finish_patch(&config, &pr, commit, benchmark_gate) +} + +fn finish_patch( + config: &AppConfig, + pr: &PrContext, + commit: bool, + benchmark_gate: bool, +) -> AppResult<()> { if commit { run_git(&["add", "-A"])?; let message = format!("deepseek: fix PR #{}", pr.number); @@ -481,6 +505,111 @@ fn append_requested_action(text: &mut String, request: Option<&str>) { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct DirectReplacementRequest { + path: PathBuf, + find: String, + replace: String, +} + +fn parse_direct_replacement_request(request: &str) -> Option { + let request = request.trim(); + if request.is_empty() { + return None; + } + let lower = request.to_ascii_lowercase(); + let quoted = quoted_segments(request); + + if lower.contains(" becomes ") && quoted.len() >= 3 { + return Some(DirectReplacementRequest { + path: safe_relative_path("ed[0]).ok()?, + find: quoted[1].clone(), + replace: quoted[2].clone(), + }); + } + + if lower.contains("replace ") + && lower.contains(" with ") + && lower.contains(" in ") + && quoted.len() >= 2 + { + let in_index = lower.rfind(" in ")?; + return Some(DirectReplacementRequest { + path: safe_relative_path(request[in_index + 4..].trim().trim_matches('`')).ok()?, + find: quoted[quoted.len() - 2].clone(), + replace: quoted[quoted.len() - 1].clone(), + }); + } + + None +} + +fn quoted_segments(text: &str) -> Vec { + let mut segments = Vec::new(); + let mut index = 0; + while index < text.len() { + let Some((start_offset, quote)) = text[index..] + .char_indices() + .find(|(_, ch)| matches!(ch, '`' | '"')) + else { + break; + }; + let start_quote = index + start_offset; + let content_start = start_quote + quote.len_utf8(); + let Some(end_offset) = text[content_start..].find(quote) else { + break; + }; + let content_end = content_start + end_offset; + segments.push(text[content_start..content_end].to_string()); + index = content_end + quote.len_utf8(); + } + segments +} + +fn safe_relative_path(path: &str) -> AppResult { + let path = Path::new(path.trim()); + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(app_error("direct patch request path must be relative")); + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(app_error( + "direct patch request path cannot escape the workspace", + )); + } + Ok(path.to_path_buf()) +} + +fn apply_direct_replacement(edit: &DirectReplacementRequest) -> AppResult { + let body = std::fs::read_to_string(&edit.path).map_err(|error| { + app_error(format!( + "failed to read direct patch target {}: {error}", + edit.path.display() + )) + })?; + if !body.contains(&edit.find) { + if body.contains(&edit.replace) { + return Ok(false); + } + return Err(app_error(format!( + "direct patch target {} did not contain requested text", + edit.path.display() + ))); + } + let updated = body.replacen(&edit.find, &edit.replace, 1); + std::fs::write(&edit.path, updated).map_err(|error| { + app_error(format!( + "failed to write direct patch target {}: {error}", + edit.path.display() + )) + })?; + Ok(true) +} + fn run_git(args: &[&str]) -> AppResult<()> { crate::util::process::run_capture_stdout("git", args).map(|_| ()) } @@ -516,6 +645,25 @@ mod tests { } } + fn unique_pr_test_dir(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "deepseek-pr-test-{}-{nanos}", + label.replace('/', "-") + )) + } + + struct CwdGuard(PathBuf); + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + #[test] fn review_task_text_mentions_number_and_title() { let text = build_review_task_text(&fixture_pr(12, "Add feature X")); @@ -569,6 +717,48 @@ mod tests { assert!(text.contains("change docs/hosted-workflow-fixture.md to after")); } + #[test] + fn parses_direct_replacement_from_comment_request() { + let request = parse_direct_replacement_request( + "change `docs/hosted-workflow-fixture.md` so `Current state: before` becomes `Current state: after`", + ) + .unwrap(); + + assert_eq!( + request.path, + PathBuf::from("docs/hosted-workflow-fixture.md") + ); + assert_eq!(request.find, "Current state: before"); + assert_eq!(request.replace, "Current state: after"); + } + + #[test] + fn apply_direct_replacement_updates_one_file() { + let root = unique_pr_test_dir("direct-replacement"); + std::fs::create_dir_all(root.join("docs")).unwrap(); + let original_cwd = std::env::current_dir().unwrap(); + let guard = CwdGuard(original_cwd); + std::env::set_current_dir(&root).unwrap(); + std::fs::write( + root.join("docs/hosted-workflow-fixture.md"), + "Requested state: after\nCurrent state: before\n", + ) + .unwrap(); + + let changed = apply_direct_replacement(&DirectReplacementRequest { + path: PathBuf::from("docs/hosted-workflow-fixture.md"), + find: "Current state: before".to_string(), + replace: "Current state: after".to_string(), + }) + .unwrap(); + + assert!(changed); + let body = std::fs::read_to_string(root.join("docs/hosted-workflow-fixture.md")).unwrap(); + assert!(body.contains("Current state: after")); + drop(guard); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn live_status_reports_readiness_without_write_requirement() { let mut pr = fixture_pr(42, "Route benchmark command"); From 6fd50104a66c30f1ff508f509fba34b57499ad71 Mon Sep 17 00:00:00 2001 From: "deepseek-code[bot]" Date: Sat, 23 May 2026 11:42:30 +0000 Subject: [PATCH 6/6] deepseek: apply requested PR update --- docs/hosted-workflow-fixture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/hosted-workflow-fixture.md b/docs/hosted-workflow-fixture.md index 62abd74..338e664 100644 --- a/docs/hosted-workflow-fixture.md +++ b/docs/hosted-workflow-fixture.md @@ -4,4 +4,4 @@ This temporary fixture exists only to prove the hosted DeepSeekCode GitHub review/write workflows can run against a real pull request. Requested state: after -Current state: before +Current state: after