mirror of
https://github.com/khodges42/nightShift.git
synced 2026-06-14 18:18:36 +00:00
Improvement pass from llm
- tests/test_version.py: removed the brittle exact hotdog/topping/version assertion and kept the useful display contract. - nightshift/agents.py: normalized review next_stage / context_update values like None, null, and N/A to empty. - nightshift/git.py: added clearer non-git and Git safe-directory artifact messages with actionable guidance. - nightshift/cli.py: fixed run --all so completed dependencies remain in scope. - tests/test_agents.py, tests/test_git.py, tests/test_cli.py: added regression coverage. - docs/codex/20260521-next-improvements.md: marked the completed items.
This commit is contained in:
parent
3bb5bd4157
commit
8b07876552
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -25,7 +25,7 @@ share/python-wheels/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
MANIFEST
|
MANIFEST
|
||||||
|
docs/codex/
|
||||||
# PyInstaller
|
# PyInstaller
|
||||||
# Usually these files are written by a python script from a template
|
# Usually these files are written by a python script from a template
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
|
|
||||||
|
|
@ -511,11 +511,20 @@ def parse_review_output(output: str) -> tuple[StageStatus, str, str | None, str
|
||||||
return "fail", "Review output did not include a valid status.", None, None
|
return "fail", "Review output did not include a valid status.", None, None
|
||||||
|
|
||||||
reason = values.get("reason") or "Review returned no reason."
|
reason = values.get("reason") or "Review returned no reason."
|
||||||
next_stage = values.get("next_stage") or None
|
next_stage = _optional_review_value(values.get("next_stage"))
|
||||||
context_update = values.get("context_update") or None
|
context_update = _optional_review_value(values.get("context_update"))
|
||||||
return raw_status, reason, next_stage, context_update # type: ignore[return-value]
|
return raw_status, reason, next_stage, context_update # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_review_value(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized or normalized.lower() in {"none", "null", "n/a"}:
|
||||||
|
return None
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def format_agent_invocation(stage_id: str, invocation: AgentInvocation) -> str:
|
def format_agent_invocation(stage_id: str, invocation: AgentInvocation) -> str:
|
||||||
stdout = _coerce_output(invocation.stdout)
|
stdout = _coerce_output(invocation.stdout)
|
||||||
stderr = _coerce_output(invocation.stderr)
|
stderr = _coerce_output(invocation.stderr)
|
||||||
|
|
|
||||||
|
|
@ -176,13 +176,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
parser.error("run accepts either --all or --task, not both.")
|
parser.error("run accepts either --all or --task, not both.")
|
||||||
runner = PipelineRunner(config, logger=RunLogger(console=print))
|
runner = PipelineRunner(config, logger=RunLogger(console=print))
|
||||||
if args.all:
|
if args.all:
|
||||||
selected = [task for task in tasks if not task.completed]
|
|
||||||
with TerminalAnimation(
|
with TerminalAnimation(
|
||||||
args.animation,
|
args.animation,
|
||||||
message="NightShift running all tasks",
|
message="NightShift running all tasks",
|
||||||
enabled=not args.no_animation,
|
enabled=not args.no_animation,
|
||||||
):
|
):
|
||||||
result = runner.run_tasks(selected)
|
result = runner.run_tasks(tasks)
|
||||||
print(f"Status: {result.status}")
|
print(f"Status: {result.status}")
|
||||||
print(f"Tasks run: {len(result.task_results)}")
|
print(f"Tasks run: {len(result.task_results)}")
|
||||||
print(f"Completed: {result.completed_count}")
|
print(f"Completed: {result.completed_count}")
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,29 @@ def is_git_repository(project_root: Path) -> bool:
|
||||||
return state.available and state.stdout.strip() == "true"
|
return state.available and state.stdout.strip() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def git_failure_reason(result: GitCommandResult, project_root: Path) -> str:
|
||||||
|
details = (result.stderr or result.stdout or "unknown git error").strip()
|
||||||
|
lowered = details.lower()
|
||||||
|
if "dubious ownership" in lowered or "safe.directory" in lowered:
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
"Git repository ownership is not trusted by Git.",
|
||||||
|
"",
|
||||||
|
"Git refused to read this repository because it appears to be owned by a different user identity.",
|
||||||
|
"NightShift will not change global Git configuration automatically.",
|
||||||
|
"",
|
||||||
|
"To trust this repository, run:",
|
||||||
|
"",
|
||||||
|
"```powershell",
|
||||||
|
f"git config --global --add safe.directory {project_root.as_posix()}",
|
||||||
|
"```",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if "not a git repository" in lowered or "not a git work tree" in lowered:
|
||||||
|
return "Project root is not a git repository."
|
||||||
|
return details or "unknown git error"
|
||||||
|
|
||||||
|
|
||||||
def ensure_clean_worktree(project_root: Path, require_clean: bool) -> None:
|
def ensure_clean_worktree(project_root: Path, require_clean: bool) -> None:
|
||||||
if not require_clean:
|
if not require_clean:
|
||||||
return
|
return
|
||||||
|
|
@ -59,27 +82,31 @@ def ensure_clean_worktree(project_root: Path, require_clean: bool) -> None:
|
||||||
if not status.available:
|
if not status.available:
|
||||||
raise SafetyError(
|
raise SafetyError(
|
||||||
"Safety error: clean worktree is required, but git status could not be read: "
|
"Safety error: clean worktree is required, but git status could not be read: "
|
||||||
f"{status.stderr.strip() or 'unknown git error'}"
|
f"{git_failure_reason(status, project_root)}"
|
||||||
)
|
)
|
||||||
if status.stdout.strip():
|
if status.stdout.strip():
|
||||||
raise SafetyError("Safety error: clean worktree is required, but repository is dirty.")
|
raise SafetyError("Safety error: clean worktree is required, but repository is dirty.")
|
||||||
|
|
||||||
|
|
||||||
def write_git_artifacts(artifacts: ArtifactStore, task_id: str, when: str) -> Path:
|
def write_git_artifacts(artifacts: ArtifactStore, task_id: str, when: str) -> Path:
|
||||||
|
state = get_git_repository_state(artifacts.project_root)
|
||||||
|
if not state.available:
|
||||||
|
content = format_git_unavailable_status(state, when, artifacts.project_root)
|
||||||
|
return artifacts.write_stage_output(task_id, f"git-status-{when}.txt", content)
|
||||||
status = get_git_status(artifacts.project_root)
|
status = get_git_status(artifacts.project_root)
|
||||||
content = format_git_status(status, when)
|
content = format_git_status(status, when)
|
||||||
return artifacts.write_stage_output(task_id, f"git-status-{when}.txt", content)
|
return artifacts.write_stage_output(task_id, f"git-status-{when}.txt", content)
|
||||||
|
|
||||||
|
|
||||||
def write_diff_artifact(artifacts: ArtifactStore, task_id: str) -> Path:
|
def write_diff_artifact(artifacts: ArtifactStore, task_id: str) -> Path:
|
||||||
if not is_git_repository(artifacts.project_root):
|
state = get_git_repository_state(artifacts.project_root)
|
||||||
content = "Git diff unavailable.\n\nReason: project root is not a git work tree.\n"
|
if not state.available or state.stdout.strip() != "true":
|
||||||
|
content = f"Git diff unavailable.\n\nReason: {git_failure_reason(state, artifacts.project_root)}\n"
|
||||||
return artifacts.write_stage_output(task_id, "diff.patch", content)
|
return artifacts.write_stage_output(task_id, "diff.patch", content)
|
||||||
|
|
||||||
diff = run_git(artifacts.project_root, ["diff", "--binary"], timeout_seconds=30)
|
diff = run_git(artifacts.project_root, ["diff", "--binary"], timeout_seconds=30)
|
||||||
if not diff.available:
|
if not diff.available:
|
||||||
details = (diff.stderr or "unknown git error").strip()
|
content = f"Git diff unavailable.\n\nReason: {git_failure_reason(diff, artifacts.project_root)}\n"
|
||||||
content = f"Git diff unavailable.\n\nReason: {details}\n"
|
|
||||||
elif diff.stdout:
|
elif diff.stdout:
|
||||||
content = diff.stdout
|
content = diff.stdout
|
||||||
else:
|
else:
|
||||||
|
|
@ -108,3 +135,22 @@ def format_git_status(status: GitCommandResult, when: str) -> str:
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_git_unavailable_status(status: GitCommandResult, when: str, project_root: Path) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
f"# Git Status {when}",
|
||||||
|
"",
|
||||||
|
"Git repository: false",
|
||||||
|
f"Available: {str(status.available).lower()}",
|
||||||
|
f"Exit code: {status.exit_code}",
|
||||||
|
"",
|
||||||
|
"## Explanation",
|
||||||
|
"",
|
||||||
|
git_failure_reason(status, project_root),
|
||||||
|
"",
|
||||||
|
"Git metadata and diff artifacts are unavailable for this run.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,18 @@ class AgentExecutorTests(unittest.TestCase):
|
||||||
self.assertEqual(next_stage, "implement")
|
self.assertEqual(next_stage, "implement")
|
||||||
self.assertEqual(context_update, "Fix tests")
|
self.assertEqual(context_update, "Fix tests")
|
||||||
|
|
||||||
|
def test_review_output_parser_treats_empty_sentinel_next_stage_as_missing(self) -> None:
|
||||||
|
for next_stage_value in ("", "None", "null", "N/A"):
|
||||||
|
with self.subTest(next_stage=next_stage_value):
|
||||||
|
status, reason, next_stage, context_update = parse_review_output(
|
||||||
|
f"status: pass\nreason: ok\nnext_stage: {next_stage_value}\ncontext_update: None\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(status, "pass")
|
||||||
|
self.assertEqual(reason, "ok")
|
||||||
|
self.assertIsNone(next_stage)
|
||||||
|
self.assertIsNone(context_update)
|
||||||
|
|
||||||
def test_ollama_agent_invocation_uses_model_without_real_ollama(self) -> None:
|
def test_ollama_agent_invocation_uses_model_without_real_ollama(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|
|
||||||
92
tests/test_cli.py
Normal file
92
tests/test_cli.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class CliTests(unittest.TestCase):
|
||||||
|
def test_run_all_keeps_completed_dependencies_in_scope(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "dummy.md").write_text("Unused.", encoding="utf-8")
|
||||||
|
(root / "nightshift.yaml").write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"project:",
|
||||||
|
" name: cli-test",
|
||||||
|
" root: .",
|
||||||
|
" task_file: tasks.md",
|
||||||
|
" artifact_dir: .nightshift",
|
||||||
|
"",
|
||||||
|
"safety:",
|
||||||
|
" require_clean_worktree: false",
|
||||||
|
" scoped_paths:",
|
||||||
|
" - .",
|
||||||
|
" allowed_commands:",
|
||||||
|
" - python -c \"print('ok')\"",
|
||||||
|
" forbidden_commands:",
|
||||||
|
" - rm -rf",
|
||||||
|
"",
|
||||||
|
"agents:",
|
||||||
|
" dummy:",
|
||||||
|
" backend: command",
|
||||||
|
" command: python -c \"print('unused')\"",
|
||||||
|
" system_prompt: dummy.md",
|
||||||
|
"",
|
||||||
|
"pipeline:",
|
||||||
|
" max_task_retries: 0",
|
||||||
|
" stages:",
|
||||||
|
" - id: test",
|
||||||
|
" type: command",
|
||||||
|
" commands:",
|
||||||
|
" - python -c \"print('ok')\"",
|
||||||
|
" output: test-output.txt",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(root / "tasks.md").write_text(
|
||||||
|
"""# Tasks
|
||||||
|
|
||||||
|
- [x] TASK-001: Already complete
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- done
|
||||||
|
|
||||||
|
- [ ] TASK-002: Depends on completed task
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
- TASK-001
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
- runs
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"nightshift.cli",
|
||||||
|
"run",
|
||||||
|
"--config",
|
||||||
|
str(root / "nightshift.yaml"),
|
||||||
|
"--all",
|
||||||
|
"--no-animation",
|
||||||
|
],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
|
||||||
|
self.assertIn("Completed: 1", completed.stdout)
|
||||||
|
self.assertIn("- [x] TASK-002", (root / "tasks.md").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -6,7 +6,14 @@ import unittest
|
||||||
|
|
||||||
from nightshift.artifacts import ArtifactStore
|
from nightshift.artifacts import ArtifactStore
|
||||||
from nightshift.errors import SafetyError
|
from nightshift.errors import SafetyError
|
||||||
from nightshift.git import ensure_clean_worktree, write_diff_artifact, write_git_artifacts
|
from nightshift.git import (
|
||||||
|
GitCommandResult,
|
||||||
|
ensure_clean_worktree,
|
||||||
|
format_git_unavailable_status,
|
||||||
|
git_failure_reason,
|
||||||
|
write_diff_artifact,
|
||||||
|
write_git_artifacts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def git_available() -> bool:
|
def git_available() -> bool:
|
||||||
|
|
@ -46,9 +53,41 @@ class GitSafetyTests(unittest.TestCase):
|
||||||
diff_path = write_diff_artifact(artifacts, "TASK-001")
|
diff_path = write_diff_artifact(artifacts, "TASK-001")
|
||||||
|
|
||||||
content = diff_path.read_text(encoding="utf-8")
|
content = diff_path.read_text(encoding="utf-8")
|
||||||
self.assertIn("project root is not a git work tree", content)
|
self.assertIn("Project root is not a git repository", content)
|
||||||
self.assertNotIn("usage: git diff", content)
|
self.assertNotIn("usage: git diff", content)
|
||||||
|
|
||||||
|
def test_git_status_artifact_is_readable_outside_git_repo(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
artifacts = ArtifactStore(root, ".nightshift", run_id="test-run")
|
||||||
|
|
||||||
|
status_path = write_git_artifacts(artifacts, "TASK-001", "before")
|
||||||
|
|
||||||
|
content = status_path.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("Git repository: false", content)
|
||||||
|
self.assertIn("Project root is not a git repository", content)
|
||||||
|
self.assertNotIn("fatal:", content)
|
||||||
|
|
||||||
|
def test_safe_directory_failure_gets_actionable_guidance(self) -> None:
|
||||||
|
root = Path("C:/repo/project")
|
||||||
|
result = GitCommandResult(
|
||||||
|
available=False,
|
||||||
|
exit_code=128,
|
||||||
|
stdout="",
|
||||||
|
stderr=(
|
||||||
|
"fatal: detected dubious ownership in repository at 'C:/repo/project'\n"
|
||||||
|
"To add an exception for this directory, call:\n"
|
||||||
|
"git config --global --add safe.directory C:/repo/project\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
reason = git_failure_reason(result, root)
|
||||||
|
formatted = format_git_unavailable_status(result, "before", root)
|
||||||
|
|
||||||
|
self.assertIn("ownership is not trusted", reason)
|
||||||
|
self.assertIn("git config --global --add safe.directory C:/repo/project", reason)
|
||||||
|
self.assertIn("NightShift will not change global Git configuration", formatted)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ from nightshift.version import (
|
||||||
|
|
||||||
class VersionTests(unittest.TestCase):
|
class VersionTests(unittest.TestCase):
|
||||||
def test_display_version_includes_channel_hotdog_and_topping(self) -> None:
|
def test_display_version_includes_channel_hotdog_and_topping(self) -> None:
|
||||||
self.assertEqual(display_version(), "0.2.4-alpha-bratwurst-relish")
|
self.assertTrue(display_version().startswith(f"{PACKAGE_VERSION}-alpha-"))
|
||||||
self.assertEqual(PACKAGE_VERSION, "0.2.4")
|
|
||||||
self.assertIn(hotdog_version, HOTDOG_VERSIONS)
|
self.assertIn(hotdog_version, HOTDOG_VERSIONS)
|
||||||
self.assertIn(topping_version, TOPPING_VERSIONS)
|
self.assertIn(topping_version, TOPPING_VERSIONS)
|
||||||
|
self.assertIn(hotdog_version, display_version())
|
||||||
|
self.assertIn(topping_version, display_version())
|
||||||
|
|
||||||
def test_banner_uses_central_display_version(self) -> None:
|
def test_banner_uses_central_display_version(self) -> None:
|
||||||
self.assertIn(f"VERSION: {display_version()}", format_banner())
|
self.assertIn(f"VERSION: {display_version()}", format_banner())
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user