diff --git a/.codex/skills/qa-night-shift/SKILL.md b/.codex/skills/qa-night-shift/SKILL.md index 5eebb72..b65823a 100644 --- a/.codex/skills/qa-night-shift/SKILL.md +++ b/.codex/skills/qa-night-shift/SKILL.md @@ -168,6 +168,24 @@ In review-driven runs, pay attention to repo-state evidence: - whether `status` and `report` show payload-repair attempts, successes, and failures with usable artifact paths +In delivery-focused investigations, also validate reviewer handoff behavior +when the repo config uses `[handoff]`: + +- whether the delivered PR body includes or omits the Night Shift-owned + handoff overlay according to `pr_body_mode` +- whether Night Shift preserves manual PR text outside its marked body region + across later updates +- whether configured snippet files are spliced into the PR body or managed + comment in the expected order +- whether unreadable snippet paths degrade to `pr_handoff_warning` evidence + instead of blocking PR delivery +- whether managed comments stay disabled by default and only appear when + `[handoff].managed_comment = true` +- whether the managed comment is updated in place instead of adding new comment + noise on each delivery +- whether handoff provenance labels clearly separate deterministic Night + Shift-owned evidence from provider-authored summary text + Use small tasks that validate the requested behavior instead of inviting large feature work. diff --git a/docs/configuration.md b/docs/configuration.md index 1d9576c..fe61103 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ --- title: Configuration -description: Configure profiles, phase defaults, verification commands, and provider overrides. +description: Configure profiles, phase defaults, verification commands, handoff behavior, and provider overrides. permalink: /configuration/ --- @@ -50,6 +50,13 @@ mode = "ask" [verification] commands = ["gleam test"] + +[handoff] +enabled = true +pr_body_mode = "append" +managed_comment = false +provenance = "structured" +pr_body_prefix_path = ".night-shift/pr-handoff-prefix.md" ``` If `config.toml` is empty, Night Shift still works. The built-in default @@ -117,6 +124,38 @@ These top-level settings shape how Night Shift delivers completed work: - `notifiers`: currently `console` and `report_file` - `[verification].commands`: commands to run locally before PR delivery +## Handoff Settings + +`[handoff]` controls the optional reviewer-facing metadata that Night Shift can +overlay onto delivered pull requests. + +Supported fields: + +- `enabled`: master switch for Night Shift handoff output +- `pr_body_mode`: `off`, `append`, or `prepend` +- `managed_comment`: whether Night Shift owns and updates one incremental PR + comment with "Since Last Review" deltas +- `provenance`: `minimal`, `light`, or `structured` +- `include_files_touched` +- `include_acceptance` +- `include_stack_context` +- `include_verification_summary` +- `pr_body_prefix_path`, `pr_body_suffix_path` +- `comment_prefix_path`, `comment_suffix_path` + +When `[handoff]` is absent, Night Shift uses the conservative default: + +- handoff enabled +- PR body overlay appended +- managed comment disabled +- structured provenance +- files touched, stack context, and verification summary included + +Snippet paths are repo-relative markdown fragments. Night Shift splices them +around its generated handoff sections; they augment the structured layout and +do not replace it. If a configured snippet path cannot be read, Night Shift +falls back to generated content and records a warning event. + Example configs live in: - `examples/config-single-profile.toml` diff --git a/docs/providers-and-delivery.md b/docs/providers-and-delivery.md index 7e62628..717d5ee 100644 --- a/docs/providers-and-delivery.md +++ b/docs/providers-and-delivery.md @@ -64,6 +64,8 @@ Night Shift's current delivery model is: - each completed task is delivered as a pull request - dependent tasks may be delivered as stacked pull requests - verification runs locally before PR creation +- Night Shift can overlay a configurable reviewer handoff block onto the PR + body, with repo-local markdown snippets before or after the generated block - the local markdown report is updated throughout the run - `night-shift report` is the live audit view for review-driven runs and can show current drift against the saved open-PR snapshot @@ -82,7 +84,30 @@ Night Shift's current delivery model is: worktree before falling back to manual attention Delivery behavior is shaped by `base_branch`, `branch_prefix`, -`pr_title_prefix`, and `[verification].commands` in `config.toml`. +`pr_title_prefix`, `[verification].commands`, and `[handoff]` in +`config.toml`. + +## Reviewer Handoff + +When handoff output is enabled, Night Shift can add a structured PR-body region +covering: + +- context for why the PR exists +- scope such as `files_touched`, acceptance cues, and stack/supersession + metadata when configured +- model-authored summary text and known risks +- deterministic evidence such as verification output +- provenance labels that distinguish Night Shift-owned facts from inferred + provider-authored text + +Night Shift encloses its PR-body overlay in stable markers and only rewrites +that marked region on later updates, so manual text outside the markers can +survive future delivery passes. + +If `[handoff].managed_comment = true`, Night Shift also owns one PR comment for +incremental review deltas such as "Since Last Review", review-driven context, +and replacement-stack status. Repositories with stricter comment etiquette can +leave that disabled and still use the PR-body overlay. ## Dashboard diff --git a/docs/state-and-artifacts.md b/docs/state-and-artifacts.md index 6418fee..c7c8a65 100644 --- a/docs/state-and-artifacts.md +++ b/docs/state-and-artifacts.md @@ -48,6 +48,9 @@ The run record itself stores: - planning provenance such as `notes only` or `reviews + notes` - an open-PR repo-state snapshot for review-driven plans - mechanically derived supersession lineage on replacement tasks +- persisted PR handoff state per delivered task, including the last delivered + commit SHA, verification digest, files list, and whether Night Shift had + emitted a body overlay or managed comment - recorded decisions - `planning_dirty` - task list and task states @@ -76,6 +79,8 @@ vanishing into the terminal scrollback. - worktree retention and pruning notes - execution recovery warnings when Night Shift accepted a sanitized or recovered provider payload +- PR handoff warnings such as unreadable snippet paths or managed-comment + update failures - payload-repair attempt, success, and failure notes when Night Shift retried a malformed execution result in place - task summaries @@ -89,7 +94,8 @@ tree when a stored snapshot exists, so its live output is authoritative for current drift while `report.md` remains durable and offline-readable. Task-level provider logs and prompt files live under each run's `logs/` -directory. +directory. PR delivery also keeps the rendered pull request body under `logs/` +so operators can inspect the exact handoff Night Shift attempted to publish. Task worktrees are intentionally sticky. Night Shift keeps them mounted after completion so operators can inspect delivery state or resume later without @@ -115,6 +121,12 @@ under distinct `.payload-repair.*` log and prompt artifacts. If that retry still fails, manual-attention summaries include both the original malformed payload path and the repair artifacts. +When `[handoff]` points at snippet files such as `pr_body_prefix_path` or +`comment_suffix_path`, Night Shift reads those repo-relative markdown files at +delivery time. Missing or unreadable snippets do not block PR delivery; Night +Shift records a `pr_handoff_warning` event and falls back to generated handoff +content. + ## Active Lock Night Shift keeps `./.night-shift/active.lock` so only one active run can diff --git a/src/night_shift/codec/journal.gleam b/src/night_shift/codec/journal.gleam index ce5bdb1..8005ec1 100644 --- a/src/night_shift/codec/journal.gleam +++ b/src/night_shift/codec/journal.gleam @@ -44,6 +44,10 @@ pub fn encode_run(run: types.RunRecord) -> String { #("created_at", json.string(run.created_at)), #("updated_at", json.string(run.updated_at)), #("tasks", json.array(run.tasks, encode_task)), + #( + "handoff_states", + json.array(run.handoff_states, encode_task_handoff_state), + ), ]) |> json.to_string } @@ -193,6 +197,20 @@ fn encode_task(task: types.Task) -> json.Json { ]) } +fn encode_task_handoff_state(state: types.TaskHandoffState) -> json.Json { + json.object([ + #("task_id", json.string(state.task_id)), + #("delivered_pr_number", json.string(state.delivered_pr_number)), + #("last_delivered_commit_sha", json.string(state.last_delivered_commit_sha)), + #("last_handoff_files", json.array(state.last_handoff_files, json.string)), + #("last_verification_digest", json.string(state.last_verification_digest)), + #("last_risks", json.array(state.last_risks, json.string)), + #("last_handoff_updated_at", json.string(state.last_handoff_updated_at)), + #("body_region_present", json.bool(state.body_region_present)), + #("managed_comment_present", json.bool(state.managed_comment_present)), + ]) +} + fn encode_decision_request(request: types.DecisionRequest) -> json.Json { json.object([ #("key", json.string(request.key)), @@ -262,45 +280,56 @@ fn run_decoder() -> decode.Decoder(types.RunRecord) { use created_at <- decode.field("created_at", decode.string) use updated_at <- decode.field("updated_at", decode.string) use tasks <- decode.field("tasks", decode.list(task_decoder())) - decode.success(types.RunRecord( - run_id: run_id, - repo_root: repo_root, - run_path: run_path, - brief_path: brief_path, - state_path: state_path, - events_path: events_path, - report_path: report_path, - lock_path: lock_path, - planning_agent: planning_agent, - execution_agent: execution_agent, - environment_name: case maybe_environment_name { - Some(name) -> name - None -> "" - }, - max_workers: max_workers, - notes_source: notes_source, - planning_provenance: case planning_provenance { - Some(provenance) -> Some(provenance) - None -> - case notes_source { - Some(source) -> Some(types.NotesOnly(source)) - None -> None - } - }, - repo_state_snapshot: repo_state_snapshot, - decisions: case decisions { - Some(entries) -> entries - None -> [] - }, - planning_dirty: case planning_dirty { - Some(value) -> value - None -> False - }, - status: status, - created_at: created_at, - updated_at: updated_at, - tasks: tasks, - )) + use handoff_states <- decode.optional_field( + "handoff_states", + None, + decode.optional(decode.list(task_handoff_state_decoder())), + ) + decode.success( + types.RunRecord( + run_id: run_id, + repo_root: repo_root, + run_path: run_path, + brief_path: brief_path, + state_path: state_path, + events_path: events_path, + report_path: report_path, + lock_path: lock_path, + planning_agent: planning_agent, + execution_agent: execution_agent, + environment_name: case maybe_environment_name { + Some(name) -> name + None -> "" + }, + max_workers: max_workers, + notes_source: notes_source, + planning_provenance: case planning_provenance { + Some(provenance) -> Some(provenance) + None -> + case notes_source { + Some(source) -> Some(types.NotesOnly(source)) + None -> None + } + }, + repo_state_snapshot: repo_state_snapshot, + decisions: case decisions { + Some(entries) -> entries + None -> [] + }, + planning_dirty: case planning_dirty { + Some(value) -> value + None -> False + }, + status: status, + created_at: created_at, + updated_at: updated_at, + tasks: tasks, + handoff_states: case handoff_states { + Some(entries) -> entries + None -> [] + }, + ), + ) } fn legacy_run_decoder() -> decode.Decoder(types.RunRecord) { @@ -319,29 +348,32 @@ fn legacy_run_decoder() -> decode.Decoder(types.RunRecord) { use updated_at <- decode.field("updated_at", decode.string) use tasks <- decode.field("tasks", decode.list(task_decoder())) let resolved_agent = types.resolved_agent_from_provider(provider) - decode.success(types.RunRecord( - run_id: run_id, - repo_root: repo_root, - run_path: run_path, - brief_path: brief_path, - state_path: state_path, - events_path: events_path, - report_path: report_path, - lock_path: lock_path, - planning_agent: resolved_agent, - execution_agent: resolved_agent, - environment_name: "", - max_workers: max_workers, - notes_source: None, - planning_provenance: None, - repo_state_snapshot: None, - decisions: [], - planning_dirty: False, - status: status, - created_at: created_at, - updated_at: updated_at, - tasks: tasks, - )) + decode.success( + types.RunRecord( + run_id: run_id, + repo_root: repo_root, + run_path: run_path, + brief_path: brief_path, + state_path: state_path, + events_path: events_path, + report_path: report_path, + lock_path: lock_path, + planning_agent: resolved_agent, + execution_agent: resolved_agent, + environment_name: "", + max_workers: max_workers, + notes_source: None, + planning_provenance: None, + repo_state_snapshot: None, + decisions: [], + planning_dirty: False, + status: status, + created_at: created_at, + updated_at: updated_at, + tasks: tasks, + handoff_states: [], + ), + ) } fn resolved_agent_decoder() -> decode.Decoder(types.ResolvedAgentConfig) { @@ -432,6 +464,44 @@ fn task_decoder() -> decode.Decoder(types.Task) { )) } +fn task_handoff_state_decoder() -> decode.Decoder(types.TaskHandoffState) { + use task_id <- decode.field("task_id", decode.string) + use delivered_pr_number <- decode.field("delivered_pr_number", decode.string) + use last_delivered_commit_sha <- decode.field( + "last_delivered_commit_sha", + decode.string, + ) + use last_handoff_files <- decode.field( + "last_handoff_files", + decode.list(decode.string), + ) + use last_verification_digest <- decode.field( + "last_verification_digest", + decode.string, + ) + use last_risks <- decode.field("last_risks", decode.list(decode.string)) + use last_handoff_updated_at <- decode.field( + "last_handoff_updated_at", + decode.string, + ) + use body_region_present <- decode.field("body_region_present", decode.bool) + use managed_comment_present <- decode.field( + "managed_comment_present", + decode.bool, + ) + decode.success(types.TaskHandoffState( + task_id: task_id, + delivered_pr_number: delivered_pr_number, + last_delivered_commit_sha: last_delivered_commit_sha, + last_handoff_files: last_handoff_files, + last_verification_digest: last_verification_digest, + last_risks: last_risks, + last_handoff_updated_at: last_handoff_updated_at, + body_region_present: body_region_present, + managed_comment_present: managed_comment_present, + )) +} + fn decision_request_decoder() -> decode.Decoder(types.DecisionRequest) { use key <- decode.field("key", decode.string) use question <- decode.field("question", decode.string) diff --git a/src/night_shift/config.gleam b/src/night_shift/config.gleam index a427f3b..e4670e6 100644 --- a/src/night_shift/config.gleam +++ b/src/night_shift/config.gleam @@ -15,6 +15,7 @@ import simplifile type Section { RootSection VerificationSection + HandoffSection ProfileSection(name: String) ProfileOverridesSection(name: String) } @@ -61,10 +62,16 @@ pub fn render(config: types.Config) -> String { <> shared.render_string_list(config.verification_commands) } + let handoff_lines = case config.handoff == types.default_handoff_config() { + True -> "" + False -> render_handoff(config.handoff) + } + string.join(root_lines, with: "\n") <> "\n\n" <> profile_lines <> verification_lines + <> handoff_lines <> "\n" } @@ -124,6 +131,7 @@ fn parse_section(section: String) -> Result(Section, String) { case inner { "verification" -> Ok(VerificationSection) + "handoff" -> Ok(HandoffSection) _ -> case string.split(inner, ".") { ["profiles", name] -> Ok(ProfileSection(name)) @@ -220,6 +228,96 @@ fn apply_value( state.section, )) + HandoffSection, "enabled" -> { + use value <- result.try(parse_bool(raw_value, "handoff")) + Ok(ParseState( + types.Config( + ..config, + handoff: types.HandoffConfig(..config.handoff, enabled: value), + ), + state.section, + )) + } + + HandoffSection, "pr_body_mode" -> { + use mode <- result.try( + shared.parse_string(raw_value) + |> types.handoff_body_mode_from_string, + ) + Ok(ParseState( + types.Config( + ..config, + handoff: types.HandoffConfig(..config.handoff, pr_body_mode: mode), + ), + state.section, + )) + } + + HandoffSection, "managed_comment" -> { + use value <- result.try(parse_bool(raw_value, "handoff")) + Ok(ParseState( + types.Config( + ..config, + handoff: types.HandoffConfig(..config.handoff, managed_comment: value), + ), + state.section, + )) + } + + HandoffSection, "provenance" -> { + use level <- result.try( + shared.parse_string(raw_value) + |> types.handoff_provenance_from_string, + ) + Ok(ParseState( + types.Config( + ..config, + handoff: types.HandoffConfig(..config.handoff, provenance: level), + ), + state.section, + )) + } + + HandoffSection, "include_files_touched" -> + parse_handoff_bool_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, include_files_touched: value) + }) + + HandoffSection, "include_acceptance" -> + parse_handoff_bool_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, include_acceptance: value) + }) + + HandoffSection, "include_stack_context" -> + parse_handoff_bool_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, include_stack_context: value) + }) + + HandoffSection, "include_verification_summary" -> + parse_handoff_bool_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, include_verification_summary: value) + }) + + HandoffSection, "pr_body_prefix_path" -> + parse_handoff_path_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, pr_body_prefix_path: value) + }) + + HandoffSection, "pr_body_suffix_path" -> + parse_handoff_path_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, pr_body_suffix_path: value) + }) + + HandoffSection, "comment_prefix_path" -> + parse_handoff_path_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, comment_prefix_path: value) + }) + + HandoffSection, "comment_suffix_path" -> + parse_handoff_path_field(state, raw_value, fn(handoff, value) { + types.HandoffConfig(..handoff, comment_suffix_path: value) + }) + ProfileSection(profile_name), "provider" -> { use provider <- result.try( shared.parse_string(raw_value) @@ -305,6 +403,43 @@ fn blank_profile(name: String) -> types.AgentProfile { types.AgentProfile(..types.default_agent_profile(), name: name) } +fn parse_bool(raw_value: String, context: String) -> Result(Bool, String) { + case shared.parse_string(raw_value) { + "true" -> Ok(True) + "false" -> Ok(False) + _ -> Error("Invalid boolean in " <> context <> ": " <> raw_value) + } +} + +fn parse_handoff_bool_field( + state: ParseState, + raw_value: String, + update: fn(types.HandoffConfig, Bool) -> types.HandoffConfig, +) -> Result(ParseState, String) { + use value <- result.try(parse_bool(raw_value, "handoff")) + Ok(ParseState( + types.Config(..state.config, handoff: update(state.config.handoff, value)), + state.section, + )) +} + +fn parse_handoff_path_field( + state: ParseState, + raw_value: String, + update: fn(types.HandoffConfig, Option(String)) -> types.HandoffConfig, +) -> Result(ParseState, String) { + Ok(ParseState( + types.Config( + ..state.config, + handoff: update( + state.config.handoff, + shared.parse_optional_string(raw_value), + ), + ), + state.section, + )) +} + fn upsert_provider_override( overrides: List(types.ProviderOverride), key: String, @@ -392,3 +527,58 @@ fn render_profile(profile: types.AgentProfile) -> String { with: "\n", ) } + +fn render_handoff(handoff: types.HandoffConfig) -> String { + let lines = + [ + "", + "[handoff]", + "enabled = " <> render_bool(handoff.enabled), + "pr_body_mode = " + <> shared.render_string(types.handoff_body_mode_to_string( + handoff.pr_body_mode, + )), + "managed_comment = " <> render_bool(handoff.managed_comment), + "provenance = " + <> shared.render_string(types.handoff_provenance_to_string( + handoff.provenance, + )), + "include_files_touched = " <> render_bool(handoff.include_files_touched), + "include_acceptance = " <> render_bool(handoff.include_acceptance), + "include_stack_context = " <> render_bool(handoff.include_stack_context), + "include_verification_summary = " + <> render_bool(handoff.include_verification_summary), + ] + |> list.append(optional_handoff_path( + "pr_body_prefix_path", + handoff.pr_body_prefix_path, + )) + |> list.append(optional_handoff_path( + "pr_body_suffix_path", + handoff.pr_body_suffix_path, + )) + |> list.append(optional_handoff_path( + "comment_prefix_path", + handoff.comment_prefix_path, + )) + |> list.append(optional_handoff_path( + "comment_suffix_path", + handoff.comment_suffix_path, + )) + + "\n" <> string.join(lines, with: "\n") +} + +fn optional_handoff_path(key: String, path: Option(String)) -> List(String) { + case path { + Some(value) -> [key <> " = " <> shared.render_string(value)] + None -> [] + } +} + +fn render_bool(value: Bool) -> String { + case value { + True -> "true" + False -> "false" + } +} diff --git a/src/night_shift/domain/pr_handoff.gleam b/src/night_shift/domain/pr_handoff.gleam new file mode 100644 index 0000000..fa0bee3 --- /dev/null +++ b/src/night_shift/domain/pr_handoff.gleam @@ -0,0 +1,400 @@ +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import night_shift/domain/repo_state +import night_shift/types + +pub const body_start_marker = "" + +pub const body_end_marker = "" + +pub type Snippets { + Snippets( + body_prefix: Option(String), + body_suffix: Option(String), + comment_prefix: Option(String), + comment_suffix: Option(String), + ) +} + +pub type RepoStateStatus { + RepoStateStatus(drift: String, open_pr_count: Int, actionable_pr_count: Int) +} + +pub fn empty_snippets() -> Snippets { + Snippets( + body_prefix: None, + body_suffix: None, + comment_prefix: None, + comment_suffix: None, + ) +} + +pub fn verification_digest(output: String) -> String { + repo_state.text_digest(output) +} + +pub fn body_region_enabled(handoff: types.HandoffConfig) -> Bool { + handoff.enabled && handoff.pr_body_mode != types.HandoffBodyOff +} + +pub fn wrap_body_region(content: String) -> String { + body_start_marker <> "\n" <> content <> "\n" <> body_end_marker +} + +pub fn comment_marker(task_id: String) -> String { + "" +} + +pub fn render_body_region( + handoff: types.HandoffConfig, + run: types.RunRecord, + task: types.Task, + execution_result: types.ExecutionResult, + verification_output: String, + snippets: Snippets, +) -> String { + let sections = + [ + render_optional(snippets.body_prefix), + "## Context\n" <> render_context(run, task), + render_scope(handoff, task, execution_result), + "## Summary\n" <> fallback_text(execution_result.pr.summary), + render_evidence(handoff, execution_result, verification_output), + "## Known Risks\n" <> bullet_list(execution_result.pr.risks), + render_provenance( + handoff.provenance, + run, + task, + execution_result, + verification_output, + ), + render_optional(snippets.body_suffix), + ] + |> list.filter(fn(section) { string.trim(section) != "" }) + |> string.join(with: "\n\n") + + wrap_body_region(sections) +} + +pub fn render_managed_comment( + run: types.RunRecord, + task: types.Task, + execution_result: types.ExecutionResult, + verification_output: String, + previous_state: Option(types.TaskHandoffState), + repo_state_status: Option(RepoStateStatus), + snippets: Snippets, +) -> String { + [ + render_optional(snippets.comment_prefix), + "## Since Last Review\n" + <> render_delta(previous_state, execution_result, verification_output), + "## Review Feedback Status\n" <> render_review_feedback_status(run), + render_stack_status(task, repo_state_status), + render_optional(snippets.comment_suffix), + comment_marker(task.id), + ] + |> list.filter(fn(section) { string.trim(section) != "" }) + |> string.join(with: "\n\n") +} + +fn render_context(run: types.RunRecord, task: types.Task) -> String { + let origin = case run.planning_provenance { + Some(provenance) -> + case types.planning_provenance_uses_reviews(provenance) { + True -> "Review-driven replacement from open PR feedback." + False -> "Planned from the Night Shift brief." + } + None -> "Planned from the Night Shift brief." + } + + bullet_list([ + "Reason: " <> origin, + "Run: " <> run.run_id, + "Task: " <> task.id, + "Brief: " <> run.brief_path, + ]) +} + +fn render_scope( + handoff: types.HandoffConfig, + task: types.Task, + execution_result: types.ExecutionResult, +) -> String { + let scope_lines = [] + let scope_lines = case handoff.include_files_touched { + True -> + list.append(scope_lines, [ + "Files touched: " <> inline_list(execution_result.files_touched), + ]) + False -> scope_lines + } + let scope_lines = case handoff.include_acceptance { + True -> + list.append(scope_lines, ["Acceptance: " <> inline_list(task.acceptance)]) + False -> scope_lines + } + let scope_lines = case handoff.include_stack_context { + True -> + list.append( + list.append(scope_lines, [ + "Branch: " <> fallback_scalar(task.branch_name), + "Supersedes: " <> render_pr_numbers(task.superseded_pr_numbers), + ]), + optional_scope_line("PR", task.pr_number), + ) + False -> scope_lines + } + + "## Scope\n" <> bullet_list(scope_lines) +} + +fn render_evidence( + handoff: types.HandoffConfig, + execution_result: types.ExecutionResult, + verification_output: String, +) -> String { + let sections = [ + "Demo evidence:\n" <> bullet_list(execution_result.demo_evidence), + ] + let sections = case handoff.include_verification_summary { + True -> + list.append(sections, [ + "Verification digest: " + <> verification_digest(verification_output) + <> "\n\n```text\n" + <> verification_output + <> "\n```", + ]) + False -> sections + } + + "## Evidence\n" <> string.join(sections, with: "\n\n") +} + +fn render_provenance( + level: types.HandoffProvenance, + run: types.RunRecord, + task: types.Task, + execution_result: types.ExecutionResult, + verification_output: String, +) -> String { + let base_lines = [ + "Planning provenance: " <> planning_label(run.planning_provenance), + "Execution summary source: model-authored", + ] + + let lines = case level { + types.HandoffProvenanceMinimal -> base_lines + types.HandoffProvenanceLight -> + list.append(base_lines, [ + "Execution provider: " <> agent_summary(run.execution_agent), + ]) + types.HandoffProvenanceStructured -> + list.append(base_lines, [ + "Planning agent: " <> agent_summary(run.planning_agent), + "Execution agent: " <> agent_summary(run.execution_agent), + "Deterministic evidence: run id, task id, files touched, verification output, superseded lineage", + "Inferred/model-authored: PR summary, risks, demo narrative", + "Verification digest: " <> verification_digest(verification_output), + "Task status: " <> types.task_state_to_string(execution_result.status), + "Task ref: " <> task.id, + ]) + } + + "## Provenance\n" <> bullet_list(lines) +} + +fn render_delta( + previous_state: Option(types.TaskHandoffState), + execution_result: types.ExecutionResult, + verification_output: String, +) -> String { + let current_files = execution_result.files_touched + let current_risks = execution_result.pr.risks + let current_digest = verification_digest(verification_output) + + case previous_state { + None -> + bullet_list([ + "Initial Night Shift handoff for this PR.", + "Files in this delivery: " <> inline_list(current_files), + "Verification changed: baseline", + "Known risks: " <> inline_list(current_risks), + ]) + Some(state) -> + bullet_list([ + "Added files: " + <> inline_list(list_difference( + current_files, + state.last_handoff_files, + )), + "Removed files: " + <> inline_list(list_difference( + state.last_handoff_files, + current_files, + )), + "Verification changed: " + <> bool_label(state.last_verification_digest != current_digest), + "Risks changed: " + <> bool_label( + string.join(state.last_risks, with: "\n") + != string.join(current_risks, with: "\n"), + ), + ]) + } +} + +fn render_review_feedback_status(run: types.RunRecord) -> String { + case run.planning_provenance { + Some(provenance) -> + case + types.planning_provenance_uses_reviews(provenance), + run.repo_state_snapshot + { + True, Some(snapshot) -> { + let actionable_lines = + snapshot.open_pull_requests + |> list.filter(fn(pr) { pr.actionable }) + |> list.flat_map(fn(pr) { + let comments = + pr.review_comments + |> list.map(fn(comment) { + "#" <> int.to_string(pr.number) <> ": " <> comment + }) + let checks = + pr.failing_checks + |> list.map(fn(check) { + "#" <> int.to_string(pr.number) <> " check: " <> check + }) + list.append(comments, checks) + }) + + case actionable_lines { + [] -> + "- Review-driven run, but no actionable comments or failing checks were captured." + _ -> bullet_list(actionable_lines) + } + } + _, _ -> "- No ingested review feedback for this update." + } + None -> "- No ingested review feedback for this update." + } +} + +fn render_stack_status( + task: types.Task, + repo_state_status: Option(RepoStateStatus), +) -> String { + let lines = case task.superseded_pr_numbers { + [] -> [] + pr_numbers -> ["Supersedes: " <> render_pr_numbers(pr_numbers)] + } + let lines = case repo_state_status { + Some(status) -> + list.append(lines, [ + "Repo-state drift: " <> status.drift, + "Open PRs: " <> int.to_string(status.open_pr_count), + "Actionable PRs: " <> int.to_string(status.actionable_pr_count), + ]) + None -> lines + } + + "## Stack / Replacement Status\n" <> bullet_list(lines) +} + +fn planning_label(provenance: Option(types.PlanningProvenance)) -> String { + case provenance { + Some(value) -> types.planning_provenance_label(value) + None -> "unknown" + } +} + +fn agent_summary(agent: types.ResolvedAgentConfig) -> String { + let model_fragment = case agent.model { + Some(model) -> " model=" <> model + None -> "" + } + let reasoning_fragment = case agent.reasoning { + Some(reasoning) -> " reasoning=" <> types.reasoning_to_string(reasoning) + None -> "" + } + types.provider_to_string(agent.provider) + <> model_fragment + <> reasoning_fragment +} + +fn bullet_list(items: List(String)) -> String { + case + items + |> list.filter(fn(item) { string.trim(item) != "" }) + { + [] -> "- None" + filtered -> + filtered + |> list.map(fn(item) { "- " <> item }) + |> string.join(with: "\n") + } +} + +fn fallback_text(value: String) -> String { + case string.trim(value) { + "" -> "- None" + trimmed -> trimmed + } +} + +fn render_pr_numbers(pr_numbers: List(Int)) -> String { + case pr_numbers { + [] -> "(none)" + _ -> + pr_numbers + |> list.map(fn(number) { "#" <> int.to_string(number) }) + |> string.join(with: ", ") + } +} + +fn inline_list(items: List(String)) -> String { + case + items + |> list.filter(fn(item) { string.trim(item) != "" }) + { + [] -> "(none)" + filtered -> string.join(filtered, with: ", ") + } +} + +fn list_difference(left: List(String), right: List(String)) -> List(String) { + left + |> list.filter(fn(item) { !list.contains(right, item) }) +} + +fn bool_label(value: Bool) -> String { + case value { + True -> "yes" + False -> "no" + } +} + +fn fallback_scalar(value: String) -> String { + case string.trim(value) { + "" -> "(none)" + trimmed -> trimmed + } +} + +fn optional_scope_line(label: String, value: String) -> List(String) { + case string.trim(value) { + "" -> [] + trimmed -> [label <> ": " <> trimmed] + } +} + +fn render_optional(value: Option(String)) -> String { + case value { + Some(contents) -> string.trim(contents) + None -> "" + } +} diff --git a/src/night_shift/domain/pull_request.gleam b/src/night_shift/domain/pull_request.gleam index 356e458..1a751b8 100644 --- a/src/night_shift/domain/pull_request.gleam +++ b/src/night_shift/domain/pull_request.gleam @@ -3,7 +3,7 @@ import gleam/list import gleam/string import night_shift/types -pub fn render_body( +pub fn render_legacy_body( run: types.RunRecord, task: types.Task, execution_result: types.ExecutionResult, @@ -27,6 +27,15 @@ pub fn render_body( <> " -->" } +pub fn render_body( + run: types.RunRecord, + task: types.Task, + execution_result: types.ExecutionResult, + verification_output: String, +) -> String { + render_legacy_body(run, task, execution_result, verification_output) +} + pub fn review_task( number: Int, url: String, diff --git a/src/night_shift/domain/repo_state.gleam b/src/night_shift/domain/repo_state.gleam index c1a1386..cefe10f 100644 --- a/src/night_shift/domain/repo_state.gleam +++ b/src/night_shift/domain/repo_state.gleam @@ -67,6 +67,10 @@ pub fn drifted(stored: RepoStateSnapshot, live: RepoStateSnapshot) -> Bool { stored.digest != live.digest } +pub fn text_digest(value: String) -> String { + sha256_hex(value) +} + fn actionable_head_refs( open_pull_requests: List(RepoPullRequestSnapshot), ) -> List(String) { diff --git a/src/night_shift/github.gleam b/src/night_shift/github.gleam index 89d4dca..9c297cb 100644 --- a/src/night_shift/github.gleam +++ b/src/night_shift/github.gleam @@ -5,11 +5,14 @@ import gleam/dynamic/decode import gleam/int import gleam/json import gleam/list +import gleam/option.{type Option, None, Some} import gleam/result import gleam/string +import night_shift/domain/pr_handoff import night_shift/domain/repo_state import night_shift/shell import night_shift/system +import night_shift/types import simplifile /// Minimal pull request identity returned after delivery. @@ -17,6 +20,15 @@ pub type PullRequest { PullRequest(number: Int, url: String, head_ref_name: String, title: String) } +pub type CommentUpsert { + CommentCreated + CommentUpdated +} + +type IssueComment { + IssueComment(id: Int, body: String) +} + /// Review details ingested when Night Shift turns open PR feedback into work. pub type ReviewWorkItem { ReviewWorkItem( @@ -38,7 +50,9 @@ pub fn open_or_update_pr( branch_name: String, base_ref: String, title: String, - body: String, + legacy_body: String, + handoff_region: Option(String), + handoff: types.HandoffConfig, run_path: String, log_path: String, ) -> Result(PullRequest, String) { @@ -48,10 +62,19 @@ pub fn open_or_update_pr( |> string.replace(each: ":", with: "-") let body_path = filepath.join(run_path, "logs/" <> safe_branch_name <> ".pr.md") - use _ <- result.try(write_file(body_path, body)) case find_pull_request(cwd, branch_name, log_path) { Ok(pull_request) -> { + let final_body = + existing_pr_body(cwd, pull_request.number, log_path) + |> result.map(fn(existing_body) { + compose_pr_body(existing_body, legacy_body, handoff_region, handoff) + }) + let final_body = case final_body { + Ok(body) -> body + Error(_) -> compose_pr_body("", legacy_body, handoff_region, handoff) + } + use _ <- result.try(write_file(body_path, final_body)) use _ <- result.try(edit_pull_request( cwd, pull_request.number, @@ -62,6 +85,8 @@ pub fn open_or_update_pr( Ok(PullRequest(..pull_request, title: title)) } Error(_) -> { + let final_body = compose_pr_body("", legacy_body, handoff_region, handoff) + use _ <- result.try(write_file(body_path, final_body)) use create_output <- result.try(create_pull_request( cwd, branch_name, @@ -79,6 +104,32 @@ pub fn open_or_update_pr( } } +pub fn upsert_handoff_comment( + cwd: String, + pr_number: Int, + task_id: String, + body: String, + log_path: String, +) -> Result(CommentUpsert, String) { + use comments <- result.try(issue_comments(cwd, pr_number, log_path)) + let marker = pr_handoff.comment_marker(task_id) + + case + list.find(comments, fn(comment) { + string.contains(does: comment.body, contain: marker) + }) + { + Ok(comment) -> { + use _ <- result.try(update_issue_comment(cwd, comment.id, body, log_path)) + Ok(CommentUpdated) + } + Error(_) -> { + use _ <- result.try(create_issue_comment(cwd, pr_number, body, log_path)) + Ok(CommentCreated) + } + } +} + /// List open pull requests created by the configured Night Shift branch prefix. pub fn list_night_shift_prs( cwd: String, @@ -188,6 +239,72 @@ fn find_pull_request( }) } +fn existing_pr_body( + cwd: String, + pr_number: Int, + log_path: String, +) -> Result(String, String) { + let command = + gh_pr_command("view ") <> int.to_string(pr_number) <> " --json body" + + let result = shell.run(command, cwd, log_path) + case shell.succeeded(result) { + True -> + json.parse(result.output, pull_request_body_decoder()) + |> result.map_error(fn(_) { "Unable to decode pull request body." }) + False -> Error("Unable to inspect pull request body.") + } +} + +fn compose_pr_body( + existing_body: String, + legacy_body: String, + handoff_region: Option(String), + handoff: types.HandoffConfig, +) -> String { + case handoff.enabled, handoff.pr_body_mode, handoff_region { + False, _, _ -> legacy_body + _, types.HandoffBodyOff, _ -> legacy_body + _, _, None -> legacy_body + _, mode, Some(region) -> + case replace_handoff_region(existing_body, region) { + Some(updated) -> updated + None -> { + let base = first_non_empty(existing_body, legacy_body) + case string.trim(base) { + "" -> region + _ -> + case mode { + types.HandoffBodyPrepend -> region <> "\n\n" <> base + _ -> base <> "\n\n" <> region + } + } + } + } + } +} + +fn replace_handoff_region( + existing_body: String, + next_region: String, +) -> Option(String) { + case string.split_once(existing_body, pr_handoff.body_start_marker) { + Ok(#(before, remainder)) -> + case string.split_once(remainder, pr_handoff.body_end_marker) { + Ok(#(_, after)) -> Some(before <> next_region <> after) + Error(_) -> None + } + Error(_) -> None + } +} + +fn first_non_empty(left: String, right: String) -> String { + case string.trim(left) { + "" -> right + _ -> left + } +} + fn create_pull_request( cwd: String, branch_name: String, @@ -228,6 +345,81 @@ fn comment_pull_request( run_gh(command, cwd, log_path) } +fn create_issue_comment( + cwd: String, + pr_number: Int, + body: String, + log_path: String, +) -> Result(Nil, String) { + let command = + gh_api_command( + "repos/:owner/:repo/issues/" <> int.to_string(pr_number) <> "/comments", + ) + <> " --method POST --raw-field body=" + <> shell.quote(body) + + run_gh(command, cwd, log_path) +} + +fn update_issue_comment( + cwd: String, + comment_id: Int, + body: String, + log_path: String, +) -> Result(Nil, String) { + let command = + gh_api_command( + "repos/:owner/:repo/issues/comments/" <> int.to_string(comment_id), + ) + <> " --method PATCH --raw-field body=" + <> shell.quote(body) + + run_gh(command, cwd, log_path) +} + +fn issue_comments( + cwd: String, + pr_number: Int, + log_path: String, +) -> Result(List(IssueComment), String) { + issue_comments_page(cwd, pr_number, 1, log_path, []) +} + +fn issue_comments_page( + cwd: String, + pr_number: Int, + page: Int, + log_path: String, + acc: List(IssueComment), +) -> Result(List(IssueComment), String) { + let command = + gh_api_command( + "repos/:owner/:repo/issues/" + <> int.to_string(pr_number) + <> "/comments?page=" + <> int.to_string(page) + <> "&per_page=100", + ) + + let result = shell.run(command, cwd, log_path) + case shell.succeeded(result) { + True -> { + use comments <- result.try( + json.parse(result.output, decode.list(issue_comment_decoder())) + |> result.map_error(fn(_) { "Unable to decode issue comments." }), + ) + + let next_acc = list.append(acc, comments) + case list.length(comments) < 100 { + True -> Ok(next_acc) + False -> + issue_comments_page(cwd, pr_number, page + 1, log_path, next_acc) + } + } + False -> Error("Unable to inspect issue comments.") + } +} + fn close_pull_request( cwd: String, pr_number: Int, @@ -267,6 +459,10 @@ fn gh_pr_command(args: String) -> String { gh_executable() <> " pr " <> args } +fn gh_api_command(path: String) -> String { + gh_executable() <> " api " <> shell.quote(path) +} + fn gh_executable() -> String { case system.get_env("NIGHT_SHIFT_GH_BIN") { "" -> "gh" @@ -419,6 +615,17 @@ fn comment_decoder() -> decode.Decoder(String) { decode.success("Comment: " <> body) } +fn pull_request_body_decoder() -> decode.Decoder(String) { + use body <- decode.field("body", decode.string) + decode.success(body) +} + +fn issue_comment_decoder() -> decode.Decoder(IssueComment) { + use id <- decode.field("id", decode.int) + use body <- decode.field("body", decode.string) + decode.success(IssueComment(id: id, body: body)) +} + fn review_work_item_snapshot( review_item: ReviewWorkItem, ) -> repo_state.RepoPullRequestSnapshot { diff --git a/src/night_shift/infra/run_store.gleam b/src/night_shift/infra/run_store.gleam index 976b464..17d5403 100644 --- a/src/night_shift/infra/run_store.gleam +++ b/src/night_shift/infra/run_store.gleam @@ -105,6 +105,7 @@ pub fn create_pending_run_with_context( created_at: timestamp, updated_at: timestamp, tasks: [], + handoff_states: [], ) case save(run, []) { diff --git a/src/night_shift/infra/task_delivery.gleam b/src/night_shift/infra/task_delivery.gleam index 80a8fc5..dac69c9 100644 --- a/src/night_shift/infra/task_delivery.gleam +++ b/src/night_shift/infra/task_delivery.gleam @@ -1,19 +1,33 @@ import filepath import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} import gleam/result +import gleam/string +import night_shift/domain/pr_handoff import night_shift/domain/pull_request as pull_request_domain import night_shift/domain/summary as domain_summary import night_shift/git import night_shift/github import night_shift/provider +import night_shift/repo_state_runtime +import night_shift/system import night_shift/types +import simplifile pub type DeliveryOutcome { NoDeliveredChanges(delivered_files: List(String)) - Delivered(pr_number: String, pr_url: String, delivered_files: List(String)) + Delivered( + pr_number: String, + pr_url: String, + delivered_files: List(String), + handoff_state: types.TaskHandoffState, + handoff_events: List(types.RunEvent), + ) } pub fn deliver_completed_task( + config: types.Config, run: types.RunRecord, task_run: provider.TaskRun, execution_result: types.ExecutionResult, @@ -41,20 +55,37 @@ pub fn deliver_completed_task( git.push_branch(task_run.worktree_path, task_run.branch_name, git_log) |> result.map_error(git_delivery_error), ) - let pr_body = - pull_request_domain.render_body( + let snippets_and_events = + load_snippets(run.repo_root, config.handoff, task_run.task.id) + let #(snippets, snippet_events) = snippets_and_events + let legacy_body = + pull_request_domain.render_legacy_body( run, task_run.task, execution_result, verification_output, ) + let handoff_region = case pr_handoff.body_region_enabled(config.handoff) { + True -> + Some(pr_handoff.render_body_region( + config.handoff, + run, + task_run.task, + execution_result, + verification_output, + snippets, + )) + False -> None + } use pull_request <- result.try( github.open_or_update_pr( task_run.worktree_path, task_run.branch_name, task_run.base_ref, execution_result.pr.title, - pr_body, + legacy_body, + handoff_region, + config.handoff, run.run_path, git_log, ) @@ -65,10 +96,122 @@ pub fn deliver_completed_task( ) }), ) + let repo_state_status = case config.handoff.managed_comment { + True -> + case repo_state_runtime.inspect(run, config.branch_prefix).view { + Some(view) -> + Some(pr_handoff.RepoStateStatus( + drift: repo_state_runtime.drift_label(view.drift), + open_pr_count: view.open_pr_count, + actionable_pr_count: view.actionable_pr_count, + )) + None -> None + } + False -> None + } + let previous_state = + types.task_handoff_state(run.handoff_states, task_run.task.id) + let #(managed_comment_present, comment_events) = case + config.handoff.enabled && config.handoff.managed_comment + { + True -> { + let comment_body = + pr_handoff.render_managed_comment( + run, + task_run.task, + execution_result, + verification_output, + previous_state, + repo_state_status, + snippets, + ) + case + github.upsert_handoff_comment( + task_run.worktree_path, + pull_request.number, + task_run.task.id, + comment_body, + git_log, + ) + { + Ok(github.CommentCreated) -> #(True, [ + handoff_event( + "pr_handoff_created", + task_run.task.id, + "Created managed PR handoff comment for PR #" + <> int.to_string(pull_request.number) + <> ".", + ), + ]) + Ok(github.CommentUpdated) -> #(True, [ + handoff_event( + "pr_handoff_updated", + task_run.task.id, + "Updated managed PR handoff comment for PR #" + <> int.to_string(pull_request.number) + <> ".", + ), + ]) + Error(message) -> #(False, [ + handoff_event( + "pr_handoff_warning", + task_run.task.id, + "Unable to update the managed PR handoff comment: " <> message, + ), + ]) + } + } + False -> #(False, []) + } + let body_region_present = case handoff_region { + Some(_) -> True + None -> False + } + let handoff_state = + types.TaskHandoffState( + task_id: task_run.task.id, + delivered_pr_number: int.to_string(pull_request.number), + last_delivered_commit_sha: delivered_head, + last_handoff_files: execution_result.files_touched, + last_verification_digest: pr_handoff.verification_digest( + verification_output, + ), + last_risks: execution_result.pr.risks, + last_handoff_updated_at: system.timestamp(), + body_region_present: body_region_present, + managed_comment_present: managed_comment_present, + ) + let base_handoff_event = case previous_state { + Some(_) -> [ + handoff_event( + "pr_handoff_updated", + task_run.task.id, + case body_region_present { + True -> "Updated Night Shift PR handoff metadata." + False -> "Persisted Night Shift PR handoff state." + }, + ), + ] + None -> [ + handoff_event( + "pr_handoff_created", + task_run.task.id, + case body_region_present { + True -> "Created Night Shift PR handoff metadata." + False -> "Created Night Shift PR handoff state." + }, + ), + ] + } Ok(Delivered( pr_number: int.to_string(pull_request.number), pr_url: pull_request.url, delivered_files: delivered_files, + handoff_state: handoff_state, + handoff_events: list.append( + list.append(snippet_events, base_handoff_event), + comment_events, + ), )) } } @@ -93,3 +236,71 @@ fn commit_worktree_changes( fn git_delivery_error(message: String) -> String { domain_summary.task_failure_summary("git delivery failed.", message) } + +fn load_snippets( + repo_root: String, + handoff: types.HandoffConfig, + task_id: String, +) -> #(pr_handoff.Snippets, List(types.RunEvent)) { + let #(body_prefix, body_prefix_events) = + load_snippet(repo_root, handoff.pr_body_prefix_path, task_id) + let #(body_suffix, body_suffix_events) = + load_snippet(repo_root, handoff.pr_body_suffix_path, task_id) + let #(comment_prefix, comment_prefix_events) = + load_snippet(repo_root, handoff.comment_prefix_path, task_id) + let #(comment_suffix, comment_suffix_events) = + load_snippet(repo_root, handoff.comment_suffix_path, task_id) + + #( + pr_handoff.Snippets( + body_prefix: body_prefix, + body_suffix: body_suffix, + comment_prefix: comment_prefix, + comment_suffix: comment_suffix, + ), + list.append( + list.append(body_prefix_events, body_suffix_events), + list.append(comment_prefix_events, comment_suffix_events), + ), + ) +} + +fn load_snippet( + repo_root: String, + configured_path: Option(String), + task_id: String, +) -> #(Option(String), List(types.RunEvent)) { + case configured_path { + None -> #(None, []) + Some(path) -> { + let absolute_path = case string.starts_with(path, "/") { + True -> path + False -> filepath.join(repo_root, path) + } + + case simplifile.read(absolute_path) { + Ok(contents) -> #(Some(contents), []) + Error(_) -> #(None, [ + handoff_event( + "pr_handoff_warning", + task_id, + "Unable to read handoff snippet at " <> path <> ".", + ), + ]) + } + } + } +} + +fn handoff_event( + kind: String, + task_id: String, + message: String, +) -> types.RunEvent { + types.RunEvent( + kind: kind, + at: system.timestamp(), + message: message, + task_id: Some(task_id), + ) +} diff --git a/src/night_shift/orchestrator/execution_phase.gleam b/src/night_shift/orchestrator/execution_phase.gleam index 028f053..516a227 100644 --- a/src/night_shift/orchestrator/execution_phase.gleam +++ b/src/night_shift/orchestrator/execution_phase.gleam @@ -413,6 +413,7 @@ fn finalize_success( Ok(verified) -> case task_delivery.deliver_completed_task( + config, run, task_run, verified.execution_result, @@ -427,7 +428,13 @@ fn finalize_success( "Primary blocker: provider reported completion but the task worktree produced no committed or uncommitted changes.\n\nEnvironment notes: verification completed, but there was nothing new to deliver.", "task_manual_attention", ) - Ok(task_delivery.Delivered(pr_number, pr_url, delivered_files)) -> { + Ok(task_delivery.Delivered( + pr_number, + pr_url, + delivered_files, + handoff_state, + handoff_events, + )) -> { let completed_task = types.Task( ..task_run.task, @@ -447,7 +454,15 @@ fn finalize_success( run.decisions, ) |> task_graph.refresh_ready_states - let updated_run = types.RunRecord(..run, tasks: merged_tasks) + let updated_run = + types.RunRecord( + ..run, + tasks: merged_tasks, + handoff_states: types.replace_task_handoff_state( + run.handoff_states, + handoff_state, + ), + ) let verified_event = types.RunEvent( kind: "task_verified", @@ -459,7 +474,7 @@ fn finalize_success( updated_run, verified_event, )) - journal.append_event( + use delivered_run <- result.try(journal.append_event( types.RunRecord( ..verified_run, tasks: task_graph.replace_task( @@ -478,7 +493,8 @@ fn finalize_success( message: pr_url, task_id: Some(task_run.task.id), ), - ) + )) + append_events(delivered_run, handoff_events) } Error(message) -> Error(message) } @@ -495,6 +511,19 @@ fn finalize_success( } } +fn append_events( + run: types.RunRecord, + events: List(types.RunEvent), +) -> Result(types.RunRecord, String) { + case events { + [] -> Ok(run) + [event, ..rest] -> { + use updated_run <- result.try(journal.append_event(run, event)) + append_events(updated_run, rest) + } + } +} + fn append_manual_attention_events( run: types.RunRecord, ) -> Result(types.RunRecord, String) { diff --git a/src/night_shift/types.gleam b/src/night_shift/types.gleam index 2803b47..da2d454 100644 --- a/src/night_shift/types.gleam +++ b/src/night_shift/types.gleam @@ -456,6 +456,7 @@ pub type RunRecord { created_at: String, updated_at: String, tasks: List(Task), + handoff_states: List(TaskHandoffState), ) } @@ -465,6 +466,128 @@ pub type RunSelector { RunId(String) } +pub type HandoffBodyMode { + HandoffBodyOff + HandoffBodyAppend + HandoffBodyPrepend +} + +pub fn handoff_body_mode_from_string( + value: String, +) -> Result(HandoffBodyMode, String) { + case value { + "off" -> Ok(HandoffBodyOff) + "append" -> Ok(HandoffBodyAppend) + "prepend" -> Ok(HandoffBodyPrepend) + _ -> Error("Unsupported handoff PR body mode: " <> value) + } +} + +pub fn handoff_body_mode_to_string(mode: HandoffBodyMode) -> String { + case mode { + HandoffBodyOff -> "off" + HandoffBodyAppend -> "append" + HandoffBodyPrepend -> "prepend" + } +} + +pub type HandoffProvenance { + HandoffProvenanceMinimal + HandoffProvenanceLight + HandoffProvenanceStructured +} + +pub fn handoff_provenance_from_string( + value: String, +) -> Result(HandoffProvenance, String) { + case value { + "minimal" -> Ok(HandoffProvenanceMinimal) + "light" -> Ok(HandoffProvenanceLight) + "structured" -> Ok(HandoffProvenanceStructured) + _ -> Error("Unsupported handoff provenance level: " <> value) + } +} + +pub fn handoff_provenance_to_string(level: HandoffProvenance) -> String { + case level { + HandoffProvenanceMinimal -> "minimal" + HandoffProvenanceLight -> "light" + HandoffProvenanceStructured -> "structured" + } +} + +pub type HandoffConfig { + HandoffConfig( + enabled: Bool, + pr_body_mode: HandoffBodyMode, + managed_comment: Bool, + provenance: HandoffProvenance, + include_files_touched: Bool, + include_acceptance: Bool, + include_stack_context: Bool, + include_verification_summary: Bool, + pr_body_prefix_path: Option(String), + pr_body_suffix_path: Option(String), + comment_prefix_path: Option(String), + comment_suffix_path: Option(String), + ) +} + +pub fn default_handoff_config() -> HandoffConfig { + HandoffConfig( + enabled: True, + pr_body_mode: HandoffBodyAppend, + managed_comment: False, + provenance: HandoffProvenanceStructured, + include_files_touched: True, + include_acceptance: False, + include_stack_context: True, + include_verification_summary: True, + pr_body_prefix_path: None, + pr_body_suffix_path: None, + comment_prefix_path: None, + comment_suffix_path: None, + ) +} + +pub type TaskHandoffState { + TaskHandoffState( + task_id: String, + delivered_pr_number: String, + last_delivered_commit_sha: String, + last_handoff_files: List(String), + last_verification_digest: String, + last_risks: List(String), + last_handoff_updated_at: String, + body_region_present: Bool, + managed_comment_present: Bool, + ) +} + +pub fn task_handoff_state( + handoff_states: List(TaskHandoffState), + task_id: String, +) -> Option(TaskHandoffState) { + case handoff_states |> list.find(fn(state) { state.task_id == task_id }) { + Ok(state) -> Some(state) + Error(_) -> None + } +} + +pub fn replace_task_handoff_state( + handoff_states: List(TaskHandoffState), + next_state: TaskHandoffState, +) -> List(TaskHandoffState) { + case handoff_states { + [] -> [next_state] + [state, ..rest] if state.task_id == next_state.task_id -> [ + next_state, + ..rest + ] + [state, ..rest] -> [state, ..replace_task_handoff_state(rest, next_state)] + } +} + /// Repo-local operator configuration for Night Shift. pub type Config { Config( @@ -479,6 +602,7 @@ pub type Config { pr_title_prefix: String, verification_commands: List(String), notifiers: List(NotifierName), + handoff: HandoffConfig, ) } @@ -496,6 +620,7 @@ pub fn default_config() -> Config { pr_title_prefix: "[night-shift]", verification_commands: [], notifiers: [ConsoleNotifier, ReportFileNotifier], + handoff: default_handoff_config(), ) } diff --git a/test/domain_pr_handoff_test.gleam b/test/domain_pr_handoff_test.gleam new file mode 100644 index 0000000..0d71098 --- /dev/null +++ b/test/domain_pr_handoff_test.gleam @@ -0,0 +1,176 @@ +import gleam/option.{None, Some} +import gleam/string +import night_shift/domain/pr_handoff +import night_shift/types +import night_shift_test_support + +pub fn render_body_region_includes_sections_and_snippets_test() { + let handoff = + types.HandoffConfig( + ..types.default_handoff_config(), + include_acceptance: True, + ) + let body = + pr_handoff.render_body_region( + handoff, + sample_run(), + sample_task(), + sample_execution_result(), + "$ gleam test", + pr_handoff.Snippets( + body_prefix: Some("Team prefix"), + body_suffix: Some("Team suffix"), + comment_prefix: None, + comment_suffix: None, + ), + ) + + assert string.contains(body, pr_handoff.body_start_marker) + assert string.contains(body, "Team prefix") + assert string.contains(body, "## Context") + assert string.contains(body, "## Scope") + assert string.contains( + body, + "Files touched: src/app.gleam, test/app_test.gleam", + ) + assert string.contains( + body, + "Acceptance: Add the app entrypoint, Cover the happy path", + ) + assert string.contains(body, "## Evidence") + assert string.contains(body, "Verification digest:") + assert string.contains(body, "## Provenance") + assert string.contains(body, "Team suffix") + assert string.contains(body, pr_handoff.body_end_marker) +} + +pub fn render_managed_comment_reports_delta_and_review_context_test() { + let comment = + pr_handoff.render_managed_comment( + sample_review_run(), + sample_task(), + sample_execution_result(), + "$ gleam test", + Some(types.TaskHandoffState( + task_id: "task-1", + delivered_pr_number: "15", + last_delivered_commit_sha: "abc123", + last_handoff_files: ["src/old.gleam"], + last_verification_digest: "old-digest", + last_risks: ["Old risk"], + last_handoff_updated_at: "2026-04-13T17:00:00Z", + body_region_present: True, + managed_comment_present: True, + )), + Some(pr_handoff.RepoStateStatus( + drift: "yes", + open_pr_count: 3, + actionable_pr_count: 1, + )), + pr_handoff.empty_snippets(), + ) + + assert string.contains(comment, "## Since Last Review") + assert string.contains( + comment, + "Added files: src/app.gleam, test/app_test.gleam", + ) + assert string.contains(comment, "Removed files: src/old.gleam") + assert string.contains(comment, "Verification changed: yes") + assert string.contains(comment, "## Review Feedback Status") + assert string.contains( + comment, + "#11: Review COMMENTED: Please make QA_NOTES.md the canonical doc.", + ) + assert string.contains(comment, "## Stack / Replacement Status") + assert string.contains(comment, "Repo-state drift: yes") + assert string.contains(comment, pr_handoff.comment_marker("task-1")) +} + +pub fn render_body_region_omits_unknown_pr_number_from_scope_test() { + let body = + pr_handoff.render_body_region( + types.default_handoff_config(), + sample_run(), + types.Task(..sample_task(), pr_number: ""), + sample_execution_result(), + "$ gleam test", + pr_handoff.empty_snippets(), + ) + + assert !string.contains(does: body, contain: "PR: (none)") + assert !string.contains(does: body, contain: "\n- PR:") +} + +fn sample_run() -> types.RunRecord { + types.RunRecord( + run_id: "run-123", + repo_root: "/repo", + run_path: "/repo/.night-shift/runs/run-123", + brief_path: "/repo/.night-shift/execution-brief.md", + state_path: "", + events_path: "", + report_path: "", + lock_path: "", + planning_agent: types.resolved_agent_from_provider(types.Codex), + execution_agent: types.resolved_agent_from_provider(types.Codex), + environment_name: "", + max_workers: 1, + notes_source: None, + planning_provenance: Some(types.NotesOnly(types.NotesFile("notes.md"))), + repo_state_snapshot: None, + decisions: [], + planning_dirty: False, + status: types.RunPending, + created_at: "", + updated_at: "", + tasks: [], + handoff_states: [], + ) +} + +fn sample_review_run() -> types.RunRecord { + types.RunRecord( + ..sample_run(), + planning_provenance: Some(types.ReviewsOnly), + repo_state_snapshot: Some( + night_shift_test_support.sample_repo_state_snapshot(), + ), + ) +} + +fn sample_task() -> types.Task { + types.Task( + id: "task-1", + title: "Task 1", + description: "Add the new app entrypoint.", + dependencies: [], + acceptance: ["Add the app entrypoint", "Cover the happy path"], + demo_plan: [], + decision_requests: [], + superseded_pr_numbers: [11, 12], + kind: types.ImplementationTask, + execution_mode: types.Serial, + state: types.Ready, + worktree_path: "", + branch_name: "night-shift/task-1", + pr_number: "15", + summary: "", + ) +} + +fn sample_execution_result() -> types.ExecutionResult { + types.ExecutionResult( + status: types.Completed, + summary: "Completed the app task.", + files_touched: ["src/app.gleam", "test/app_test.gleam"], + demo_evidence: ["Ran the app entrypoint"], + pr: types.PrPlan( + title: "Task 1", + summary: "Adds the app entrypoint.", + demo: ["Ran the app entrypoint"], + risks: ["Docs follow-up remains."], + ), + follow_up_tasks: [], + ) +} diff --git a/test/domain_pull_request_test.gleam b/test/domain_pull_request_test.gleam index 01dcf2f..3064f9a 100644 --- a/test/domain_pull_request_test.gleam +++ b/test/domain_pull_request_test.gleam @@ -125,6 +125,7 @@ fn sample_run() -> types.RunRecord { created_at: "", updated_at: "", tasks: [], + handoff_states: [], ) } diff --git a/test/domain_report_test.gleam b/test/domain_report_test.gleam index 59a12c2..79de6a3 100644 --- a/test/domain_report_test.gleam +++ b/test/domain_report_test.gleam @@ -114,6 +114,7 @@ fn review_run() -> types.RunRecord { "/tmp/repo/.night-shift/runs/review-run/worktrees/refresh-links", ), ], + handoff_states: [], ) } diff --git a/test/night_shift_cli_config_test.gleam b/test/night_shift_cli_config_test.gleam index e6cd2e9..eac44e3 100644 --- a/test/night_shift_cli_config_test.gleam +++ b/test/night_shift_cli_config_test.gleam @@ -207,3 +207,58 @@ pub fn parse_notifiers_and_verification_commands_test() { assert parsed.notifiers == [types.ConsoleNotifier, types.ReportFileNotifier] assert parsed.verification_commands == ["gleam test", "npm test"] } + +pub fn parse_handoff_config_test() { + let source = + "[handoff]\n" + <> "enabled = false\n" + <> "pr_body_mode = \"prepend\"\n" + <> "managed_comment = true\n" + <> "provenance = \"light\"\n" + <> "include_files_touched = false\n" + <> "include_acceptance = true\n" + <> "include_stack_context = false\n" + <> "include_verification_summary = false\n" + <> "pr_body_prefix_path = \".night-shift/pr-prefix.md\"\n" + <> "comment_suffix_path = \".night-shift/comment-suffix.md\"\n" + + let assert Ok(parsed) = config.parse(source) + + assert parsed.handoff.enabled == False + assert parsed.handoff.pr_body_mode == types.HandoffBodyPrepend + assert parsed.handoff.managed_comment == True + assert parsed.handoff.provenance == types.HandoffProvenanceLight + assert parsed.handoff.include_files_touched == False + assert parsed.handoff.include_acceptance == True + assert parsed.handoff.include_stack_context == False + assert parsed.handoff.include_verification_summary == False + assert parsed.handoff.pr_body_prefix_path == Some(".night-shift/pr-prefix.md") + assert parsed.handoff.comment_suffix_path + == Some(".night-shift/comment-suffix.md") +} + +pub fn render_handoff_config_round_trip_test() { + let configured = + types.Config( + ..types.default_config(), + handoff: types.HandoffConfig( + enabled: True, + pr_body_mode: types.HandoffBodyPrepend, + managed_comment: True, + provenance: types.HandoffProvenanceMinimal, + include_files_touched: False, + include_acceptance: True, + include_stack_context: False, + include_verification_summary: False, + pr_body_prefix_path: Some(".night-shift/pr-prefix.md"), + pr_body_suffix_path: None, + comment_prefix_path: Some(".night-shift/comment-prefix.md"), + comment_suffix_path: None, + ), + ) + + let rendered = config.render(configured) + let assert Ok(parsed) = config.parse(rendered) + + assert parsed.handoff == configured.handoff +} diff --git a/test/night_shift_execution_delivery_test.gleam b/test/night_shift_execution_delivery_test.gleam index 4747eb8..8eab36e 100644 --- a/test/night_shift_execution_delivery_test.gleam +++ b/test/night_shift_execution_delivery_test.gleam @@ -3,6 +3,7 @@ import gleam/list import gleam/option.{None, Some} import gleam/result import gleam/string +import night_shift/domain/pr_handoff import night_shift/github import night_shift/journal import night_shift/orchestrator @@ -58,6 +59,8 @@ pub fn github_open_or_update_pr_uses_create_output_when_listing_lags_test() { "main", "Demo PR", "Body", + None, + types.default_handoff_config(), run_path, filepath.join(run_path, "logs/gh.log"), ) @@ -74,6 +77,191 @@ pub fn github_open_or_update_pr_uses_create_output_when_listing_lags_test() { let _ = simplifile.delete(file_or_dir_at: base_dir) } +pub fn github_open_or_update_pr_preserves_manual_body_outside_handoff_region_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-gh-handoff-body-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let run_path = filepath.join(base_dir, "run") + let bin_dir = filepath.join(base_dir, "bin") + let fake_gh = filepath.join(bin_dir, "gh") + let old_path = system.get_env("PATH") + let old_gh_bin = system.get_env("NIGHT_SHIFT_GH_BIN") + let body_file = fake_gh <> ".body.txt" + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = + simplifile.create_directory_all(filepath.join(run_path, "logs")) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + support.seed_git_repo(repo_root, base_dir) + let _ = + shell.run( + "git checkout -b night-shift/demo-branch", + repo_root, + filepath.join(base_dir, "branch.log"), + ) + let assert Ok(_) = support.write_handoff_fake_gh(fake_gh) + let assert Ok(_) = + simplifile.write( + "Manual intro\n\n" + <> "\nold body\n\n\n" + <> "Manual footer\n", + to: body_file, + ) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_gh), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + + system.set_env("PATH", bin_dir <> ":" <> old_path) + system.set_env("NIGHT_SHIFT_GH_BIN", fake_gh) + + let result = + github.open_or_update_pr( + repo_root, + "night-shift/demo-branch", + "main", + "Demo PR", + "Legacy body", + Some( + "\nnew body\n", + ), + types.default_handoff_config(), + run_path, + filepath.join(run_path, "logs/gh.log"), + ) + + system.set_env("PATH", old_path) + support.restore_env("NIGHT_SHIFT_GH_BIN", old_gh_bin) + + let assert Ok(_) = result + let assert Ok(updated_body) = simplifile.read(body_file) + + assert string.contains(updated_body, "Manual intro") + assert string.contains(updated_body, "new body") + assert string.contains(updated_body, "Manual footer") + assert !string.contains(does: updated_body, contain: "old body") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn github_upsert_handoff_comment_updates_existing_comment_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-gh-handoff-comment-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let bin_dir = filepath.join(base_dir, "bin") + let fake_gh = filepath.join(bin_dir, "gh") + let old_path = system.get_env("PATH") + let old_gh_bin = system.get_env("NIGHT_SHIFT_GH_BIN") + let comment_file = fake_gh <> ".comment.txt" + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + let assert Ok(_) = support.write_handoff_fake_gh(fake_gh) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_gh), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + + system.set_env("PATH", bin_dir <> ":" <> old_path) + system.set_env("NIGHT_SHIFT_GH_BIN", fake_gh) + + let assert Ok(github.CommentCreated) = + github.upsert_handoff_comment( + repo_root, + 1, + "task-1", + "First body\n\n" <> pr_handoff.comment_marker("task-1"), + filepath.join(base_dir, "gh-create.log"), + ) + let assert Ok(github.CommentUpdated) = + github.upsert_handoff_comment( + repo_root, + 1, + "task-1", + "Second body\n\n" <> pr_handoff.comment_marker("task-1"), + filepath.join(base_dir, "gh-update.log"), + ) + + system.set_env("PATH", old_path) + support.restore_env("NIGHT_SHIFT_GH_BIN", old_gh_bin) + + let assert Ok(comment_body) = simplifile.read(comment_file) + assert string.contains(comment_body, "Second body") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + +pub fn github_upsert_handoff_comment_finds_existing_marker_across_pages_test() { + let unique = system.unique_id() + let base_dir = + support.absolute_path(filepath.join( + system.state_directory(), + "night-shift-gh-handoff-comment-pages-" <> unique, + )) + let repo_root = filepath.join(base_dir, "repo") + let bin_dir = filepath.join(base_dir, "bin") + let fake_gh = filepath.join(bin_dir, "gh") + let old_path = system.get_env("PATH") + let old_gh_bin = system.get_env("NIGHT_SHIFT_GH_BIN") + let comment_file = fake_gh <> ".comment.txt" + let pages_file = fake_gh <> ".comment-pages.json" + + let _ = simplifile.delete(file_or_dir_at: base_dir) + let assert Ok(_) = simplifile.create_directory_all(repo_root) + let assert Ok(_) = simplifile.create_directory_all(bin_dir) + let assert Ok(_) = support.write_handoff_fake_gh(fake_gh) + let assert Ok(_) = + simplifile.write( + "{\"1\":[" + <> support.repeat_text("{\"id\":1,\"body\":\"noise\"},", 99) + <> "{\"id\":100,\"body\":\"noise\"}]," + <> "\"2\":[{\"id\":101,\"body\":\"Old body\\n\\n" + <> pr_handoff.comment_marker("task-1") + <> "\"}]}", + to: pages_file, + ) + let _ = + shell.run( + "chmod +x " <> shell.quote(fake_gh), + base_dir, + filepath.join(base_dir, "chmod.log"), + ) + + system.set_env("PATH", bin_dir <> ":" <> old_path) + system.set_env("NIGHT_SHIFT_GH_BIN", fake_gh) + + let assert Ok(github.CommentUpdated) = + github.upsert_handoff_comment( + repo_root, + 1, + "task-1", + "Updated body\n\n" <> pr_handoff.comment_marker("task-1"), + filepath.join(base_dir, "gh-update.log"), + ) + + system.set_env("PATH", old_path) + support.restore_env("NIGHT_SHIFT_GH_BIN", old_gh_bin) + + let assert Ok(comment_body) = simplifile.read(comment_file) + assert string.contains(comment_body, "Updated body") + + let _ = simplifile.delete(file_or_dir_at: base_dir) +} + pub fn orchestrator_start_runs_fake_provider_test() { let unique = system.unique_id() let base_dir = diff --git a/test/night_shift_persistence_provider_test.gleam b/test/night_shift_persistence_provider_test.gleam index fecaf44..b875f37 100644 --- a/test/night_shift_persistence_provider_test.gleam +++ b/test/night_shift_persistence_provider_test.gleam @@ -381,6 +381,7 @@ pub fn plan_command_non_tty_streaming_stays_plain_test() { let old_path = system.get_env("PATH") let old_state_home = system.get_env("XDG_STATE_HOME") let old_stream_ui = system.get_env("NIGHT_SHIFT_STREAM_UI") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") let _ = simplifile.delete(file_or_dir_at: base_dir) let _ = @@ -401,6 +402,7 @@ pub fn plan_command_non_tty_streaming_stays_plain_test() { system.set_env("PATH", bin_dir <> ":" <> old_path) system.set_env("XDG_STATE_HOME", state_home) system.set_env("NIGHT_SHIFT_STREAM_UI", "auto") + system.unset_env("NIGHT_SHIFT_FAKE_PROVIDER") let result = support.run_local_cli_command( @@ -412,6 +414,7 @@ pub fn plan_command_non_tty_streaming_stays_plain_test() { system.set_env("PATH", old_path) support.restore_env("XDG_STATE_HOME", old_state_home) support.restore_env("NIGHT_SHIFT_STREAM_UI", old_stream_ui) + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) let assert Ok(output) = result assert !string.contains(does: output, contain: "\u{001b}") @@ -440,6 +443,7 @@ pub fn plan_command_streaming_handles_utf8_tool_output_truncation_test() { let old_path = system.get_env("PATH") let old_state_home = system.get_env("XDG_STATE_HOME") let old_stream_ui = system.get_env("NIGHT_SHIFT_STREAM_UI") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") let _ = simplifile.delete(file_or_dir_at: base_dir) let _ = @@ -460,6 +464,7 @@ pub fn plan_command_streaming_handles_utf8_tool_output_truncation_test() { system.set_env("PATH", bin_dir <> ":" <> old_path) system.set_env("XDG_STATE_HOME", state_home) system.set_env("NIGHT_SHIFT_STREAM_UI", "auto") + system.unset_env("NIGHT_SHIFT_FAKE_PROVIDER") let result = support.run_local_cli_command( @@ -471,6 +476,7 @@ pub fn plan_command_streaming_handles_utf8_tool_output_truncation_test() { system.set_env("PATH", old_path) support.restore_env("XDG_STATE_HOME", old_state_home) support.restore_env("NIGHT_SHIFT_STREAM_UI", old_stream_ui) + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) let assert Ok(output) = result assert string.contains(does: output, contain: "Planned run ") @@ -491,6 +497,7 @@ pub fn plan_command_tty_streaming_restores_alt_screen_test() { let notes_path = filepath.join(base_dir, "notes.md") let fake_codex = filepath.join(bin_dir, "codex") let state_home = filepath.join(base_dir, "state") + let old_fake_provider = system.get_env("NIGHT_SHIFT_FAKE_PROVIDER") let _ = simplifile.delete(file_or_dir_at: base_dir) let _ = @@ -516,6 +523,8 @@ pub fn plan_command_tty_streaming_restores_alt_screen_test() { <> shell.quote(bin_dir <> ":" <> system.get_env("PATH")) <> " XDG_STATE_HOME=" <> shell.quote(state_home) + <> " NIGHT_SHIFT_FAKE_PROVIDER=" + <> shell.quote("") <> " NIGHT_SHIFT_REPO_ROOT=" <> shell.quote(repo_root) <> " NIGHT_SHIFT_STREAM_UI=tui " @@ -534,6 +543,8 @@ pub fn plan_command_tty_streaming_restores_alt_screen_test() { assert string.contains(does: output.output, contain: "\u{001b}[?1049h") assert string.contains(does: output.output, contain: "\u{001b}[?1049l") + support.restore_env("NIGHT_SHIFT_FAKE_PROVIDER", old_fake_provider) + let _ = simplifile.delete(file_or_dir_at: base_dir) } diff --git a/test/night_shift_test_support.gleam b/test/night_shift_test_support.gleam index c5f5d3e..af70cdb 100644 --- a/test/night_shift_test_support.gleam +++ b/test/night_shift_test_support.gleam @@ -1119,3 +1119,98 @@ pub fn write_branch_sensitive_fake_gh( to: path, ) } + +pub fn write_handoff_fake_gh(path: String) -> Result(Nil, simplifile.FileError) { + simplifile.write( + "#!/bin/sh\n" + <> "BODY_FILE=\"$0.body.txt\"\n" + <> "COMMENT_FILE=\"$0.comment.txt\"\n" + <> "COMMENT_PAGES_FILE=\"$0.comment-pages.json\"\n" + <> "if [ ! -f \"$BODY_FILE\" ]; then\n" + <> " printf 'Legacy body\\n' > \"$BODY_FILE\"\n" + <> "fi\n" + <> "if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n" + <> " BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf 'night-shift/demo')\n" + <> " printf '[{\"number\":1,\"url\":\"https://example.test/pr/1\",\"headRefName\":\"%s\",\"title\":\"Night Shift PR\"}]\\n' \"$BRANCH\"\n" + <> " exit 0\n" + <> "fi\n" + <> "if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n" + <> " if [ \"$4\" = \"--json\" ] && [ \"$5\" = \"body\" ]; then\n" + <> " python3 - <<'PY' \"$BODY_FILE\"\n" + <> "import json, sys\n" + <> "body = open(sys.argv[1]).read()\n" + <> "print(json.dumps({'body': body}))\n" + <> "PY\n" + <> " exit 0\n" + <> " fi\n" + <> " printf '{\"number\":1,\"title\":\"Night Shift PR\",\"body\":\"Review body\",\"headRefName\":\"night-shift/demo\",\"baseRefName\":\"main\",\"url\":\"https://example.test/pr/1\",\"reviewDecision\":\"REVIEW_REQUIRED\",\"statusCheckRollup\":[],\"reviews\":[],\"comments\":[]}'\n" + <> " exit 0\n" + <> "fi\n" + <> "if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"edit\" ]; then\n" + <> " shift 2\n" + <> " while [ $# -gt 0 ]; do\n" + <> " case \"$1\" in\n" + <> " --body-file)\n" + <> " cp \"$2\" \"$BODY_FILE\"\n" + <> " shift 2\n" + <> " ;;\n" + <> " *)\n" + <> " shift\n" + <> " ;;\n" + <> " esac\n" + <> " done\n" + <> " exit 0\n" + <> "fi\n" + <> "if [ \"$1\" = \"api\" ]; then\n" + <> " PATH_ARG=$2\n" + <> " METHOD=GET\n" + <> " BODY=''\n" + <> " shift 2\n" + <> " while [ $# -gt 0 ]; do\n" + <> " case \"$1\" in\n" + <> " --method)\n" + <> " METHOD=$2\n" + <> " shift 2\n" + <> " ;;\n" + <> " --raw-field)\n" + <> " BODY=${2#body=}\n" + <> " shift 2\n" + <> " ;;\n" + <> " *)\n" + <> " shift\n" + <> " ;;\n" + <> " esac\n" + <> " done\n" + <> " case \"$METHOD:$PATH_ARG\" in\n" + <> " GET:repos/:owner/:repo/issues/1/comments*)\n" + <> " python3 - <<'PY' \"$COMMENT_FILE\" \"$COMMENT_PAGES_FILE\" \"$PATH_ARG\"\n" + <> "import json, os, sys, urllib.parse\n" + <> "comment_path, pages_path, path_arg = sys.argv[1:4]\n" + <> "parsed = urllib.parse.urlparse('https://example.test/' + path_arg)\n" + <> "page = urllib.parse.parse_qs(parsed.query).get('page', ['1'])[0]\n" + <> "if os.path.exists(pages_path):\n" + <> " pages = json.load(open(pages_path))\n" + <> " print(json.dumps(pages.get(page, [])))\n" + <> "elif not os.path.exists(comment_path):\n" + <> " print('[]')\n" + <> "else:\n" + <> " body = open(comment_path).read()\n" + <> " print(json.dumps([{'id': 1, 'body': body}]))\n" + <> "PY\n" + <> " exit 0\n" + <> " ;;\n" + <> " POST:repos/:owner/:repo/issues/1/comments)\n" + <> " printf '%s' \"$BODY\" > \"$COMMENT_FILE\"\n" + <> " exit 0\n" + <> " ;;\n" + <> " PATCH:repos/:owner/:repo/issues/comments/*)\n" + <> " printf '%s' \"$BODY\" > \"$COMMENT_FILE\"\n" + <> " exit 0\n" + <> " ;;\n" + <> " esac\n" + <> "fi\n" + <> "printf 'unsupported gh invocation: %s %s\\n' \"$1\" \"$2\" >&2\n" + <> "exit 1\n", + to: path, + ) +}