bugpilot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bugpilot/__init__.py +5 -0
- bugpilot/__main__.py +5 -0
- bugpilot/cli.py +2615 -0
- bugpilot/cli_json.py +173 -0
- bugpilot/core/__init__.py +1 -0
- bugpilot/core/agent_runner.py +138 -0
- bugpilot/core/artifact_io.py +40 -0
- bugpilot/core/artifacts.py +47 -0
- bugpilot/core/attachments.py +247 -0
- bugpilot/core/branch_policy.py +223 -0
- bugpilot/core/cleanup.py +58 -0
- bugpilot/core/code_files.py +99 -0
- bugpilot/core/config.py +242 -0
- bugpilot/core/context.py +436 -0
- bugpilot/core/copilot.py +47 -0
- bugpilot/core/delivery_instructions.py +106 -0
- bugpilot/core/doctor.py +66 -0
- bugpilot/core/email_notify.py +316 -0
- bugpilot/core/errors.py +105 -0
- bugpilot/core/executables.py +87 -0
- bugpilot/core/fix_mode_state.py +212 -0
- bugpilot/core/fix_mode_store.py +600 -0
- bugpilot/core/fix_modes.py +412 -0
- bugpilot/core/fix_report.py +124 -0
- bugpilot/core/git_history.py +1579 -0
- bugpilot/core/git_ops.py +254 -0
- bugpilot/core/handoff.py +127 -0
- bugpilot/core/identity.py +112 -0
- bugpilot/core/input_adapters.py +97 -0
- bugpilot/core/instructions.py +337 -0
- bugpilot/core/issue.py +487 -0
- bugpilot/core/jira.py +886 -0
- bugpilot/core/jira_adf.py +167 -0
- bugpilot/core/jira_parse.py +442 -0
- bugpilot/core/keywords.py +386 -0
- bugpilot/core/logging_utils.py +28 -0
- bugpilot/core/memory.py +151 -0
- bugpilot/core/models.py +322 -0
- bugpilot/core/project_settings.py +283 -0
- bugpilot/core/prompts.py +567 -0
- bugpilot/core/repository_profile.py +739 -0
- bugpilot/core/retrieval.py +485 -0
- bugpilot/core/review_changes.py +155 -0
- bugpilot/core/review_report.py +171 -0
- bugpilot/core/run.py +171 -0
- bugpilot/core/safe_paths.py +173 -0
- bugpilot/core/search.py +765 -0
- bugpilot/core/search_terms.py +310 -0
- bugpilot/core/setup.py +212 -0
- bugpilot/core/user_config.py +211 -0
- bugpilot/core/verification_report.py +313 -0
- bugpilot/core/workflow.py +2385 -0
- bugpilot/mcp_server.py +651 -0
- bugpilot-0.1.0.dist-info/METADATA +270 -0
- bugpilot-0.1.0.dist-info/RECORD +59 -0
- bugpilot-0.1.0.dist-info/WHEEL +5 -0
- bugpilot-0.1.0.dist-info/entry_points.txt +3 -0
- bugpilot-0.1.0.dist-info/licenses/LICENSE +122 -0
- bugpilot-0.1.0.dist-info/top_level.txt +1 -0
bugpilot/cli_json.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The ``--json`` envelope: bugpilot's machine-facing output contract.
|
|
2
|
+
|
|
3
|
+
Lives in the CLI layer, not in ``core``, because rendering is an adapter's job
|
|
4
|
+
(design invariant 3). The VS Code extension consumes this; the MCP server gets
|
|
5
|
+
the same data as Python objects and never goes through here.
|
|
6
|
+
|
|
7
|
+
Contract, from ``docs/adapter_design.md`` section 5.1:
|
|
8
|
+
|
|
9
|
+
- Exactly one JSON object on stdout, whether the command succeeded or failed.
|
|
10
|
+
- ``schema_version`` from the first release, so the extension can refuse a
|
|
11
|
+
version it does not understand instead of misreading fields.
|
|
12
|
+
- On failure the human-readable message still goes to stderr and the exit code
|
|
13
|
+
is still non-zero. The two channels are additive, not alternatives.
|
|
14
|
+
- Consumers branch on ``error.code`` and never parse ``error.message``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
SCHEMA_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def success(command: str, **fields: object) -> dict[str, object]:
|
|
26
|
+
"""Build a success envelope. ``fields`` are merged in as-is."""
|
|
27
|
+
payload: dict[str, object] = {
|
|
28
|
+
"schema_version": SCHEMA_VERSION,
|
|
29
|
+
"ok": True,
|
|
30
|
+
"command": command,
|
|
31
|
+
}
|
|
32
|
+
payload.update(fields)
|
|
33
|
+
payload.setdefault("warnings", [])
|
|
34
|
+
return payload
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def failure(command: str, code: str, message: str, **fields: object) -> dict[str, object]:
|
|
38
|
+
"""Build a failure envelope carrying a stable code and a human message."""
|
|
39
|
+
payload: dict[str, object] = {
|
|
40
|
+
"schema_version": SCHEMA_VERSION,
|
|
41
|
+
"ok": False,
|
|
42
|
+
"command": command,
|
|
43
|
+
"error": {"code": code, "message": message},
|
|
44
|
+
}
|
|
45
|
+
payload.update(fields)
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _utf8_stdout() -> None:
|
|
50
|
+
"""Make stdout UTF-8 before JSON is written to it.
|
|
51
|
+
|
|
52
|
+
JSON between programs is UTF-8 (RFC 8259), and the VS Code extension
|
|
53
|
+
decodes it as UTF-8. Python on Windows writes a pipe in the ANSI code page
|
|
54
|
+
(cp1252) unless PYTHONIOENCODING or UTF-8 mode says otherwise: a "→" in an
|
|
55
|
+
instructions file or a Chinese bug title failed the command with "'charmap'
|
|
56
|
+
codec can't encode", and an "é" arrived as a byte the extension could not
|
|
57
|
+
decode. A console is unaffected — Python
|
|
58
|
+
writes it as UTF-16 — and a stream that is UTF-8 already is left alone.
|
|
59
|
+
"""
|
|
60
|
+
stream = sys.stdout
|
|
61
|
+
encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "").replace("_", "")
|
|
62
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
63
|
+
if encoding == "utf8" or reconfigure is None:
|
|
64
|
+
return
|
|
65
|
+
try:
|
|
66
|
+
reconfigure(encoding="utf-8")
|
|
67
|
+
except (ValueError, OSError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def emit(payload: dict[str, object]) -> None:
|
|
72
|
+
"""Write one envelope to stdout.
|
|
73
|
+
|
|
74
|
+
``ensure_ascii=False`` so a Chinese bug title stays readable in a terminal
|
|
75
|
+
and in an editor's output pane; the stream is made UTF-8 first.
|
|
76
|
+
"""
|
|
77
|
+
_utf8_stdout()
|
|
78
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def emit_failure(command: str, code: str, message: str, **fields: object) -> None:
|
|
82
|
+
"""Write a failure envelope to stdout and the same message to stderr.
|
|
83
|
+
|
|
84
|
+
Callers still return a non-zero exit code: a consumer that ignores JSON and
|
|
85
|
+
only checks the exit status must see the failure too.
|
|
86
|
+
"""
|
|
87
|
+
emit(failure(command, code, message, **fields))
|
|
88
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class JsonLinesEmitter:
|
|
92
|
+
"""Streams one JSON object per line while a run is in progress.
|
|
93
|
+
|
|
94
|
+
The two output channels have different jobs, per design section 5.1:
|
|
95
|
+
JSONL is live events for as long as the process runs, and
|
|
96
|
+
``run.json`` is the state that survives it. A consumer follows
|
|
97
|
+
the stream for progress and re-reads the file after a restart.
|
|
98
|
+
|
|
99
|
+
``run_investigation``'s progress callback fires *before* each step, so
|
|
100
|
+
completion is inferred: the next ``step_started`` closes the previous step,
|
|
101
|
+
and :meth:`finish` closes the last one. A step that raises is never closed,
|
|
102
|
+
which is what distinguishes it from one that finished.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, work_item_id: str, source: str) -> None:
|
|
106
|
+
self._open_step: str | None = None
|
|
107
|
+
self._emit({"type": "started", "work_item_id": work_item_id, "source": source})
|
|
108
|
+
|
|
109
|
+
def _emit(self, event: dict[str, object]) -> None:
|
|
110
|
+
payload: dict[str, object] = {"schema_version": SCHEMA_VERSION}
|
|
111
|
+
payload.update(event)
|
|
112
|
+
_utf8_stdout()
|
|
113
|
+
print(json.dumps(payload, ensure_ascii=False), flush=True)
|
|
114
|
+
|
|
115
|
+
def skipped(self, steps: list[str]) -> None:
|
|
116
|
+
for step in steps:
|
|
117
|
+
self._emit({"type": "step_skipped", "step": step, "reason": "plan"})
|
|
118
|
+
|
|
119
|
+
def progress(self, event: str) -> None:
|
|
120
|
+
"""Callback for ``run_investigation(progress=...)``.
|
|
121
|
+
|
|
122
|
+
Non-step events (the clean phase) are reported under their own type so a
|
|
123
|
+
consumer can show them without mistaking them for pipeline steps.
|
|
124
|
+
"""
|
|
125
|
+
if event.startswith("clean_"):
|
|
126
|
+
self._emit({"type": "phase", "phase": event})
|
|
127
|
+
return
|
|
128
|
+
self._close_open_step()
|
|
129
|
+
self._open_step = event
|
|
130
|
+
self._emit({"type": "step_started", "step": event})
|
|
131
|
+
|
|
132
|
+
def _close_open_step(self) -> None:
|
|
133
|
+
if self._open_step is not None:
|
|
134
|
+
self._emit({"type": "step_completed", "step": self._open_step})
|
|
135
|
+
self._open_step = None
|
|
136
|
+
|
|
137
|
+
def finish(self, generated_files: list[str], warnings: list[str] | None = None) -> None:
|
|
138
|
+
self._close_open_step()
|
|
139
|
+
for path in generated_files:
|
|
140
|
+
self._emit({"type": "artifact", "path": path})
|
|
141
|
+
# Carried on the terminal event rather than as a new event type: a
|
|
142
|
+
# consumer that has not been taught about `warnings` ignores the extra
|
|
143
|
+
# key, where an unknown event type is a shape it has to decide about.
|
|
144
|
+
completed: dict[str, object] = {"type": "completed", "ok": True}
|
|
145
|
+
if warnings:
|
|
146
|
+
completed["warnings"] = list(warnings)
|
|
147
|
+
self._emit(completed)
|
|
148
|
+
|
|
149
|
+
def fail(self, code: str, message: str) -> None:
|
|
150
|
+
"""End the stream without closing the step that raised."""
|
|
151
|
+
self._emit({"type": "completed", "ok": False, "error": {"code": code, "message": message}})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def emit_stream_failure(code: str, message: str) -> None:
|
|
155
|
+
"""Close a --json-lines stream that failed before or outside a run.
|
|
156
|
+
|
|
157
|
+
A consumer waits for a terminal event; without one an aborted run is
|
|
158
|
+
indistinguishable from a process that is still working.
|
|
159
|
+
"""
|
|
160
|
+
_utf8_stdout()
|
|
161
|
+
print(
|
|
162
|
+
json.dumps(
|
|
163
|
+
{
|
|
164
|
+
"schema_version": SCHEMA_VERSION,
|
|
165
|
+
"type": "completed",
|
|
166
|
+
"ok": False,
|
|
167
|
+
"error": {"code": code, "message": message},
|
|
168
|
+
},
|
|
169
|
+
ensure_ascii=False,
|
|
170
|
+
),
|
|
171
|
+
flush=True,
|
|
172
|
+
)
|
|
173
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core modules for bugpilot."""
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Deprecated: launching a coding agent from bugpilot.
|
|
2
|
+
|
|
3
|
+
**This module is deprecated as of V1 and will be removed in a future
|
|
4
|
+
release** (design section 9, phase 7). It still works, and nothing that
|
|
5
|
+
depends on it has been changed.
|
|
6
|
+
|
|
7
|
+
It exists because bugpilot originally launched Claude in a terminal after
|
|
8
|
+
preparing a package. That is no longer the default: `bugpilot bug` prepares and
|
|
9
|
+
stops, and this module runs only when a command line asks for it with
|
|
10
|
+
``--launch-agent claude`` or ``--launch-agent copilot``. The three-entry architecture made that the odd one out:
|
|
11
|
+
the MCP server is *called by* an agent, and the VS Code extension hands the
|
|
12
|
+
package over on a human's click. In both, deciding to involve a model is a
|
|
13
|
+
separate act from preparing the context — which is requirement R5, and the
|
|
14
|
+
reason this path is going away rather than being extended.
|
|
15
|
+
|
|
16
|
+
What to use instead:
|
|
17
|
+
|
|
18
|
+
- ``bugpilot bug <ID>`` (prepare-only is the default), then hand ``.ai/<ID>/task.md`` to
|
|
19
|
+
whatever agent you use. The extension's "Copy handoff prompt" does exactly
|
|
20
|
+
this, and the Claude Code skill in ``skills/`` tells the agent to do it
|
|
21
|
+
itself.
|
|
22
|
+
- The handoff wording now lives in :mod:`bugpilot.core.handoff`, shared with
|
|
23
|
+
the MCP prompt and the skill.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import shlex
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from .config import AppConfig, load_config
|
|
36
|
+
from .executables import child_environment, find_executable
|
|
37
|
+
from .handoff import handoff_prompt, retry_handoff_prompt
|
|
38
|
+
|
|
39
|
+
# Kept as format strings because callers use `.format(...)` on them, but the
|
|
40
|
+
# wording now comes from `handoff.py` — the one place that says what an agent is
|
|
41
|
+
# told to do with a prepared package (design 5.5: a skill and an MCP prompt
|
|
42
|
+
# carrying the same instructions in two files will drift).
|
|
43
|
+
HANDOFF_PROMPT = handoff_prompt("{issue_key}")
|
|
44
|
+
RETRY_HANDOFF_PROMPT = retry_handoff_prompt("{prompt_file}")
|
|
45
|
+
|
|
46
|
+
# What `bugpilot bug --launch-agent` accepts. The flag is the only way the CLI
|
|
47
|
+
# starts an agent: there is no implicit default one.
|
|
48
|
+
LAUNCHABLE_AGENTS: tuple[str, ...] = ("claude", "copilot")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AgentRunResult:
|
|
53
|
+
agent: str
|
|
54
|
+
ran: bool
|
|
55
|
+
command: list[str]
|
|
56
|
+
returncode: int | None = None
|
|
57
|
+
skipped_reason: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_agent_command(agent: str, issue_key: str, config: AppConfig, prompt: str | None = None) -> list[str]:
|
|
61
|
+
prompt = prompt or HANDOFF_PROMPT.format(issue_key=issue_key)
|
|
62
|
+
if agent == "claude":
|
|
63
|
+
base = [config.claude_command, *shlex.split(config.claude_args)]
|
|
64
|
+
elif agent == "copilot":
|
|
65
|
+
base = [config.copilot_command, *shlex.split(config.copilot_args)]
|
|
66
|
+
else:
|
|
67
|
+
raise ValueError(f"Unknown agent: {agent}")
|
|
68
|
+
return [*base, prompt]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_agent(
|
|
72
|
+
repo_root: Path,
|
|
73
|
+
issue_key: str,
|
|
74
|
+
agent: str,
|
|
75
|
+
config: AppConfig | None = None,
|
|
76
|
+
prompt: str | None = None,
|
|
77
|
+
) -> AgentRunResult:
|
|
78
|
+
_warn_deprecated(agent)
|
|
79
|
+
config = config if config is not None else load_config(repo_root)
|
|
80
|
+
command = build_agent_command(agent, issue_key, config, prompt)
|
|
81
|
+
launch = _resolve_launch_command(command)
|
|
82
|
+
if launch is None:
|
|
83
|
+
return AgentRunResult(
|
|
84
|
+
agent=agent,
|
|
85
|
+
ran=False,
|
|
86
|
+
command=command,
|
|
87
|
+
skipped_reason=f"{command[0]} was not found on PATH.",
|
|
88
|
+
)
|
|
89
|
+
# Inherit the terminal so the agent runs interactively and the developer can
|
|
90
|
+
# watch it work. Run in the target repo root (the current working directory),
|
|
91
|
+
# with the current directory out of the agent's own program lookups.
|
|
92
|
+
completed = subprocess.run(launch, cwd=repo_root, env=child_environment())
|
|
93
|
+
return AgentRunResult(
|
|
94
|
+
agent=agent,
|
|
95
|
+
ran=True,
|
|
96
|
+
command=command,
|
|
97
|
+
returncode=completed.returncode,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _warn_deprecated(agent: str) -> None:
|
|
102
|
+
"""Tell the developer, on stderr, that this path is going away.
|
|
103
|
+
|
|
104
|
+
A ``DeprecationWarning`` would be the library-shaped answer and invisible
|
|
105
|
+
here: Python hides those by default, and the audience is a person watching a
|
|
106
|
+
terminal. stderr also keeps requirement R1 intact — the human-readable
|
|
107
|
+
stdout of every existing command is untouched.
|
|
108
|
+
"""
|
|
109
|
+
print(
|
|
110
|
+
f"NOTE: launching {agent} from bugpilot is deprecated and will be removed "
|
|
111
|
+
"in a future release.",
|
|
112
|
+
file=sys.stderr,
|
|
113
|
+
)
|
|
114
|
+
print(
|
|
115
|
+
" Prefer leaving out --launch-agent (prepare-only is the default) and handing "
|
|
116
|
+
"task.md over yourself (the VS Code extension's Copy handoff prompt does this).",
|
|
117
|
+
file=sys.stderr,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_launch_command(command: list[str]) -> list[str] | None:
|
|
122
|
+
"""Resolve the executable to a runnable form for the current OS.
|
|
123
|
+
|
|
124
|
+
`find_executable` honors PATHEXT (so it finds `claude.cmd` on Windows) and,
|
|
125
|
+
unlike shutil.which, never looks in the current directory — the repository.
|
|
126
|
+
CreateProcess cannot launch a .cmd/.bat directly, so a
|
|
127
|
+
Windows batch shim runs through the system's own cmd.exe, by absolute path.
|
|
128
|
+
"""
|
|
129
|
+
resolved = find_executable(command[0])
|
|
130
|
+
if resolved is None:
|
|
131
|
+
return None
|
|
132
|
+
rest = command[1:]
|
|
133
|
+
if sys.platform == "win32" and resolved.lower().endswith((".cmd", ".bat")):
|
|
134
|
+
shell = find_executable(os.environ.get("ComSpec") or "") or find_executable("cmd")
|
|
135
|
+
if shell is None:
|
|
136
|
+
return None
|
|
137
|
+
return [shell, "/c", resolved, *rest]
|
|
138
|
+
return [resolved, *rest]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Writes into `.ai/<work item>/` that another process may be reading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .safe_paths import refuse_link
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
13
|
+
"""Write via a temp file and one rename, so a reader never sees half a file.
|
|
14
|
+
|
|
15
|
+
The retry is for Windows: ``os.replace`` onto a path another process has open
|
|
16
|
+
fails with ``PermissionError`` there, and the readers of these files are
|
|
17
|
+
exactly that — an extension restoring its checklist, an MCP ``get_status``
|
|
18
|
+
call. Those reads last microseconds, so a couple of retries clear it.
|
|
19
|
+
|
|
20
|
+
If it still fails, write in place rather than raising: a torn read costs one
|
|
21
|
+
stale checklist, while a raised exception costs the whole step.
|
|
22
|
+
|
|
23
|
+
A target that is itself a symbolic link or junction is refused before
|
|
24
|
+
anything is written: the fallback below would follow it.
|
|
25
|
+
"""
|
|
26
|
+
refuse_link(path)
|
|
27
|
+
temp = path.with_name(path.name + f".tmp{os.getpid()}")
|
|
28
|
+
temp.write_text(text, encoding="utf-8")
|
|
29
|
+
for attempt in range(4):
|
|
30
|
+
try:
|
|
31
|
+
os.replace(temp, path)
|
|
32
|
+
return
|
|
33
|
+
except PermissionError:
|
|
34
|
+
if attempt == 3:
|
|
35
|
+
break
|
|
36
|
+
time.sleep(0.05)
|
|
37
|
+
try:
|
|
38
|
+
path.write_text(text, encoding="utf-8")
|
|
39
|
+
finally:
|
|
40
|
+
temp.unlink(missing_ok=True)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The standard BugPilot artifact contract: what a work item directory holds.
|
|
2
|
+
|
|
3
|
+
One place for the names, because every reader and writer has to agree on them
|
|
4
|
+
and a filename spelled out in five modules is five chances to drift.
|
|
5
|
+
|
|
6
|
+
issue.json the normalized bug and the guidance a run was given
|
|
7
|
+
retrieval.json search terms, relevant files and their matched lines
|
|
8
|
+
context.md the context an agent reads first
|
|
9
|
+
task.md the task package handed to the coding agent
|
|
10
|
+
run.json runtime state: step lifecycle, agent state
|
|
11
|
+
fix_report.md optional, only once a fix produced something to report
|
|
12
|
+
review_report.md optional, only once somebody recorded a review's result
|
|
13
|
+
verification_report.md
|
|
14
|
+
optional, only once somebody recorded verification evidence
|
|
15
|
+
|
|
16
|
+
Every JSON artifact carries ``schema_version``. There is deliberately no
|
|
17
|
+
migration and no reader for any earlier layout: BugPilot is pre-release, and a
|
|
18
|
+
work item prepared under an older layout is re-prepared rather than upgraded.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
ARTIFACT_SCHEMA_VERSION = 1
|
|
24
|
+
|
|
25
|
+
ISSUE_ARTIFACT = "issue.json"
|
|
26
|
+
RETRIEVAL_ARTIFACT = "retrieval.json"
|
|
27
|
+
CONTEXT_ARTIFACT = "context.md"
|
|
28
|
+
TASK_ARTIFACT = "task.md"
|
|
29
|
+
RUN_ARTIFACT = "run.json"
|
|
30
|
+
FIX_REPORT_ARTIFACT = "fix_report.md"
|
|
31
|
+
REVIEW_REPORT_ARTIFACT = "review_report.md"
|
|
32
|
+
VERIFICATION_REPORT_ARTIFACT = "verification_report.md"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WorkItemNotFoundError(FileNotFoundError):
|
|
36
|
+
"""The work item directory does not exist."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# What a normal prepare-only run leaves behind once every batch has landed.
|
|
40
|
+
# The reports are not here: each exists only once something wrote it.
|
|
41
|
+
CORE_ARTIFACTS: tuple[str, ...] = (
|
|
42
|
+
ISSUE_ARTIFACT,
|
|
43
|
+
RETRIEVAL_ARTIFACT,
|
|
44
|
+
CONTEXT_ARTIFACT,
|
|
45
|
+
TASK_ARTIFACT,
|
|
46
|
+
RUN_ARTIFACT,
|
|
47
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Files a developer hands to the agent alongside the prepared package.
|
|
2
|
+
|
|
3
|
+
A crash log, a screenshot of the broken dialog, a config that reproduces it —
|
|
4
|
+
things that are not in the repository and not in the Jira description, and that
|
|
5
|
+
often decide whether a diagnosis is right.
|
|
6
|
+
|
|
7
|
+
Two rules shape this module, and both come from the same principle the rest of
|
|
8
|
+
the pipeline follows: **the agent must never be told about something it cannot
|
|
9
|
+
read.**
|
|
10
|
+
|
|
11
|
+
1. An attachment that could not be copied is not listed in the task file. It
|
|
12
|
+
is reported back instead, so the developer learns it did not make it rather
|
|
13
|
+
than believing the agent saw it.
|
|
14
|
+
2. Copying is byte-for-byte and makes no encoding assumption. A PNG is a PNG;
|
|
15
|
+
a log written by a Windows tool in cp1252 stays exactly as it was.
|
|
16
|
+
|
|
17
|
+
The files land in ``.ai/<work_item>/attachments/``, inside the directory the
|
|
18
|
+
agent is already pointed at, so nothing has to be given a path outside it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import shutil
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Mapping, Sequence
|
|
27
|
+
|
|
28
|
+
from .safe_paths import refuse_link
|
|
29
|
+
|
|
30
|
+
ATTACHMENTS_DIR = "attachments"
|
|
31
|
+
|
|
32
|
+
# Per file. A crash dump or a video of the repro is not an attachment, it is a
|
|
33
|
+
# download — and copying one into `.ai/` bloats the repository the artifacts
|
|
34
|
+
# directory lives in.
|
|
35
|
+
MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
# Past this it is a directory, not a set of attachments, and an agent handed
|
|
38
|
+
# thirty files reads none of them properly.
|
|
39
|
+
MAX_ATTACHMENTS = 10
|
|
40
|
+
|
|
41
|
+
# A description says why a file matters — a sentence or two — and is not a
|
|
42
|
+
# second bug description. Longer text is cut, not refused: the file is still
|
|
43
|
+
# worth attaching.
|
|
44
|
+
MAX_ATTACHMENT_NOTE_CHARS = 500
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class AttachmentResult:
|
|
49
|
+
"""What was copied, and what was not and why."""
|
|
50
|
+
|
|
51
|
+
# Names inside `attachments/`, in the order they were given.
|
|
52
|
+
copied: list[str] = field(default_factory=list)
|
|
53
|
+
# ``(what the developer asked for, why it did not make it)``.
|
|
54
|
+
skipped: list[tuple[str, str]] = field(default_factory=list)
|
|
55
|
+
# The developer's description of a copied file, by its name in
|
|
56
|
+
# `attachments/`. Only files that arrived and were described appear here.
|
|
57
|
+
notes: dict[str, str] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def relative_paths(self) -> list[str]:
|
|
61
|
+
return [f"{ATTACHMENTS_DIR}/{name}" for name in self.copied]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_attachment_note(text: str | None) -> str:
|
|
65
|
+
"""A description as the task file carries it: one line, bounded.
|
|
66
|
+
|
|
67
|
+
One line because it sits under a heading in ``task.md``: a pasted newline
|
|
68
|
+
followed by ``##`` must not become a section of its own.
|
|
69
|
+
"""
|
|
70
|
+
if not text:
|
|
71
|
+
return ""
|
|
72
|
+
return " ".join(text.split())[:MAX_ATTACHMENT_NOTE_CHARS].rstrip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def copy_attachments(
|
|
76
|
+
target: Path, paths: Sequence[str], descriptions: Sequence[str] | None = None
|
|
77
|
+
) -> AttachmentResult:
|
|
78
|
+
"""Copy each path into ``target/attachments/``.
|
|
79
|
+
|
|
80
|
+
``descriptions`` pairs with ``paths`` by position — the Nth describes the
|
|
81
|
+
Nth file; a blank one, or none, means no description. A description follows
|
|
82
|
+
its file to the name it was copied under, and is dropped with a file that
|
|
83
|
+
did not make it.
|
|
84
|
+
|
|
85
|
+
Never raises for a bad input: a missing or oversized file costs itself and
|
|
86
|
+
nothing else, because losing a whole investigation over one unreadable
|
|
87
|
+
screenshot would be the wrong trade. The caller surfaces ``skipped``.
|
|
88
|
+
"""
|
|
89
|
+
result = AttachmentResult()
|
|
90
|
+
if not paths:
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
directory = target / ATTACHMENTS_DIR
|
|
94
|
+
used: set[str] = set()
|
|
95
|
+
described = list(descriptions or [])
|
|
96
|
+
|
|
97
|
+
for index, raw in enumerate(paths):
|
|
98
|
+
if len(result.copied) >= MAX_ATTACHMENTS:
|
|
99
|
+
result.skipped.append((raw, f"more than {MAX_ATTACHMENTS} attachments"))
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
source = Path(raw).expanduser()
|
|
103
|
+
try:
|
|
104
|
+
if not source.is_file():
|
|
105
|
+
result.skipped.append((raw, "not a file"))
|
|
106
|
+
continue
|
|
107
|
+
size = source.stat().st_size
|
|
108
|
+
except OSError as exc:
|
|
109
|
+
result.skipped.append((raw, f"could not be read: {exc.strerror or exc}"))
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if size > MAX_ATTACHMENT_BYTES:
|
|
113
|
+
megabytes = MAX_ATTACHMENT_BYTES // (1024 * 1024)
|
|
114
|
+
result.skipped.append((raw, f"larger than {megabytes} MB"))
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
name = _unique_name(source.name, used)
|
|
118
|
+
try:
|
|
119
|
+
# Never into, or through, a link: the folder and
|
|
120
|
+
# the file are checked before the copy, which would follow either.
|
|
121
|
+
refuse_link(directory)
|
|
122
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
refuse_link(directory)
|
|
124
|
+
# copyfile, not copy2: the metadata of the developer's original is
|
|
125
|
+
# theirs, and a copied mtime makes the artifact look older than the
|
|
126
|
+
# run that produced it.
|
|
127
|
+
shutil.copyfile(source, refuse_link(directory / name))
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
result.skipped.append((raw, f"could not be copied: {exc.strerror or exc}"))
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
used.add(name.lower())
|
|
133
|
+
result.copied.append(name)
|
|
134
|
+
note = normalize_attachment_note(described[index] if index < len(described) else None)
|
|
135
|
+
if note:
|
|
136
|
+
result.notes[name] = note
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def merge_attachment_notes(previous: Mapping[str, str], result: AttachmentResult) -> dict[str, str]:
|
|
142
|
+
"""The descriptions a work item carries after this run.
|
|
143
|
+
|
|
144
|
+
A file this run copied carries this run's description, or none — the
|
|
145
|
+
developer may have cleared it. A file from an earlier run that this one did
|
|
146
|
+
not re-copy keeps the description it had, because it is still on disk and
|
|
147
|
+
the task file still names it.
|
|
148
|
+
"""
|
|
149
|
+
merged = dict(previous)
|
|
150
|
+
for name in result.copied:
|
|
151
|
+
if name in result.notes:
|
|
152
|
+
merged[name] = result.notes[name]
|
|
153
|
+
else:
|
|
154
|
+
merged.pop(name, None)
|
|
155
|
+
return merged
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _unique_name(name: str, used: set[str]) -> str:
|
|
159
|
+
"""A file name that does not collide with one already copied.
|
|
160
|
+
|
|
161
|
+
Two attachments chosen from different directories can share a basename, and
|
|
162
|
+
the second silently overwriting the first is the kind of loss nobody
|
|
163
|
+
notices until the agent quotes the wrong log. Compared case-insensitively,
|
|
164
|
+
because the artifact directory may live on Windows.
|
|
165
|
+
"""
|
|
166
|
+
# `Path.name` cannot contain a separator, so this cannot escape the
|
|
167
|
+
# directory; the fallback only matters for a pathological empty name.
|
|
168
|
+
candidate = name or "attachment"
|
|
169
|
+
if candidate.lower() not in used:
|
|
170
|
+
return candidate
|
|
171
|
+
|
|
172
|
+
stem = Path(candidate).stem
|
|
173
|
+
suffix = Path(candidate).suffix
|
|
174
|
+
for index in range(2, MAX_ATTACHMENTS + 2):
|
|
175
|
+
attempt = f"{stem}-{index}{suffix}"
|
|
176
|
+
if attempt.lower() not in used:
|
|
177
|
+
return attempt
|
|
178
|
+
return f"{stem}-{len(used) + 1}{suffix}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def is_plain_attachment_name(name: str) -> bool:
|
|
182
|
+
"""A single file name: no separator, no drive, not ``.``/``..``, nothing that leaves the folder."""
|
|
183
|
+
return (
|
|
184
|
+
bool(name)
|
|
185
|
+
and name not in {".", ".."}
|
|
186
|
+
and "/" not in name
|
|
187
|
+
and "\\" not in name
|
|
188
|
+
and ":" not in name
|
|
189
|
+
and Path(name).name == name
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def remove_attachments(target: Path, names: Sequence[str]) -> list[str]:
|
|
194
|
+
"""Delete these files from ``target/attachments/``, and nothing else; return what went.
|
|
195
|
+
|
|
196
|
+
Only ever called with names BugPilot itself recorded as copied — never with
|
|
197
|
+
whatever happens to be in the folder — and each one is refused unless it is
|
|
198
|
+
a plain file name naming a regular file (or a link, which is removed and not
|
|
199
|
+
followed) directly inside that folder, and the folder is inside ``target``.
|
|
200
|
+
A name that fails any check is skipped, not "cleaned": it is not ours to
|
|
201
|
+
guess about.
|
|
202
|
+
"""
|
|
203
|
+
directory = target / ATTACHMENTS_DIR
|
|
204
|
+
try:
|
|
205
|
+
if not directory.is_dir() or directory.resolve().parent != target.resolve():
|
|
206
|
+
return []
|
|
207
|
+
except OSError:
|
|
208
|
+
return []
|
|
209
|
+
removed: list[str] = []
|
|
210
|
+
for name in names:
|
|
211
|
+
if not is_plain_attachment_name(name):
|
|
212
|
+
continue
|
|
213
|
+
candidate = directory / name
|
|
214
|
+
try:
|
|
215
|
+
if candidate.is_symlink() or candidate.is_file():
|
|
216
|
+
candidate.unlink()
|
|
217
|
+
removed.append(name)
|
|
218
|
+
except OSError:
|
|
219
|
+
# Locked or already gone: the record no longer names it either way.
|
|
220
|
+
continue
|
|
221
|
+
return removed
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def listed_attachments(target: Path, recorded: Sequence[str] | None) -> list[str]:
|
|
225
|
+
"""The attachments a task file names: the recorded selection, else the folder.
|
|
226
|
+
|
|
227
|
+
A work item prepared by the extension records which files it currently
|
|
228
|
+
has (``guidance.attachment_files``); then only those are named — and only
|
|
229
|
+
the ones still on disk. One prepared before the record existed, or only
|
|
230
|
+
ever by an additive CLI run, falls back to reading the folder as before.
|
|
231
|
+
"""
|
|
232
|
+
if recorded is None:
|
|
233
|
+
return attachment_names(target)
|
|
234
|
+
directory = target / ATTACHMENTS_DIR
|
|
235
|
+
return [name for name in recorded if is_plain_attachment_name(name) and (directory / name).is_file()]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def attachment_names(target: Path) -> list[str]:
|
|
239
|
+
"""What is in the attachments directory now, sorted.
|
|
240
|
+
|
|
241
|
+
Read from disk rather than remembered, so a `--resume` run lists what a
|
|
242
|
+
previous run copied.
|
|
243
|
+
"""
|
|
244
|
+
directory = target / ATTACHMENTS_DIR
|
|
245
|
+
if not directory.is_dir():
|
|
246
|
+
return []
|
|
247
|
+
return sorted(path.name for path in directory.iterdir() if path.is_file())
|