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.py
ADDED
|
@@ -0,0 +1,2615 @@
|
|
|
1
|
+
"""Command line interface for bugpilot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from bugpilot import __version__, cli_json
|
|
12
|
+
from bugpilot.core import agent_runner, copilot, doctor, errors, setup, workflow
|
|
13
|
+
from bugpilot.core.branch_policy import BRANCH_POLICIES
|
|
14
|
+
from bugpilot.core.cleanup import clean_issue_artifacts
|
|
15
|
+
from bugpilot.core.safe_paths import UnsafePathError
|
|
16
|
+
from bugpilot.core.context import build_context
|
|
17
|
+
from bugpilot.core.email_notify import EmailSendError
|
|
18
|
+
from bugpilot.core.git_history import render_git_context
|
|
19
|
+
from bugpilot.core.fix_mode_state import fix_mode_metadata
|
|
20
|
+
from bugpilot.core.fix_mode_store import FixModeCatalog, FixModeStore, scoped_modes
|
|
21
|
+
from bugpilot.core.fix_modes import FixMode, FixModeError
|
|
22
|
+
from bugpilot.core.jira import JiraCommentPostError, JiraFetchError, JiraSiteError, fetch_issue, normalize_jira_site, parse_issue
|
|
23
|
+
from bugpilot.core.artifacts import CONTEXT_ARTIFACT, CORE_ARTIFACTS, FIX_REPORT_ARTIFACT, RUN_ARTIFACT
|
|
24
|
+
from bugpilot.core.artifacts import ISSUE_ARTIFACT, RETRIEVAL_ARTIFACT, TASK_ARTIFACT, WorkItemNotFoundError
|
|
25
|
+
from bugpilot.core.review_report import (
|
|
26
|
+
MAX_SECTION_CHARS,
|
|
27
|
+
REVIEW_SECTIONS,
|
|
28
|
+
ReviewInput,
|
|
29
|
+
ReviewReportExistsError,
|
|
30
|
+
record_review,
|
|
31
|
+
)
|
|
32
|
+
from bugpilot.core.verification_report import (
|
|
33
|
+
MAX_CHECKS,
|
|
34
|
+
MAX_NAME_CHARS,
|
|
35
|
+
MAX_TEXT_CHARS,
|
|
36
|
+
VerificationCheck,
|
|
37
|
+
VerificationReportExistsError,
|
|
38
|
+
record_verification,
|
|
39
|
+
summary_line,
|
|
40
|
+
)
|
|
41
|
+
from bugpilot.core.instructions import (
|
|
42
|
+
INSTRUCTION_SCOPES,
|
|
43
|
+
MAX_INSTRUCTION_CHARS,
|
|
44
|
+
InstructionFile,
|
|
45
|
+
display_path,
|
|
46
|
+
instructions_payload,
|
|
47
|
+
load_instructions,
|
|
48
|
+
save_instructions,
|
|
49
|
+
)
|
|
50
|
+
from bugpilot.core.project_settings import (
|
|
51
|
+
DEFAULT_BRANCH_TEMPLATE_LABEL,
|
|
52
|
+
SHOWN_PATH as SETTINGS_SHOWN_PATH,
|
|
53
|
+
load_project_settings,
|
|
54
|
+
project_settings_payload,
|
|
55
|
+
save_project_settings,
|
|
56
|
+
settings_from_mapping,
|
|
57
|
+
settings_path,
|
|
58
|
+
)
|
|
59
|
+
from bugpilot.core.review_changes import collect_review_changes, review_changes_markdown
|
|
60
|
+
from bugpilot.core.run import RunArtifactError, load_run, run_to_dict
|
|
61
|
+
from bugpilot.core.identity import is_work_item_id
|
|
62
|
+
from bugpilot.core.input_adapters import bug_spec_from_description
|
|
63
|
+
from bugpilot.core.issue import IssueArtifactError, read_issue_quietly
|
|
64
|
+
from bugpilot.core.keywords import extract_keywords
|
|
65
|
+
from bugpilot.core.models import (
|
|
66
|
+
DEFAULT_MAX_SIMILAR_FIXES,
|
|
67
|
+
GIT_HISTORY_DEPTHS,
|
|
68
|
+
MAX_RELATED_COMMITS_LIMIT,
|
|
69
|
+
MAX_SIMILAR_FIXES_LIMIT,
|
|
70
|
+
SOURCE_MANUAL,
|
|
71
|
+
GitHistoryOptions,
|
|
72
|
+
InvestigationOptions,
|
|
73
|
+
InvestigationPlan,
|
|
74
|
+
InvestigationRequest,
|
|
75
|
+
SimilarFixesOptions,
|
|
76
|
+
)
|
|
77
|
+
from bugpilot.core.memory import add_memory_entry, search_memory
|
|
78
|
+
from bugpilot.core.prompts import generate_task
|
|
79
|
+
from bugpilot.core.repository_profile import (
|
|
80
|
+
PROFILE_FIELD_KEYS,
|
|
81
|
+
PROFILE_FIELDS,
|
|
82
|
+
PROFILE_MODE_LABELS,
|
|
83
|
+
PROFILE_MODES,
|
|
84
|
+
PROJECT_CONFIG_DIR,
|
|
85
|
+
PROFILE_FILE_NAME,
|
|
86
|
+
RepositoryProfile,
|
|
87
|
+
detect_repository,
|
|
88
|
+
load_repository_profile,
|
|
89
|
+
profile_from_payload,
|
|
90
|
+
profile_path,
|
|
91
|
+
resolve_repository_context,
|
|
92
|
+
save_repository_profile,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _add_json_flag(parser: argparse.ArgumentParser) -> None:
|
|
97
|
+
"""Machine-readable output on stdout; human text is unchanged without it."""
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--json",
|
|
100
|
+
action="store_true",
|
|
101
|
+
dest="json_output",
|
|
102
|
+
help="Emit a single JSON object on stdout instead of human-readable text. Never launches an agent.",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
107
|
+
parser = argparse.ArgumentParser(prog="bugpilot")
|
|
108
|
+
# "bugpilot 0.1.0": one line a person or a tool can read. The version is
|
|
109
|
+
# bugpilot.__version__, the one string pyproject.toml also builds from.
|
|
110
|
+
parser.add_argument("--version", action="version", version=f"bugpilot {__version__}")
|
|
111
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
112
|
+
|
|
113
|
+
setup_parser = subparsers.add_parser("setup", help="Interactively configure BugPilot (Jira email + API token).")
|
|
114
|
+
setup_parser.add_argument("--hide-token", action="store_true", help="Hide the API token while typing (default: shown so you can verify the paste).")
|
|
115
|
+
doctor_parser = subparsers.add_parser("doctor", help="Check local environment readiness.")
|
|
116
|
+
_add_json_flag(doctor_parser)
|
|
117
|
+
subparsers.add_parser("agent-check", help="Check AI agent readiness.")
|
|
118
|
+
|
|
119
|
+
for name in ("parse", "keywords", "search", "git-context", "context", "status", "agent-instructions", "review-package", "delivery-check", "push-plan", "retry-prompt"):
|
|
120
|
+
command = subparsers.add_parser(name, help=f"Run the {name} step.")
|
|
121
|
+
command.add_argument("issue_key")
|
|
122
|
+
if name in {"status", "delivery-check", "search", "context", "review-package"}:
|
|
123
|
+
_add_json_flag(command)
|
|
124
|
+
if name == "review-package":
|
|
125
|
+
command.add_argument(
|
|
126
|
+
"--include-changes",
|
|
127
|
+
action="store_true",
|
|
128
|
+
help=(
|
|
129
|
+
"Also collect the current git status and diff, read-only, for a reviewer "
|
|
130
|
+
"that cannot run commands (Review with AI's captured review)."
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
for name in ("prompt", "agent-task"):
|
|
135
|
+
command = subparsers.add_parser(name, help=f"Run the {name} step.")
|
|
136
|
+
command.add_argument("issue_key")
|
|
137
|
+
command.add_argument(
|
|
138
|
+
"--jira-comment",
|
|
139
|
+
action="store_true",
|
|
140
|
+
help="Include the pre-commit Jira status comment instruction in the generated task.md (omitted by default).",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
summarize_parser = subparsers.add_parser("summarize-results", help="Summarize agent results; optionally post a Jira comment so watchers are notified.")
|
|
144
|
+
summarize_parser.add_argument("issue_key")
|
|
145
|
+
summarize_jira = summarize_parser.add_mutually_exclusive_group()
|
|
146
|
+
summarize_jira.add_argument("--jira-comment", action="store_true", help="Post the analysis summary as a Jira comment (Jira then notifies watchers by email).")
|
|
147
|
+
summarize_jira.add_argument("--no-jira-comment", action="store_true", help="Do not post a Jira comment even if BUGPILOT_AUTO_JIRA_COMMENT is set.")
|
|
148
|
+
_add_json_flag(summarize_parser)
|
|
149
|
+
|
|
150
|
+
commit_plan_parser = subparsers.add_parser("commit-plan", help="Run the commit-plan step and notify by email at the commit gate.")
|
|
151
|
+
commit_plan_parser.add_argument("issue_key")
|
|
152
|
+
commit_plan_parser.add_argument("--no-email", action="store_true", help="Do not send the commit-gate notification email.")
|
|
153
|
+
|
|
154
|
+
notify_parser = subparsers.add_parser("notify", help="Preview or send the post-fix notification email.")
|
|
155
|
+
notify_parser.add_argument("issue_key")
|
|
156
|
+
notify_parser.add_argument("--execute", action="store_true", help="Send the email over SMTP. Without this flag, only a local preview is written.")
|
|
157
|
+
|
|
158
|
+
fetch_parser = subparsers.add_parser("fetch", help="Fetch Jira data.")
|
|
159
|
+
fetch_parser.add_argument("issue_key")
|
|
160
|
+
fetch_mock = fetch_parser.add_mutually_exclusive_group()
|
|
161
|
+
fetch_mock.add_argument("--allow-mock", action="store_true", help="Allow mock/demo fallback when Jira fetch fails.")
|
|
162
|
+
fetch_mock.add_argument("--no-mock", action="store_true", help="Require real Jira data. This is the default.")
|
|
163
|
+
_add_json_flag(fetch_parser)
|
|
164
|
+
|
|
165
|
+
issue_details_parser = subparsers.add_parser(
|
|
166
|
+
"issue-details",
|
|
167
|
+
help="Print one issue's title and description. Reads Jira; writes nothing.",
|
|
168
|
+
)
|
|
169
|
+
issue_details_parser.add_argument("issue_key")
|
|
170
|
+
issue_details_mock = issue_details_parser.add_mutually_exclusive_group()
|
|
171
|
+
issue_details_mock.add_argument("--allow-mock", action="store_true", help="Allow mock/demo fallback when Jira fetch fails.")
|
|
172
|
+
issue_details_mock.add_argument("--no-mock", action="store_true", help="Require real Jira data. This is the default.")
|
|
173
|
+
_add_json_flag(issue_details_parser)
|
|
174
|
+
|
|
175
|
+
jira_validate_parser = subparsers.add_parser("jira-validate", help="Validate Jira issue fetch and field mapping (requires real Jira credentials).")
|
|
176
|
+
jira_validate_parser.add_argument("issue_key")
|
|
177
|
+
|
|
178
|
+
jira_comment_parser = subparsers.add_parser("jira-comment-draft", help="Generate a local Jira comment draft from existing bugpilot artifacts.")
|
|
179
|
+
jira_comment_parser.add_argument("issue_key")
|
|
180
|
+
jira_comment_parser.add_argument("--strict", action="store_true", help="Fail if agent result artifacts are missing.")
|
|
181
|
+
|
|
182
|
+
jira_post_parser = subparsers.add_parser("jira-comment", help="Preview or explicitly post a Jira comment draft.")
|
|
183
|
+
jira_post_parser.add_argument("issue_key")
|
|
184
|
+
jira_post_parser.add_argument("--execute", action="store_true", help="Post the local Jira comment draft to Jira.")
|
|
185
|
+
|
|
186
|
+
list_parser = subparsers.add_parser("list", help="List prepared work items under .ai/.")
|
|
187
|
+
_add_json_flag(list_parser)
|
|
188
|
+
|
|
189
|
+
clean_parser = subparsers.add_parser("clean", help="Remove generated workflow artifacts for an issue.")
|
|
190
|
+
clean_parser.add_argument("issue_key")
|
|
191
|
+
clean_parser.add_argument("--include-memory", action="store_true", help="Also remove that issue's memory entry.")
|
|
192
|
+
|
|
193
|
+
commit_parser = subparsers.add_parser("commit", help="Says why bugpilot does not commit, and what to use instead.")
|
|
194
|
+
commit_parser.add_argument("issue_key")
|
|
195
|
+
commit_parser.add_argument("--execute", action="store_true", help="Accepted and ignored: this command never executes anything.")
|
|
196
|
+
|
|
197
|
+
push_parser = subparsers.add_parser("push", help="Says why bugpilot does not push, and what to use instead.")
|
|
198
|
+
push_parser.add_argument("issue_key")
|
|
199
|
+
push_parser.add_argument("--execute", action="store_true", help="Accepted and ignored: this command never executes anything.")
|
|
200
|
+
|
|
201
|
+
check_parser = subparsers.add_parser("check-results", help="Check whether the agent's fix report exists.")
|
|
202
|
+
check_parser.add_argument("issue_key")
|
|
203
|
+
check_parser.add_argument("--strict", action="store_true", help="Exit non-zero when the fix report is missing.")
|
|
204
|
+
_add_json_flag(check_parser)
|
|
205
|
+
|
|
206
|
+
manual_result_parser = subparsers.add_parser("manual-result", help="Generate the developer manual-fix report template.")
|
|
207
|
+
manual_result_parser.add_argument("issue_key")
|
|
208
|
+
manual_result_parser.add_argument("--overwrite", action="store_true", help="Overwrite an existing fix report with the template.")
|
|
209
|
+
|
|
210
|
+
record_review_parser = subparsers.add_parser(
|
|
211
|
+
"record-review",
|
|
212
|
+
help="Record the result of a completed review in review_report.md. Records what the review said; checks nothing.",
|
|
213
|
+
)
|
|
214
|
+
record_review_parser.add_argument("issue_key")
|
|
215
|
+
record_review_parser.add_argument("--summary", default=None, help="What the review concluded, in its own words.")
|
|
216
|
+
record_review_parser.add_argument("--findings", default=None, help="The problems or observations the review reported.")
|
|
217
|
+
record_review_parser.add_argument("--validation-notes", dest="validation_notes", default=None, help="What the reviewer checked or ran, as they reported it.")
|
|
218
|
+
record_review_parser.add_argument("--recommendations", default=None, help="What the review recommends doing next.")
|
|
219
|
+
record_review_parser.add_argument(
|
|
220
|
+
"--from-file",
|
|
221
|
+
dest="from_file",
|
|
222
|
+
default=None,
|
|
223
|
+
help="A JSON object with summary, findings, validation_notes and recommendations, instead of the text options.",
|
|
224
|
+
)
|
|
225
|
+
record_review_parser.add_argument("--replace", action="store_true", help="Overwrite a review result that is already recorded.")
|
|
226
|
+
_add_json_flag(record_review_parser)
|
|
227
|
+
|
|
228
|
+
record_verification_parser = subparsers.add_parser(
|
|
229
|
+
"record-verification",
|
|
230
|
+
help=(
|
|
231
|
+
"Record verification evidence in verification_report.md: the checks you ran and the status you "
|
|
232
|
+
"recorded for each. Runs nothing and verifies nothing."
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
record_verification_parser.add_argument("issue_key")
|
|
236
|
+
record_verification_parser.add_argument(
|
|
237
|
+
"--from-file",
|
|
238
|
+
dest="from_file",
|
|
239
|
+
default=None,
|
|
240
|
+
help=(
|
|
241
|
+
'A JSON object {"checks": [...]}; each check has a name and a status (passed, failed or not_run), '
|
|
242
|
+
"and optionally a type (automated, manual or other), procedure, evidence and notes."
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
record_verification_parser.add_argument(
|
|
246
|
+
"--replace", action="store_true", help="Overwrite verification evidence that is already recorded."
|
|
247
|
+
)
|
|
248
|
+
_add_json_flag(record_verification_parser)
|
|
249
|
+
|
|
250
|
+
fix_mode_parser = subparsers.add_parser("fix-mode", help="List, show or customize the AI fixing workflows.")
|
|
251
|
+
fix_mode_parser.add_argument(
|
|
252
|
+
"action",
|
|
253
|
+
choices=["list", "show", "duplicate", "create", "update", "delete"],
|
|
254
|
+
help="list or show the modes, or duplicate/create/update/delete a custom one.",
|
|
255
|
+
)
|
|
256
|
+
fix_mode_parser.add_argument("mode_id", nargs="?", help="Mode id. Required by everything but `list`.")
|
|
257
|
+
fix_mode_parser.add_argument("new_id", nargs="?", help="The copy's id, for `duplicate`.")
|
|
258
|
+
fix_mode_parser.add_argument(
|
|
259
|
+
"--scope",
|
|
260
|
+
choices=["user", "project"],
|
|
261
|
+
help="Where a custom mode lives: your home directory, or this repository. Required to write one.",
|
|
262
|
+
)
|
|
263
|
+
fix_mode_parser.add_argument(
|
|
264
|
+
"--all-scopes",
|
|
265
|
+
action="store_true",
|
|
266
|
+
dest="all_scopes",
|
|
267
|
+
help="List every definition on disk, including one shadowed by another scope.",
|
|
268
|
+
)
|
|
269
|
+
fix_mode_parser.add_argument(
|
|
270
|
+
"--from-file",
|
|
271
|
+
metavar="PATH",
|
|
272
|
+
dest="from_file",
|
|
273
|
+
help="JSON file holding the mode definition, for `create` and `update`.",
|
|
274
|
+
)
|
|
275
|
+
fix_mode_parser.add_argument(
|
|
276
|
+
"--expected-version",
|
|
277
|
+
type=int,
|
|
278
|
+
metavar="N",
|
|
279
|
+
dest="expected_version",
|
|
280
|
+
help="The version you last saw. `update` and `delete` refuse if it has moved on since.",
|
|
281
|
+
)
|
|
282
|
+
fix_mode_parser.add_argument(
|
|
283
|
+
"--name",
|
|
284
|
+
metavar="TEXT",
|
|
285
|
+
help="Display name for a duplicated mode.",
|
|
286
|
+
)
|
|
287
|
+
_add_json_flag(fix_mode_parser)
|
|
288
|
+
|
|
289
|
+
memory_parser = subparsers.add_parser("memory", help="Manage shared AI memory.")
|
|
290
|
+
memory_subparsers = memory_parser.add_subparsers(dest="memory_command", required=True)
|
|
291
|
+
memory_add = memory_subparsers.add_parser("add", help="Add bug memory entry.")
|
|
292
|
+
memory_add.add_argument("issue_key")
|
|
293
|
+
memory_update = memory_subparsers.add_parser("update", help="Update bug memory from result summary.")
|
|
294
|
+
memory_update.add_argument("issue_key")
|
|
295
|
+
memory_search = memory_subparsers.add_parser("search", help="Search shared AI memory.")
|
|
296
|
+
memory_search.add_argument("query")
|
|
297
|
+
|
|
298
|
+
bug_parser = subparsers.add_parser(
|
|
299
|
+
"bug",
|
|
300
|
+
help="Prepare the bug workflow package. Keeps existing artifacts and launches no agent unless asked.",
|
|
301
|
+
description=(
|
|
302
|
+
"Prepare .ai/<issue>/ for a coding agent: issue details, code search, git history, "
|
|
303
|
+
"similar fixes, context.md and task.md. By default this only prepares: existing "
|
|
304
|
+
"artifacts are kept (--fresh deletes them first) and no agent is launched "
|
|
305
|
+
"(--launch-agent starts one)."
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
# Optional so a bug can be described by hand instead: exactly one of an issue
|
|
309
|
+
# key or a description is required, checked in main() for a clearer message
|
|
310
|
+
# than argparse's mutually-exclusive-group wording gives for a positional.
|
|
311
|
+
bug_parser.add_argument("issue_key", nargs="?")
|
|
312
|
+
bug_input = bug_parser.add_mutually_exclusive_group()
|
|
313
|
+
bug_input.add_argument(
|
|
314
|
+
"--description",
|
|
315
|
+
metavar="TEXT",
|
|
316
|
+
help="Describe the bug directly instead of fetching a Jira issue.",
|
|
317
|
+
)
|
|
318
|
+
bug_input.add_argument(
|
|
319
|
+
"--description-file",
|
|
320
|
+
metavar="PATH",
|
|
321
|
+
help="Read the bug description from a file instead of fetching a Jira issue.",
|
|
322
|
+
)
|
|
323
|
+
bug_parser.add_argument("--title", metavar="TEXT", help="Title for a hand-written bug (derived from the description otherwise).")
|
|
324
|
+
bug_parser.add_argument("--keywords", metavar="WORD", action="append", default=[], help="Extra search keyword, shared: code search uses it, and git history and similar fixes unless told not to. Repeatable.")
|
|
325
|
+
bug_parser.add_argument("--focus-file", metavar="PATH", action="append", default=[], dest="focus_files", help="Rank this file or directory higher. Repeatable.")
|
|
326
|
+
bug_parser.add_argument("--ignore-path", metavar="PATH", action="append", default=[], dest="ignore_paths", help="Exclude this file or directory from code search. Repeatable.")
|
|
327
|
+
bug_parser.add_argument("--attach", metavar="PATH", action="append", default=[], dest="attachments", help="Copy this file into the work item for the agent to read: a log, a screenshot, a config. Repeatable.")
|
|
328
|
+
bug_parser.add_argument("--attach-description", metavar="TEXT", action="append", default=[], dest="attachment_descriptions", help="Why the matching --attach file matters, by position: the Nth describes the Nth --attach. Give one per --attach (an empty one for none), or none at all.")
|
|
329
|
+
bug_parser.add_argument("--replace-attachments", action="store_true", help="Make the --attach files the work item's complete set: a file an earlier run attached that is not among them is removed from .ai/<id>/attachments/ and from the task file. Without it, --attach adds to what an earlier run attached.")
|
|
330
|
+
bug_parser.add_argument("--max-files", type=int, metavar="N", help="How many related files to keep (default 10).")
|
|
331
|
+
bug_parser.add_argument("--max-search-lines", type=int, metavar="N", help="Line budget for the matched-lines section (default 300).")
|
|
332
|
+
bug_parser.add_argument("--skip-code-search", action="store_true", help="Skip the code search capability.")
|
|
333
|
+
bug_parser.add_argument("--skip-git-history", action="store_true", help="Skip the git history capability.")
|
|
334
|
+
# Git History Settings: Git history only — Code Search never reads them.
|
|
335
|
+
# Off-switches rather than on-switches, so a command line that names none of
|
|
336
|
+
# them runs exactly as before they existed.
|
|
337
|
+
bug_parser.add_argument("--git-keyword", metavar="WORD", action="append", default=[], dest="git_keywords", help="Extra keyword for the git history commit search only. Repeatable.")
|
|
338
|
+
bug_parser.add_argument("--git-file", metavar="PATH", action="append", default=[], dest="git_files", help="Extra file or directory whose git history is read; not used by code search. Repeatable.")
|
|
339
|
+
bug_parser.add_argument("--git-no-shared-keywords", action="store_true", help="Git history ignores --keywords (code search still uses them).")
|
|
340
|
+
bug_parser.add_argument("--git-no-shared-focus-files", action="store_true", help="Git history ignores --focus-file (code search still uses them).")
|
|
341
|
+
bug_parser.add_argument("--git-no-commit-search", action="store_true", help="Git history does not search commit messages.")
|
|
342
|
+
bug_parser.add_argument("--git-no-file-history", action="store_true", help="Git history does not read the history of related files.")
|
|
343
|
+
bug_parser.add_argument("--git-history-depth", choices=GIT_HISTORY_DEPTHS, default="recent", help="How far back git history reads (default recent).")
|
|
344
|
+
bug_parser.add_argument("--git-max-commits", type=int, metavar="N", help=f"How many related commits to keep, 1-{MAX_RELATED_COMMITS_LIMIT} (default 10).")
|
|
345
|
+
bug_parser.add_argument("--skip-similar-fixes", action="store_true", help="Skip the memory search for similar past bugs.")
|
|
346
|
+
# Similar Fixes Settings: the memory search only — neither code search nor
|
|
347
|
+
# git history reads them. As above, a command line that names none of them
|
|
348
|
+
# runs exactly as before they existed.
|
|
349
|
+
bug_parser.add_argument("--similar-fixes-keyword", metavar="WORD", action="append", default=[], dest="similar_keywords", help="Extra keyword for the similar-fixes memory search only. Repeatable.")
|
|
350
|
+
bug_parser.add_argument("--similar-fixes-no-shared-keywords", action="store_true", help="The similar-fixes memory search ignores --keywords (code search still uses them).")
|
|
351
|
+
bug_parser.add_argument("--max-similar-fixes", type=int, metavar="N", help=f"How many similar past fixes to keep, 1-{MAX_SIMILAR_FIXES_LIMIT} (default {DEFAULT_MAX_SIMILAR_FIXES}).")
|
|
352
|
+
bug_parser.add_argument("--only-issue-details", action="store_true", help="Only normalize the bug description; skip search, history and context.")
|
|
353
|
+
_add_json_flag(bug_parser)
|
|
354
|
+
bug_parser.add_argument(
|
|
355
|
+
"--retry",
|
|
356
|
+
action="store_true",
|
|
357
|
+
help=(
|
|
358
|
+
"Prepare a second attempt: generate the retry prompt and user_feedback.md. "
|
|
359
|
+
"Launches no agent unless --launch-agent is given."
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
bug_parser.add_argument(
|
|
363
|
+
"--json-lines",
|
|
364
|
+
action="store_true",
|
|
365
|
+
dest="json_lines",
|
|
366
|
+
help="Stream one JSON event per line while the workflow runs. Never launches an agent.",
|
|
367
|
+
)
|
|
368
|
+
bug_parser.add_argument(
|
|
369
|
+
"--agent-fix",
|
|
370
|
+
action="store_true",
|
|
371
|
+
help="Print experimental agent invocation guidance after preparation.",
|
|
372
|
+
)
|
|
373
|
+
# Preparing and involving a model are separate decisions (requirement R5),
|
|
374
|
+
# so launching an agent is something a command line asks for by name. There
|
|
375
|
+
# is no implicit agent: a bare `bugpilot bug <issue>` prepares and stops.
|
|
376
|
+
bug_run = bug_parser.add_mutually_exclusive_group()
|
|
377
|
+
bug_run.add_argument(
|
|
378
|
+
"--prepare-only",
|
|
379
|
+
action="store_true",
|
|
380
|
+
help="Prepare artifacts and launch no agent. This is the default; the flag says so explicitly.",
|
|
381
|
+
)
|
|
382
|
+
bug_run.add_argument(
|
|
383
|
+
"--launch-agent",
|
|
384
|
+
choices=agent_runner.LAUNCHABLE_AGENTS,
|
|
385
|
+
metavar="AGENT",
|
|
386
|
+
dest="launch_agent",
|
|
387
|
+
help=(
|
|
388
|
+
"After preparing, launch this coding agent (claude or copilot) in this terminal to "
|
|
389
|
+
"work the task. Without it no agent is launched."
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
# Likewise deleting: the work item's folder holds the agent's fix report and
|
|
393
|
+
# the developer's own notes, so it is kept unless --fresh says otherwise.
|
|
394
|
+
bug_mode = bug_parser.add_mutually_exclusive_group()
|
|
395
|
+
bug_mode.add_argument(
|
|
396
|
+
"--fresh",
|
|
397
|
+
action="store_true",
|
|
398
|
+
help="Delete the existing .ai/<issue>/ artifacts before preparing. Without it they are kept.",
|
|
399
|
+
)
|
|
400
|
+
bug_mode.add_argument(
|
|
401
|
+
"--resume",
|
|
402
|
+
action="store_true",
|
|
403
|
+
help="Keep the existing .ai/<issue>/ artifacts and continue the workflow. This is the default; the flag says so explicitly.",
|
|
404
|
+
)
|
|
405
|
+
bug_parser.add_argument(
|
|
406
|
+
"--include-memory",
|
|
407
|
+
action="store_true",
|
|
408
|
+
help="With --fresh, also remove that issue's memory entry before preparing.",
|
|
409
|
+
)
|
|
410
|
+
bug_parser.add_argument(
|
|
411
|
+
"--hint",
|
|
412
|
+
metavar="TEXT",
|
|
413
|
+
help="Developer hint (e.g. fix location) injected into task.md so the agent goes straight to it.",
|
|
414
|
+
)
|
|
415
|
+
bug_parser.add_argument(
|
|
416
|
+
"--jira-comment",
|
|
417
|
+
action="store_true",
|
|
418
|
+
help="Include the pre-commit Jira status comment instruction in the generated task.md (omitted by default).",
|
|
419
|
+
)
|
|
420
|
+
# No argparse `choices=`: the list comes from the registry, which a later
|
|
421
|
+
# phase widens with project and user modes, and argparse would both freeze
|
|
422
|
+
# that list at parser-build time and word the rejection worse than core does.
|
|
423
|
+
bug_parser.add_argument(
|
|
424
|
+
"--fix-mode",
|
|
425
|
+
metavar="ID",
|
|
426
|
+
dest="fix_mode",
|
|
427
|
+
help=(
|
|
428
|
+
"Select the AI fixing workflow (see: bugpilot fix-mode list). "
|
|
429
|
+
"Default: the work item's previous selection, otherwise Standard Fix."
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
bug_parser.add_argument(
|
|
433
|
+
"--branch-policy",
|
|
434
|
+
dest="branch_policy",
|
|
435
|
+
choices=BRANCH_POLICIES,
|
|
436
|
+
help=(
|
|
437
|
+
"Which branch the agent works on: 'current' works on the checked-out branch and "
|
|
438
|
+
"creates a feature branch only after asking, when on main/master or a detached HEAD; "
|
|
439
|
+
"'per-issue' creates or reuses one branch for the work item; 'ask' asks the "
|
|
440
|
+
"developer before editing. Preparing again never calls for a new branch, and "
|
|
441
|
+
"main/master is never edited under any policy. "
|
|
442
|
+
"Default: the work item's previous choice, otherwise 'current'."
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
bug_parser.add_argument(
|
|
446
|
+
"--repository-profile",
|
|
447
|
+
dest="repository_profile",
|
|
448
|
+
choices=PROFILE_MODES,
|
|
449
|
+
help=(
|
|
450
|
+
"How task.md describes this repository, for this run only: 'auto' reads high-confidence "
|
|
451
|
+
"facts from its build and package files, 'generic' assumes nothing, 'custom' uses the "
|
|
452
|
+
"details saved with `bugpilot repository-profile set`. "
|
|
453
|
+
"Default: the repository's .bugpilot/repository_profile.json, otherwise 'auto'."
|
|
454
|
+
),
|
|
455
|
+
)
|
|
456
|
+
bug_mock = bug_parser.add_mutually_exclusive_group()
|
|
457
|
+
bug_mock.add_argument("--allow-mock", action="store_true", help="Allow mock/demo fallback when Jira fetch fails.")
|
|
458
|
+
bug_mock.add_argument("--no-mock", action="store_true", help="Require real Jira data. This is the default.")
|
|
459
|
+
|
|
460
|
+
profile_parser = subparsers.add_parser(
|
|
461
|
+
"repository-profile",
|
|
462
|
+
help="Show or set how task.md describes this repository (Auto-detect, Generic or Custom).",
|
|
463
|
+
description=(
|
|
464
|
+
"The Repository Profile is saved in .bugpilot/repository_profile.json in the repository, "
|
|
465
|
+
"where the CLI, the MCP server and the VS Code extension all read it. Without the file "
|
|
466
|
+
"the profile is Auto-detect."
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
profile_parser.add_argument(
|
|
470
|
+
"action",
|
|
471
|
+
nargs="?",
|
|
472
|
+
choices=["show", "set"],
|
|
473
|
+
default="show",
|
|
474
|
+
help="show (the default) prints the profile and what Auto-detect finds; set saves a new one.",
|
|
475
|
+
)
|
|
476
|
+
profile_source = profile_parser.add_mutually_exclusive_group()
|
|
477
|
+
profile_source.add_argument(
|
|
478
|
+
"--mode",
|
|
479
|
+
choices=PROFILE_MODES,
|
|
480
|
+
help="For `set`: the mode to save. Custom details already saved are kept.",
|
|
481
|
+
)
|
|
482
|
+
profile_source.add_argument(
|
|
483
|
+
"--from-file",
|
|
484
|
+
metavar="PATH",
|
|
485
|
+
dest="from_file",
|
|
486
|
+
help=(
|
|
487
|
+
'For `set`: a JSON file holding the whole profile, {"mode": ..., "custom": {...}}, with custom '
|
|
488
|
+
f"fields {', '.join(PROFILE_FIELD_KEYS)}."
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
_add_json_flag(profile_parser)
|
|
492
|
+
|
|
493
|
+
site_parser = subparsers.add_parser(
|
|
494
|
+
"jira-site",
|
|
495
|
+
help="Show or set the Jira site BugPilot fetches issues from.",
|
|
496
|
+
description=(
|
|
497
|
+
"The Jira site is read from the JIRA_BASE_URL environment variable, else from ~/.bugpilot/config.toml "
|
|
498
|
+
"(written by `bugpilot setup`, `bugpilot jira-site set` or the VS Code extension's Jira Setup). It must be "
|
|
499
|
+
"an https:// address with no user name, password, query or fragment."
|
|
500
|
+
),
|
|
501
|
+
)
|
|
502
|
+
site_parser.add_argument(
|
|
503
|
+
"action", nargs="?", choices=["show", "set"], default="show", help="show (the default) or set."
|
|
504
|
+
)
|
|
505
|
+
site_parser.add_argument("url", nargs="?", help="For `set`: the site, e.g. https://your-company.atlassian.net.")
|
|
506
|
+
site_parser.add_argument(
|
|
507
|
+
"--stdin", action="store_true", help="For `set`: read the site from standard input instead of the command line."
|
|
508
|
+
)
|
|
509
|
+
_add_json_flag(site_parser)
|
|
510
|
+
|
|
511
|
+
settings_parser = subparsers.add_parser(
|
|
512
|
+
"project-settings",
|
|
513
|
+
help="Show or set the repository's Verification Policy and branch naming template.",
|
|
514
|
+
description=(
|
|
515
|
+
"Project settings are saved in .bugpilot/project_settings.json in the repository, where the CLI, "
|
|
516
|
+
"the MCP server and the VS Code extension all read them. Without the file the defaults apply: run "
|
|
517
|
+
"relevant tests and static checks, report what was not run, no full suite, and branch names "
|
|
518
|
+
"feature/{issue}-{slug}."
|
|
519
|
+
),
|
|
520
|
+
)
|
|
521
|
+
settings_parser.add_argument(
|
|
522
|
+
"action",
|
|
523
|
+
nargs="?",
|
|
524
|
+
choices=["show", "set"],
|
|
525
|
+
default="show",
|
|
526
|
+
help="show (the default) prints the settings; set saves new ones from a JSON file.",
|
|
527
|
+
)
|
|
528
|
+
settings_parser.add_argument(
|
|
529
|
+
"--from-file",
|
|
530
|
+
metavar="PATH",
|
|
531
|
+
dest="from_file",
|
|
532
|
+
help=(
|
|
533
|
+
'For `set`: a JSON file, {"verification": {"relevant_tests": true, "static_checks": true, '
|
|
534
|
+
'"full_suite": false, "report_not_run": true}, "branch_naming": {"template": "bugfix/{issue}-{slug}"}}. '
|
|
535
|
+
"Omitted values take their defaults; an empty template is the default branch name."
|
|
536
|
+
),
|
|
537
|
+
)
|
|
538
|
+
_add_json_flag(settings_parser)
|
|
539
|
+
|
|
540
|
+
instructions_parser = subparsers.add_parser(
|
|
541
|
+
"instructions",
|
|
542
|
+
help="Show or set the User and Project / Team Instructions task.md carries.",
|
|
543
|
+
description=(
|
|
544
|
+
"User Instructions live in ~/.bugpilot/instructions.md and apply to every repository; "
|
|
545
|
+
"Project / Team Instructions live in .bugpilot/instructions.md in the repository and can be "
|
|
546
|
+
"committed with it. The CLI, the MCP server and the VS Code extension all read these two files. "
|
|
547
|
+
"They refine how the AI agent works and never override BugPilot's safety rules."
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
instructions_parser.add_argument(
|
|
551
|
+
"action",
|
|
552
|
+
nargs="?",
|
|
553
|
+
choices=["show", "set"],
|
|
554
|
+
default="show",
|
|
555
|
+
help="show (the default) prints both; set saves one scope, or removes it when the text is empty.",
|
|
556
|
+
)
|
|
557
|
+
instructions_parser.add_argument(
|
|
558
|
+
"--scope",
|
|
559
|
+
choices=INSTRUCTION_SCOPES,
|
|
560
|
+
help="user (~/.bugpilot/instructions.md) or project (.bugpilot/instructions.md). Required for set.",
|
|
561
|
+
)
|
|
562
|
+
instructions_source = instructions_parser.add_mutually_exclusive_group()
|
|
563
|
+
instructions_source.add_argument(
|
|
564
|
+
"--from-file", metavar="PATH", dest="from_file", help="For `set`: a UTF-8 text or Markdown file."
|
|
565
|
+
)
|
|
566
|
+
instructions_source.add_argument(
|
|
567
|
+
"--stdin", action="store_true", help="For `set`: read the text from standard input."
|
|
568
|
+
)
|
|
569
|
+
instructions_source.add_argument(
|
|
570
|
+
"--clear", action="store_true", help="For `set`: remove the instructions file."
|
|
571
|
+
)
|
|
572
|
+
_add_json_flag(instructions_parser)
|
|
573
|
+
|
|
574
|
+
return parser
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _subcommand_names(parser: argparse.ArgumentParser) -> set[str]:
|
|
578
|
+
for action in parser._actions:
|
|
579
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
580
|
+
return set(action.choices)
|
|
581
|
+
return set()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def main(argv: list[str] | None = None) -> int:
|
|
585
|
+
parser = build_parser()
|
|
586
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
587
|
+
# `bug` is the default command: `bugpilot JR-12345` is treated as `bugpilot bug JR-12345`.
|
|
588
|
+
# Only inject when the first token is neither a known subcommand nor an option.
|
|
589
|
+
if argv and not argv[0].startswith("-") and argv[0] not in _subcommand_names(parser):
|
|
590
|
+
argv = ["bug", *argv]
|
|
591
|
+
args = parser.parse_args(argv)
|
|
592
|
+
repo_root = Path.cwd()
|
|
593
|
+
|
|
594
|
+
if not (getattr(args, "json_output", False) or getattr(args, "json_lines", False)):
|
|
595
|
+
try:
|
|
596
|
+
return _dispatch(args, repo_root)
|
|
597
|
+
except UnsafePathError as exc:
|
|
598
|
+
# A generated path that is a link or junction, or a work item id that
|
|
599
|
+
# is not one: one sentence, never a traceback.
|
|
600
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
601
|
+
return 1
|
|
602
|
+
except RunArtifactError as exc:
|
|
603
|
+
# Standalone steps read run.json to mark themselves; an unusable
|
|
604
|
+
# file is an error, and this is its one sentence — a traceback
|
|
605
|
+
# chained through the step's own handler is not.
|
|
606
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
607
|
+
print("Preparing the work item again replaces it: bugpilot bug <work-item-id>", file=sys.stderr)
|
|
608
|
+
return 1
|
|
609
|
+
# Machine-readable modes promise exactly one object (or one closed stream) on
|
|
610
|
+
# stdout for every outcome. An unhandled exception would otherwise print a
|
|
611
|
+
# traceback to stderr and nothing at all to stdout, which a consumer reads as
|
|
612
|
+
# a hang rather than a failure.
|
|
613
|
+
try:
|
|
614
|
+
return _dispatch(args, repo_root)
|
|
615
|
+
except Exception as exc: # noqa: BLE001 - deliberately the last resort
|
|
616
|
+
code = errors.error_code_for(exc)
|
|
617
|
+
if getattr(args, "json_lines", False):
|
|
618
|
+
cli_json.emit_stream_failure(code, str(exc))
|
|
619
|
+
else:
|
|
620
|
+
cli_json.emit_failure(args.command, code, str(exc))
|
|
621
|
+
return 1
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _dispatch(args, repo_root: Path) -> int:
|
|
625
|
+
if args.command == "setup":
|
|
626
|
+
return setup.run_setup(hide_token=args.hide_token)
|
|
627
|
+
|
|
628
|
+
if args.command == "doctor":
|
|
629
|
+
if args.json_output:
|
|
630
|
+
cli_json.emit(cli_json.success("doctor", report=doctor.collect_doctor_report(repo_root)))
|
|
631
|
+
return 0
|
|
632
|
+
for line in doctor.doctor_report_lines(repo_root):
|
|
633
|
+
print(line)
|
|
634
|
+
return 0
|
|
635
|
+
|
|
636
|
+
if args.command == "fix-mode":
|
|
637
|
+
return _run_fix_mode_command(args, repo_root)
|
|
638
|
+
|
|
639
|
+
if args.command == "repository-profile":
|
|
640
|
+
return _run_repository_profile_command(args, repo_root)
|
|
641
|
+
|
|
642
|
+
if args.command == "instructions":
|
|
643
|
+
return _run_instructions_command(args, repo_root)
|
|
644
|
+
|
|
645
|
+
if args.command == "project-settings":
|
|
646
|
+
return _run_project_settings_command(args, repo_root)
|
|
647
|
+
|
|
648
|
+
if args.command == "jira-site":
|
|
649
|
+
return _run_jira_site_command(args)
|
|
650
|
+
|
|
651
|
+
if args.command == "agent-check":
|
|
652
|
+
for line in copilot.agent_status_lines(repo_root):
|
|
653
|
+
print(line)
|
|
654
|
+
return 0
|
|
655
|
+
|
|
656
|
+
if args.command == "clean":
|
|
657
|
+
try:
|
|
658
|
+
result = clean_issue_artifacts(repo_root, args.issue_key, include_memory=args.include_memory)
|
|
659
|
+
except ValueError as exc:
|
|
660
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
661
|
+
return 1
|
|
662
|
+
_print_clean_result(result, include_memory=args.include_memory)
|
|
663
|
+
return 0
|
|
664
|
+
|
|
665
|
+
if args.command == "jira-validate":
|
|
666
|
+
refusal = _refuse_for_manual(repo_root, "jira-validate", args.issue_key, False)
|
|
667
|
+
if refusal is not None:
|
|
668
|
+
return refusal
|
|
669
|
+
try:
|
|
670
|
+
summary = workflow.jira_validate_step(repo_root, args.issue_key)
|
|
671
|
+
except JiraFetchError as exc:
|
|
672
|
+
_print_jira_validate_error(exc)
|
|
673
|
+
return 1
|
|
674
|
+
except Exception as exc:
|
|
675
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
676
|
+
return 1
|
|
677
|
+
_print_jira_validate_summary(args.issue_key, summary)
|
|
678
|
+
return 0
|
|
679
|
+
|
|
680
|
+
if args.command == "jira-comment-draft":
|
|
681
|
+
refusal = _refuse_without_jira_target(repo_root, "jira-comment-draft", args.issue_key, False)
|
|
682
|
+
if refusal is not None:
|
|
683
|
+
return refusal
|
|
684
|
+
try:
|
|
685
|
+
path = workflow.jira_comment_draft_step(repo_root, args.issue_key, strict=args.strict)
|
|
686
|
+
except FileNotFoundError as exc:
|
|
687
|
+
print(str(exc), file=sys.stderr)
|
|
688
|
+
return 1
|
|
689
|
+
except ValueError as exc:
|
|
690
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
691
|
+
return 1
|
|
692
|
+
print(f"Generated Jira comment draft: {path}")
|
|
693
|
+
return 0
|
|
694
|
+
|
|
695
|
+
if args.command == "jira-comment":
|
|
696
|
+
refusal = _refuse_without_jira_target(repo_root, "jira-comment", args.issue_key, False)
|
|
697
|
+
if refusal is not None:
|
|
698
|
+
return refusal
|
|
699
|
+
try:
|
|
700
|
+
result = workflow.jira_comment_step(repo_root, args.issue_key, execute=args.execute)
|
|
701
|
+
except FileNotFoundError as exc:
|
|
702
|
+
print(str(exc), file=sys.stderr)
|
|
703
|
+
return 1
|
|
704
|
+
except JiraCommentPostError as exc:
|
|
705
|
+
print(f"ERROR: {exc.message}", file=sys.stderr)
|
|
706
|
+
return 1
|
|
707
|
+
except ValueError as exc:
|
|
708
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
709
|
+
return 1
|
|
710
|
+
if args.execute:
|
|
711
|
+
print(f"Posted Jira comment for {args.issue_key}.")
|
|
712
|
+
print(f"Comment ID: {result.get('comment_id') or '(not returned)'}")
|
|
713
|
+
print(f"Generated: .ai/{args.issue_key}/jira_comment_post_result.json")
|
|
714
|
+
else:
|
|
715
|
+
print(f"Preview Jira comment for {args.issue_key}.")
|
|
716
|
+
print(f"Draft: .ai/{args.issue_key}/jira_comment_draft.md")
|
|
717
|
+
print(f"Length: {result.get('length', 0)} characters")
|
|
718
|
+
print("No Jira comment was posted. Use --execute to post exactly one Jira comment.")
|
|
719
|
+
print()
|
|
720
|
+
print(str(result.get("preview", "")))
|
|
721
|
+
return 0
|
|
722
|
+
|
|
723
|
+
if args.command == "fetch":
|
|
724
|
+
refusal = _refuse_for_manual(repo_root, "fetch", args.issue_key, args.json_output)
|
|
725
|
+
if refusal is not None:
|
|
726
|
+
return refusal
|
|
727
|
+
try:
|
|
728
|
+
result = workflow.fetch_step(repo_root, args.issue_key, allow_mock=_allow_mock(args))
|
|
729
|
+
except JiraFetchError as exc:
|
|
730
|
+
if args.json_output:
|
|
731
|
+
cli_json.emit_failure(
|
|
732
|
+
"fetch",
|
|
733
|
+
errors.code_for_jira_error_type(exc.result.error_type),
|
|
734
|
+
exc.result.error_message or str(exc),
|
|
735
|
+
work_item_id=args.issue_key,
|
|
736
|
+
)
|
|
737
|
+
return 1
|
|
738
|
+
_print_jira_error(exc)
|
|
739
|
+
return 1
|
|
740
|
+
mock_warning = (
|
|
741
|
+
f"{result.error_message} Using mock/demo Jira data." if result.source == "mock" else None
|
|
742
|
+
)
|
|
743
|
+
if args.json_output:
|
|
744
|
+
cli_json.emit(
|
|
745
|
+
cli_json.success(
|
|
746
|
+
"fetch",
|
|
747
|
+
work_item_id=args.issue_key,
|
|
748
|
+
source="jira",
|
|
749
|
+
issue_dir=f".ai/{args.issue_key}",
|
|
750
|
+
jira_source=result.source,
|
|
751
|
+
warnings=[mock_warning] if mock_warning else [],
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
return 0
|
|
755
|
+
if mock_warning:
|
|
756
|
+
print(f"WARN: {mock_warning}")
|
|
757
|
+
print(f"Fetched Jira data for {args.issue_key} into .ai/{args.issue_key}/")
|
|
758
|
+
return 0
|
|
759
|
+
|
|
760
|
+
if args.command == "issue-details":
|
|
761
|
+
# Read-only, and deliberately not a step: `fetch` writes `.ai/<issue>/`
|
|
762
|
+
# because a run needs those artifacts, and the editor's hint improver
|
|
763
|
+
# needs two strings. Creating a work item as a side effect of improving
|
|
764
|
+
# a sentence would be a surprise, so this reuses the same Jira client
|
|
765
|
+
# and the same parser and writes nothing.
|
|
766
|
+
refusal = _refuse_for_manual(repo_root, "issue-details", args.issue_key, args.json_output)
|
|
767
|
+
if refusal is not None:
|
|
768
|
+
return refusal
|
|
769
|
+
try:
|
|
770
|
+
result = fetch_issue(repo_root, args.issue_key, allow_mock=_allow_mock(args))
|
|
771
|
+
except JiraFetchError as exc:
|
|
772
|
+
if args.json_output:
|
|
773
|
+
cli_json.emit_failure(
|
|
774
|
+
"issue-details",
|
|
775
|
+
errors.code_for_jira_error_type(exc.result.error_type),
|
|
776
|
+
exc.result.error_message or str(exc),
|
|
777
|
+
work_item_id=args.issue_key,
|
|
778
|
+
)
|
|
779
|
+
return 1
|
|
780
|
+
_print_jira_error(exc)
|
|
781
|
+
return 1
|
|
782
|
+
parsed = parse_issue(result.data)
|
|
783
|
+
title = str(parsed.get("summary") or "")
|
|
784
|
+
description = str(parsed.get("description") or "")
|
|
785
|
+
if args.json_output:
|
|
786
|
+
cli_json.emit(
|
|
787
|
+
cli_json.success(
|
|
788
|
+
"issue-details",
|
|
789
|
+
work_item_id=args.issue_key,
|
|
790
|
+
title=title,
|
|
791
|
+
description=description,
|
|
792
|
+
jira_source=result.source,
|
|
793
|
+
)
|
|
794
|
+
)
|
|
795
|
+
return 0
|
|
796
|
+
print(f"{args.issue_key}: {title}")
|
|
797
|
+
if description:
|
|
798
|
+
print()
|
|
799
|
+
print(description)
|
|
800
|
+
return 0
|
|
801
|
+
|
|
802
|
+
if args.command == "parse":
|
|
803
|
+
# parse works for both sources; it is fetch/jira-validate that are Jira-only.
|
|
804
|
+
workflow.parse_step(repo_root, args.issue_key)
|
|
805
|
+
print(f"Parsed Jira data for {args.issue_key}.")
|
|
806
|
+
return 0
|
|
807
|
+
|
|
808
|
+
if args.command == "keywords":
|
|
809
|
+
# Nothing is written: the extraction is recomputed from issue.json by
|
|
810
|
+
# whichever step needs it, so printing it is what this command is for.
|
|
811
|
+
keywords = workflow.keywords_step(repo_root, args.issue_key)
|
|
812
|
+
print(f"Extracted keywords for {args.issue_key}.")
|
|
813
|
+
for label, key in (("High value", "high_value_keywords"), ("Normal", "normal_keywords"), ("Phrases", "phrase_keywords")):
|
|
814
|
+
values = keywords.get(key)
|
|
815
|
+
print(f" {label}: {', '.join(map(str, values)) if isinstance(values, list) and values else '-'}")
|
|
816
|
+
return 0
|
|
817
|
+
|
|
818
|
+
if args.command == "search":
|
|
819
|
+
workflow.code_search_step(repo_root, args.issue_key)
|
|
820
|
+
if args.json_output:
|
|
821
|
+
cli_json.emit(
|
|
822
|
+
cli_json.success(
|
|
823
|
+
"search",
|
|
824
|
+
work_item_id=args.issue_key,
|
|
825
|
+
generated_files=[f".ai/{args.issue_key}/{RETRIEVAL_ARTIFACT}"],
|
|
826
|
+
)
|
|
827
|
+
)
|
|
828
|
+
return 0
|
|
829
|
+
print(f"Generated code search for {args.issue_key}.")
|
|
830
|
+
return 0
|
|
831
|
+
|
|
832
|
+
if args.command == "git-context":
|
|
833
|
+
# Printed, not written: what Git history finds now, with the default
|
|
834
|
+
# settings, through the renderer the context uses. The prepared run's
|
|
835
|
+
# section in retrieval.json, its run.json step and its context.md are
|
|
836
|
+
# left as they are, so the panel and the agent keep reading one run.
|
|
837
|
+
print(render_git_context(workflow.git_context_step(repo_root, args.issue_key, record=False)), end="")
|
|
838
|
+
return 0
|
|
839
|
+
|
|
840
|
+
if args.command == "context":
|
|
841
|
+
# A context rebuilt on its own gathers the two results it folds in, the
|
|
842
|
+
# same way the pipeline does, rather than reading them from files.
|
|
843
|
+
similar_fixes = workflow.memory_search_step(repo_root, args.issue_key)
|
|
844
|
+
git_history = workflow.git_context_step(repo_root, args.issue_key)
|
|
845
|
+
workflow.context_step(
|
|
846
|
+
repo_root, args.issue_key, git_history=git_history, similar_fixes=similar_fixes
|
|
847
|
+
)
|
|
848
|
+
if args.json_output:
|
|
849
|
+
cli_json.emit(
|
|
850
|
+
cli_json.success(
|
|
851
|
+
"context",
|
|
852
|
+
work_item_id=args.issue_key,
|
|
853
|
+
generated_files=[f".ai/{args.issue_key}/{CONTEXT_ARTIFACT}"],
|
|
854
|
+
)
|
|
855
|
+
)
|
|
856
|
+
return 0
|
|
857
|
+
print(f"Generated context for {args.issue_key}.")
|
|
858
|
+
return 0
|
|
859
|
+
|
|
860
|
+
if args.command == "prompt":
|
|
861
|
+
try:
|
|
862
|
+
workflow.prompt_step(repo_root, args.issue_key, jira_comment=args.jira_comment)
|
|
863
|
+
except FixModeError as exc:
|
|
864
|
+
return _report_fix_mode_failure(exc)
|
|
865
|
+
except IssueArtifactError as exc:
|
|
866
|
+
return _report_issue_failure(args.issue_key, exc)
|
|
867
|
+
print(f"Generated {TASK_ARTIFACT} for {args.issue_key}.")
|
|
868
|
+
return 0
|
|
869
|
+
|
|
870
|
+
if args.command == "agent-task":
|
|
871
|
+
try:
|
|
872
|
+
workflow.copilot_task_step(repo_root, args.issue_key, jira_comment=args.jira_comment)
|
|
873
|
+
except FileNotFoundError as exc:
|
|
874
|
+
if (repo_root / ".ai" / args.issue_key / CONTEXT_ARTIFACT).exists():
|
|
875
|
+
# The context is there, so what is missing is issue.json.
|
|
876
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
877
|
+
return 1
|
|
878
|
+
print(f"Missing .ai/{args.issue_key}/{CONTEXT_ARTIFACT}.", file=sys.stderr)
|
|
879
|
+
print(f"Run: bugpilot bug {args.issue_key}", file=sys.stderr)
|
|
880
|
+
return 1
|
|
881
|
+
except FixModeError as exc:
|
|
882
|
+
return _report_fix_mode_failure(exc)
|
|
883
|
+
except IssueArtifactError as exc:
|
|
884
|
+
return _report_issue_failure(args.issue_key, exc)
|
|
885
|
+
print(f"Regenerated {TASK_ARTIFACT} for {args.issue_key}.")
|
|
886
|
+
return 0
|
|
887
|
+
|
|
888
|
+
if args.command == "agent-instructions":
|
|
889
|
+
# Printed: the team instructions are a section of task.md, not a file of
|
|
890
|
+
# their own in every work item.
|
|
891
|
+
print(workflow.copilot_instructions_step(repo_root, args.issue_key), end="")
|
|
892
|
+
return 0
|
|
893
|
+
|
|
894
|
+
if args.command == "retry-prompt":
|
|
895
|
+
try:
|
|
896
|
+
result = workflow.retry_prompt_step(repo_root, args.issue_key)
|
|
897
|
+
except FileNotFoundError as exc:
|
|
898
|
+
print(str(exc), file=sys.stderr)
|
|
899
|
+
return 1
|
|
900
|
+
except FixModeError as exc:
|
|
901
|
+
return _report_fix_mode_failure(exc)
|
|
902
|
+
print(f"Generated retry prompt: {result['prompt']}")
|
|
903
|
+
if "user_feedback" in result:
|
|
904
|
+
print(f"Generated user feedback template: {result['user_feedback']}")
|
|
905
|
+
print(f"Next manual agent instruction: Read .ai/{args.issue_key}/agent_retry_prompt.md and continue the workflow.")
|
|
906
|
+
return 0
|
|
907
|
+
|
|
908
|
+
if args.command == "check-results":
|
|
909
|
+
missing = workflow.check_results_step(repo_root, args.issue_key, strict=args.strict)
|
|
910
|
+
if args.json_output:
|
|
911
|
+
if missing and args.strict:
|
|
912
|
+
cli_json.emit_failure(
|
|
913
|
+
"check-results",
|
|
914
|
+
errors.MISSING_RESULTS,
|
|
915
|
+
f"missing {len(missing)} agent result file(s)",
|
|
916
|
+
work_item_id=args.issue_key,
|
|
917
|
+
missing=missing,
|
|
918
|
+
)
|
|
919
|
+
return 1
|
|
920
|
+
cli_json.emit(
|
|
921
|
+
cli_json.success(
|
|
922
|
+
"check-results",
|
|
923
|
+
work_item_id=args.issue_key,
|
|
924
|
+
missing=missing,
|
|
925
|
+
warnings=[f"missing {name}" for name in missing],
|
|
926
|
+
)
|
|
927
|
+
)
|
|
928
|
+
return 0
|
|
929
|
+
if missing:
|
|
930
|
+
print(f"WARN: missing {len(missing)} agent result file(s).")
|
|
931
|
+
for file_name in missing:
|
|
932
|
+
print(f" {file_name}")
|
|
933
|
+
print(f"Not fixed yet? Run: bugpilot bug {args.issue_key} --retry")
|
|
934
|
+
return 1 if args.strict else 0
|
|
935
|
+
else:
|
|
936
|
+
print(f"PASS: the fix report exists (.ai/{args.issue_key}/{FIX_REPORT_ARTIFACT}).")
|
|
937
|
+
return 0
|
|
938
|
+
|
|
939
|
+
if args.command == "manual-result":
|
|
940
|
+
try:
|
|
941
|
+
result = workflow.manual_result_step(repo_root, args.issue_key, overwrite=args.overwrite)
|
|
942
|
+
except FileNotFoundError as exc:
|
|
943
|
+
print(str(exc), file=sys.stderr)
|
|
944
|
+
return 1
|
|
945
|
+
if args.overwrite:
|
|
946
|
+
print("WARN: overwrote the fix report with the developer manual-fix template.")
|
|
947
|
+
print(f"Manual result template for {args.issue_key}:")
|
|
948
|
+
for file_name in result["created"]:
|
|
949
|
+
print(f" created: {file_name}")
|
|
950
|
+
for file_name in result["preserved"]:
|
|
951
|
+
print(f" preserved: {file_name}")
|
|
952
|
+
return 0
|
|
953
|
+
|
|
954
|
+
if args.command == "record-review":
|
|
955
|
+
return _record_review(repo_root, args)
|
|
956
|
+
|
|
957
|
+
if args.command == "record-verification":
|
|
958
|
+
return _record_verification(repo_root, args)
|
|
959
|
+
|
|
960
|
+
if args.command == "summarize-results":
|
|
961
|
+
# The gate has to cover BUGPILOT_AUTO_JIRA_COMMENT too, not just the explicit
|
|
962
|
+
# flag: an env var must not be able to post a comment for a work item that
|
|
963
|
+
# has no Jira issue behind it.
|
|
964
|
+
wants_comment = _auto_jira_comment_enabled(args)
|
|
965
|
+
if wants_comment:
|
|
966
|
+
refusal = _refuse_without_jira_target(repo_root, "summarize-results", args.issue_key, args.json_output)
|
|
967
|
+
if refusal is not None:
|
|
968
|
+
return refusal
|
|
969
|
+
overview = workflow.summarize_results_step(repo_root, args.issue_key)
|
|
970
|
+
if not args.json_output:
|
|
971
|
+
# Rendered, not written: the report is the record and this is the
|
|
972
|
+
# developer's next-steps view of it.
|
|
973
|
+
print(overview, end="")
|
|
974
|
+
if wants_comment:
|
|
975
|
+
_post_jira_comment_auto(repo_root, args.issue_key, quiet=args.json_output)
|
|
976
|
+
if args.json_output:
|
|
977
|
+
report_path = repo_root / ".ai" / args.issue_key / FIX_REPORT_ARTIFACT
|
|
978
|
+
cli_json.emit(
|
|
979
|
+
cli_json.success(
|
|
980
|
+
"summarize-results",
|
|
981
|
+
work_item_id=args.issue_key,
|
|
982
|
+
fix_report=f".ai/{args.issue_key}/{FIX_REPORT_ARTIFACT}" if report_path.exists() else None,
|
|
983
|
+
overview=overview,
|
|
984
|
+
jira_comment_requested=wants_comment,
|
|
985
|
+
)
|
|
986
|
+
)
|
|
987
|
+
return 0
|
|
988
|
+
|
|
989
|
+
if args.command == "list":
|
|
990
|
+
return _list_work_items(repo_root, args.json_output)
|
|
991
|
+
|
|
992
|
+
if args.command == "review-package":
|
|
993
|
+
if args.json_output:
|
|
994
|
+
# A query, not a step: the prompt and the validation checklist, with
|
|
995
|
+
# nothing created, marked or posted — what a panel button may run.
|
|
996
|
+
try:
|
|
997
|
+
prompt, checklist = workflow.review_package_projection(repo_root, args.issue_key)
|
|
998
|
+
except workflow.WorkItemNotFoundError as exc:
|
|
999
|
+
cli_json.emit_failure("review-package", errors.WORK_ITEM_NOT_FOUND, str(exc), work_item_id=args.issue_key)
|
|
1000
|
+
return 1
|
|
1001
|
+
except ValueError as exc:
|
|
1002
|
+
cli_json.emit_failure("review-package", errors.INVALID_INPUT, str(exc), work_item_id=args.issue_key)
|
|
1003
|
+
return 1
|
|
1004
|
+
except OSError as exc:
|
|
1005
|
+
# A report or retrieval file that vanished or cannot be read
|
|
1006
|
+
# mid-query — a Fresh run deleting it, say.
|
|
1007
|
+
cli_json.emit_failure("review-package", errors.error_code_for(exc), str(exc), work_item_id=args.issue_key)
|
|
1008
|
+
return 1
|
|
1009
|
+
# Only when asked: collecting a diff is work Copy Review Prompt
|
|
1010
|
+
# and the validation checklist do not need.
|
|
1011
|
+
changes = (
|
|
1012
|
+
{"changes": review_changes_markdown(collect_review_changes(repo_root))}
|
|
1013
|
+
if args.include_changes
|
|
1014
|
+
else {}
|
|
1015
|
+
)
|
|
1016
|
+
cli_json.emit(
|
|
1017
|
+
cli_json.success(
|
|
1018
|
+
"review-package",
|
|
1019
|
+
work_item_id=args.issue_key,
|
|
1020
|
+
prompt=prompt,
|
|
1021
|
+
validation={
|
|
1022
|
+
"steps": list(checklist.steps),
|
|
1023
|
+
"regression_files": list(checklist.regression_files),
|
|
1024
|
+
"review_risks": [line.strip() for line in checklist.review_risks],
|
|
1025
|
+
},
|
|
1026
|
+
**changes,
|
|
1027
|
+
)
|
|
1028
|
+
)
|
|
1029
|
+
return 0
|
|
1030
|
+
# Printed, not written: the developer pastes it into a reviewer, and
|
|
1031
|
+
# nothing ever read the file this used to produce.
|
|
1032
|
+
print(workflow.review_package_step(repo_root, args.issue_key), end="")
|
|
1033
|
+
if args.include_changes:
|
|
1034
|
+
print("\n" + review_changes_markdown(collect_review_changes(repo_root)), end="")
|
|
1035
|
+
return 0
|
|
1036
|
+
|
|
1037
|
+
if args.command == "delivery-check":
|
|
1038
|
+
warnings = workflow.delivery_check_step(repo_root, args.issue_key)
|
|
1039
|
+
if args.json_output:
|
|
1040
|
+
cli_json.emit(
|
|
1041
|
+
cli_json.success(
|
|
1042
|
+
"delivery-check",
|
|
1043
|
+
work_item_id=args.issue_key,
|
|
1044
|
+
ready=not warnings,
|
|
1045
|
+
warnings=warnings,
|
|
1046
|
+
)
|
|
1047
|
+
)
|
|
1048
|
+
return 0
|
|
1049
|
+
if warnings:
|
|
1050
|
+
print("WARN: delivery is not ready.")
|
|
1051
|
+
for warning in warnings:
|
|
1052
|
+
print(f" {warning}")
|
|
1053
|
+
print(f"Not fixed yet? Run: bugpilot bug {args.issue_key} --retry")
|
|
1054
|
+
else:
|
|
1055
|
+
print("PASS: ready for manual commit/push.")
|
|
1056
|
+
return 0
|
|
1057
|
+
|
|
1058
|
+
if args.command == "commit-plan":
|
|
1059
|
+
# Printed: the plan is regenerable from git state and the report, and
|
|
1060
|
+
# committing stays the developer's own action.
|
|
1061
|
+
print(workflow.commit_plan_step(repo_root, args.issue_key), end="")
|
|
1062
|
+
if not args.no_email:
|
|
1063
|
+
_notify_at_commit_gate(repo_root, args.issue_key)
|
|
1064
|
+
return 0
|
|
1065
|
+
|
|
1066
|
+
if args.command == "notify":
|
|
1067
|
+
try:
|
|
1068
|
+
result = workflow.notify_step(repo_root, args.issue_key, execute=args.execute)
|
|
1069
|
+
except EmailSendError as exc:
|
|
1070
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1071
|
+
_print_email_env_hint()
|
|
1072
|
+
return 1
|
|
1073
|
+
_print_notify_result(result)
|
|
1074
|
+
return 0
|
|
1075
|
+
|
|
1076
|
+
if args.command == "push-plan":
|
|
1077
|
+
print(workflow.push_plan_step(repo_root, args.issue_key), end="")
|
|
1078
|
+
return 0
|
|
1079
|
+
|
|
1080
|
+
if args.command in {"commit", "push"}:
|
|
1081
|
+
print("bugpilot never commits or pushes for you.")
|
|
1082
|
+
print("Use commit-plan or push-plan, review the plan, and run the commands yourself.")
|
|
1083
|
+
return 0
|
|
1084
|
+
|
|
1085
|
+
if args.command == "memory":
|
|
1086
|
+
if args.memory_command == "add":
|
|
1087
|
+
workflow.memory_add_step(repo_root, args.issue_key)
|
|
1088
|
+
print(f"Added shared memory entry for {args.issue_key}.")
|
|
1089
|
+
elif args.memory_command == "update":
|
|
1090
|
+
updated = workflow.memory_update_step(repo_root, args.issue_key)
|
|
1091
|
+
if updated:
|
|
1092
|
+
print(f"Updated shared memory entry for {args.issue_key}.")
|
|
1093
|
+
else:
|
|
1094
|
+
print(
|
|
1095
|
+
f"WARN: missing .ai/{args.issue_key}/{FIX_REPORT_ARTIFACT}. "
|
|
1096
|
+
f"The agent writes it; for a manual fix run: bugpilot manual-result {args.issue_key}"
|
|
1097
|
+
)
|
|
1098
|
+
elif args.memory_command == "search":
|
|
1099
|
+
issue_key = args.query if workflow.looks_like_issue_key(args.query) else None
|
|
1100
|
+
if issue_key:
|
|
1101
|
+
print(workflow.memory_search_step(repo_root, issue_key), end="")
|
|
1102
|
+
else:
|
|
1103
|
+
_issue_key, markdown, _results = search_memory(repo_root, args.query)
|
|
1104
|
+
print(markdown, end="")
|
|
1105
|
+
else:
|
|
1106
|
+
return 1
|
|
1107
|
+
return 0
|
|
1108
|
+
|
|
1109
|
+
if args.command == "status":
|
|
1110
|
+
if args.json_output:
|
|
1111
|
+
return _emit_status_json(repo_root, args.issue_key)
|
|
1112
|
+
return _print_status(repo_root, args.issue_key)
|
|
1113
|
+
|
|
1114
|
+
if args.command == "bug" and args.launch_agent and (args.json_output or args.json_lines):
|
|
1115
|
+
# The machine-readable modes promise one object or one stream on
|
|
1116
|
+
# stdout; an interactive agent sharing that terminal breaks both.
|
|
1117
|
+
message = "--launch-agent starts an interactive agent and cannot be combined with --json or --json-lines."
|
|
1118
|
+
_report_bug_preflight_failure(args, errors.INVALID_INPUT, message)
|
|
1119
|
+
return 1
|
|
1120
|
+
|
|
1121
|
+
if args.command == "bug" and args.retry:
|
|
1122
|
+
return _run_retry(repo_root, args)
|
|
1123
|
+
|
|
1124
|
+
if args.command == "bug":
|
|
1125
|
+
# Non-destructive by default: only an explicit --fresh deletes .ai/<id>/.
|
|
1126
|
+
fresh = args.fresh
|
|
1127
|
+
json_mode = args.json_output
|
|
1128
|
+
if args.include_memory and not fresh:
|
|
1129
|
+
message = "--include-memory deletes a memory entry, so it needs --fresh to say so."
|
|
1130
|
+
_report_bug_preflight_failure(args, errors.INVALID_INPUT, message)
|
|
1131
|
+
return 1
|
|
1132
|
+
try:
|
|
1133
|
+
request = _build_bug_request(repo_root, args)
|
|
1134
|
+
except ValueError as exc:
|
|
1135
|
+
_report_bug_preflight_failure(args, errors.INVALID_INPUT, str(exc))
|
|
1136
|
+
return 1
|
|
1137
|
+
|
|
1138
|
+
work_item_id = request.work_item_id
|
|
1139
|
+
# Whether there was anything to keep, so the summary does not claim to
|
|
1140
|
+
# have preserved a folder this run created.
|
|
1141
|
+
existed = (repo_root / ".ai" / work_item_id).is_dir()
|
|
1142
|
+
stream = None
|
|
1143
|
+
if args.json_lines:
|
|
1144
|
+
stream = cli_json.JsonLinesEmitter(work_item_id, request.spec.source)
|
|
1145
|
+
stream.skipped(request.skipped_steps())
|
|
1146
|
+
progress = stream.progress
|
|
1147
|
+
elif json_mode:
|
|
1148
|
+
progress = None
|
|
1149
|
+
else:
|
|
1150
|
+
print(f"bugpilot bug {work_item_id}")
|
|
1151
|
+
progress = _bug_progress_printer(work_item_id, request.resolved_steps(), request.spec.source)
|
|
1152
|
+
try:
|
|
1153
|
+
result = workflow.run_investigation(
|
|
1154
|
+
repo_root,
|
|
1155
|
+
request,
|
|
1156
|
+
agent_fix=args.agent_fix,
|
|
1157
|
+
fresh=fresh,
|
|
1158
|
+
include_memory=args.include_memory,
|
|
1159
|
+
allow_mock=_allow_mock(args),
|
|
1160
|
+
progress=progress,
|
|
1161
|
+
hint=args.hint,
|
|
1162
|
+
jira_comment=args.jira_comment,
|
|
1163
|
+
)
|
|
1164
|
+
except JiraFetchError as exc:
|
|
1165
|
+
if stream is not None:
|
|
1166
|
+
stream.fail(
|
|
1167
|
+
errors.code_for_jira_error_type(exc.result.error_type),
|
|
1168
|
+
exc.result.error_message or str(exc),
|
|
1169
|
+
)
|
|
1170
|
+
return 1
|
|
1171
|
+
if json_mode:
|
|
1172
|
+
cli_json.emit_failure(
|
|
1173
|
+
"bug",
|
|
1174
|
+
errors.code_for_jira_error_type(exc.result.error_type),
|
|
1175
|
+
exc.result.error_message or str(exc),
|
|
1176
|
+
work_item_id=work_item_id,
|
|
1177
|
+
)
|
|
1178
|
+
return 1
|
|
1179
|
+
print(f"[ERROR] Fetching Jira issue failed: {exc.result.error_message}", file=sys.stderr)
|
|
1180
|
+
_print_log_hint(repo_root, work_item_id)
|
|
1181
|
+
_print_jira_error(exc)
|
|
1182
|
+
return 1
|
|
1183
|
+
# Before the ValueError arm below, which FixModeError also matches: a
|
|
1184
|
+
# rejected mode is a bad argument, not a failed run, so it points at
|
|
1185
|
+
# `fix-mode list` instead of at a log that may not exist yet.
|
|
1186
|
+
except FixModeError as exc:
|
|
1187
|
+
if stream is not None:
|
|
1188
|
+
stream.fail(errors.INVALID_INPUT, str(exc))
|
|
1189
|
+
return 1
|
|
1190
|
+
if json_mode:
|
|
1191
|
+
cli_json.emit_failure("bug", errors.INVALID_INPUT, str(exc), work_item_id=work_item_id)
|
|
1192
|
+
return 1
|
|
1193
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1194
|
+
print("Run: bugpilot fix-mode list", file=sys.stderr)
|
|
1195
|
+
return 1
|
|
1196
|
+
except ValueError as exc:
|
|
1197
|
+
if stream is not None:
|
|
1198
|
+
stream.fail(errors.INVALID_INPUT, str(exc))
|
|
1199
|
+
return 1
|
|
1200
|
+
if json_mode:
|
|
1201
|
+
cli_json.emit_failure("bug", errors.INVALID_INPUT, str(exc), work_item_id=work_item_id)
|
|
1202
|
+
return 1
|
|
1203
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1204
|
+
_print_log_hint(repo_root, work_item_id)
|
|
1205
|
+
return 1
|
|
1206
|
+
|
|
1207
|
+
mock_warning = (
|
|
1208
|
+
f"{result.jira_result.error_message} Using mock/demo Jira data."
|
|
1209
|
+
if result.jira_result and result.jira_result.source == "mock"
|
|
1210
|
+
else None
|
|
1211
|
+
)
|
|
1212
|
+
# --only-issue-details skips the prompt step, so there is no task file to
|
|
1213
|
+
# point an agent at. Claiming one would send Claude at a missing path.
|
|
1214
|
+
agent_task = f".ai/{work_item_id}/{TASK_ARTIFACT}"
|
|
1215
|
+
agent_task_exists = (repo_root / ".ai" / work_item_id / TASK_ARTIFACT).exists()
|
|
1216
|
+
if stream is not None:
|
|
1217
|
+
stream.finish(result.generated_files, result.warnings)
|
|
1218
|
+
return 0
|
|
1219
|
+
if json_mode:
|
|
1220
|
+
# Re-read the issue: for a Jira item the title is only known after
|
|
1221
|
+
# the fetch, and request.spec is still the stub it started from.
|
|
1222
|
+
stored = read_issue_quietly(repo_root, work_item_id)
|
|
1223
|
+
cli_json.emit(
|
|
1224
|
+
cli_json.success(
|
|
1225
|
+
"bug",
|
|
1226
|
+
work_item_id=work_item_id,
|
|
1227
|
+
source=stored.source if stored else request.spec.source,
|
|
1228
|
+
source_ref=stored.source_ref if stored else request.spec.source_ref,
|
|
1229
|
+
title=(stored.title if stored else request.spec.title) or None,
|
|
1230
|
+
issue_dir=f".ai/{work_item_id}",
|
|
1231
|
+
generated_files=result.generated_files,
|
|
1232
|
+
skipped_steps=request.skipped_steps(),
|
|
1233
|
+
fix_mode=fix_mode_metadata(result.fix_mode) if result.fix_mode else None,
|
|
1234
|
+
agent_task=agent_task if agent_task_exists else None,
|
|
1235
|
+
warnings=([mock_warning] if mock_warning else []) + list(result.warnings),
|
|
1236
|
+
)
|
|
1237
|
+
)
|
|
1238
|
+
return 0
|
|
1239
|
+
|
|
1240
|
+
if mock_warning:
|
|
1241
|
+
print(f"WARN: {mock_warning}")
|
|
1242
|
+
for warning in result.warnings:
|
|
1243
|
+
print(f"WARN: {warning}")
|
|
1244
|
+
if not fresh and existed:
|
|
1245
|
+
print(f"Updated the existing workflow package for {work_item_id}.")
|
|
1246
|
+
print("Previous artifacts were kept (--fresh deletes them first).")
|
|
1247
|
+
_print_key_generated_artifacts(repo_root, work_item_id)
|
|
1248
|
+
print(f"Prepared bugpilot workflow package for {work_item_id}.")
|
|
1249
|
+
print(f"Artifacts: .ai/{work_item_id}")
|
|
1250
|
+
if result.fix_mode is not None:
|
|
1251
|
+
print(f"AI Fix Mode: {result.fix_mode.name} ({result.fix_mode.id})")
|
|
1252
|
+
if result.fix_mode.is_investigation:
|
|
1253
|
+
print(" Investigation only: the agent will not change source code in this pass.")
|
|
1254
|
+
# Prepare-only unless --launch-agent names an agent; --agent-fix prints
|
|
1255
|
+
# the legacy invocation guidance alongside the manual instruction.
|
|
1256
|
+
if not agent_task_exists:
|
|
1257
|
+
print(f"No {agent_task} was generated, so there is nothing to hand to an agent.")
|
|
1258
|
+
print("Re-run without --only-issue-details to build the full task package.")
|
|
1259
|
+
return 0
|
|
1260
|
+
if args.launch_agent is None:
|
|
1261
|
+
print("Next manual agent instruction:")
|
|
1262
|
+
print(f" Read {agent_task} and complete the workflow.")
|
|
1263
|
+
if args.agent_fix:
|
|
1264
|
+
for line in copilot.agent_status_lines(repo_root):
|
|
1265
|
+
print(line)
|
|
1266
|
+
for line in copilot.auto_invocation_guidance(work_item_id):
|
|
1267
|
+
print(line)
|
|
1268
|
+
return 0
|
|
1269
|
+
return _run_agent_after_prepare(repo_root, work_item_id, args.launch_agent, fix_mode=result.fix_mode)
|
|
1270
|
+
|
|
1271
|
+
return 1
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _launch_expectation_lines(fix_mode: FixMode | None, *, retry: bool) -> list[str]:
|
|
1275
|
+
"""What the developer is told the agent is about to do.
|
|
1276
|
+
|
|
1277
|
+
Three sentences, chosen by the mode's execution kind rather than its id, so a
|
|
1278
|
+
custom investigate-kind mode reads the same as the built-in one. The task
|
|
1279
|
+
file is what actually instructs the agent; this only has to agree with it —
|
|
1280
|
+
telling the developer a fix is coming while the task says "do not modify
|
|
1281
|
+
source code" was the contradiction this replaces. A retry launch has no
|
|
1282
|
+
resolved mode in hand, so it describes the loop and lets the retry prompt
|
|
1283
|
+
say the rest.
|
|
1284
|
+
"""
|
|
1285
|
+
if retry:
|
|
1286
|
+
return [
|
|
1287
|
+
"The agent will read your feedback and the previous attempt, then continue",
|
|
1288
|
+
"the workflow the retry prompt describes.",
|
|
1289
|
+
"It stops at the commit gate. Review its changes as third-party code before you commit.",
|
|
1290
|
+
]
|
|
1291
|
+
if fix_mode is not None and fix_mode.is_investigation:
|
|
1292
|
+
return [
|
|
1293
|
+
"The agent will investigate the issue, document the evidence and hypotheses,",
|
|
1294
|
+
"and propose a fix plan without changing source code.",
|
|
1295
|
+
"It stops at the investigation handoff and asks before any implementation.",
|
|
1296
|
+
]
|
|
1297
|
+
return [
|
|
1298
|
+
"The agent will analyze, implement the smallest safe fix, and write the fix report.",
|
|
1299
|
+
"It stops at the commit gate and asks before committing.",
|
|
1300
|
+
"Review its changes as third-party code before you commit.",
|
|
1301
|
+
]
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _git_history_options(args: argparse.Namespace) -> GitHistoryOptions:
|
|
1305
|
+
"""The Git History Settings a ``bug`` command line asks for.
|
|
1306
|
+
|
|
1307
|
+
A count outside 1-25 is refused rather than clamped: a typo should say so,
|
|
1308
|
+
not quietly run with a different number.
|
|
1309
|
+
"""
|
|
1310
|
+
max_commits = args.git_max_commits
|
|
1311
|
+
if max_commits is not None and not 1 <= max_commits <= MAX_RELATED_COMMITS_LIMIT:
|
|
1312
|
+
raise ValueError(f"--git-max-commits must be between 1 and {MAX_RELATED_COMMITS_LIMIT}.")
|
|
1313
|
+
defaults = GitHistoryOptions()
|
|
1314
|
+
return GitHistoryOptions(
|
|
1315
|
+
use_shared_keywords=not args.git_no_shared_keywords,
|
|
1316
|
+
use_shared_focus_files=not args.git_no_shared_focus_files,
|
|
1317
|
+
keywords=tuple(args.git_keywords),
|
|
1318
|
+
files=tuple(args.git_files),
|
|
1319
|
+
search_commit_messages=not args.git_no_commit_search,
|
|
1320
|
+
search_file_history=not args.git_no_file_history,
|
|
1321
|
+
history_depth=args.git_history_depth,
|
|
1322
|
+
max_related_commits=defaults.max_related_commits if max_commits is None else max_commits,
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _similar_fixes_options(args: argparse.Namespace) -> SimilarFixesOptions:
|
|
1327
|
+
"""The Similar Fixes Settings a ``bug`` command line asks for.
|
|
1328
|
+
|
|
1329
|
+
Like ``--git-max-commits``, a count outside 1-20 is refused rather than
|
|
1330
|
+
clamped.
|
|
1331
|
+
"""
|
|
1332
|
+
max_fixes = args.max_similar_fixes
|
|
1333
|
+
if max_fixes is not None and not 1 <= max_fixes <= MAX_SIMILAR_FIXES_LIMIT:
|
|
1334
|
+
raise ValueError(f"--max-similar-fixes must be between 1 and {MAX_SIMILAR_FIXES_LIMIT}.")
|
|
1335
|
+
return SimilarFixesOptions(
|
|
1336
|
+
use_shared_keywords=not args.similar_fixes_no_shared_keywords,
|
|
1337
|
+
keywords=tuple(args.similar_keywords),
|
|
1338
|
+
max_results=DEFAULT_MAX_SIMILAR_FIXES if max_fixes is None else max_fixes,
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _emit_status_json(repo_root: Path, issue_key: str) -> int:
|
|
1343
|
+
"""Machine-readable `status`. Missing state is a failure, not an empty result."""
|
|
1344
|
+
try:
|
|
1345
|
+
run = load_run(repo_root, issue_key)
|
|
1346
|
+
except RunArtifactError as exc:
|
|
1347
|
+
cli_json.emit_failure(
|
|
1348
|
+
"status", errors.ARTIFACT_NOT_FOUND, str(exc), work_item_id=issue_key,
|
|
1349
|
+
)
|
|
1350
|
+
return 1
|
|
1351
|
+
if run is None:
|
|
1352
|
+
cli_json.emit_failure(
|
|
1353
|
+
"status",
|
|
1354
|
+
errors.WORK_ITEM_NOT_FOUND,
|
|
1355
|
+
f"No run state found for {issue_key}. Run: bugpilot bug {issue_key}",
|
|
1356
|
+
work_item_id=issue_key,
|
|
1357
|
+
)
|
|
1358
|
+
return 1
|
|
1359
|
+
issue = read_issue_quietly(repo_root, issue_key)
|
|
1360
|
+
record = run_to_dict(run)
|
|
1361
|
+
cli_json.emit(
|
|
1362
|
+
cli_json.success(
|
|
1363
|
+
"status",
|
|
1364
|
+
work_item_id=issue_key,
|
|
1365
|
+
source=issue.source if issue else None,
|
|
1366
|
+
title=issue.title if issue else None,
|
|
1367
|
+
# The one authoritative run state: running, prepared or failed.
|
|
1368
|
+
status=run.status,
|
|
1369
|
+
steps=record["steps"],
|
|
1370
|
+
generated_files=record["generated_files"],
|
|
1371
|
+
# The AI workflow the package was prepared under. Null for a
|
|
1372
|
+
# package prepared before Fix Modes existed.
|
|
1373
|
+
fix_mode=run.fix_mode,
|
|
1374
|
+
# Where a failed run failed, when it did. Additive.
|
|
1375
|
+
error=record.get("error"),
|
|
1376
|
+
)
|
|
1377
|
+
)
|
|
1378
|
+
return 0
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _print_status(repo_root: Path, issue_key: str) -> int:
|
|
1382
|
+
try:
|
|
1383
|
+
run = load_run(repo_root, issue_key)
|
|
1384
|
+
except RunArtifactError as exc:
|
|
1385
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1386
|
+
print(f"Re-run: bugpilot bug {issue_key}", file=sys.stderr)
|
|
1387
|
+
return 1
|
|
1388
|
+
if run is None:
|
|
1389
|
+
print(f"No run state found for {issue_key}.", file=sys.stderr)
|
|
1390
|
+
print("Run:", file=sys.stderr)
|
|
1391
|
+
print(f" bugpilot bug {issue_key}", file=sys.stderr)
|
|
1392
|
+
return 1
|
|
1393
|
+
|
|
1394
|
+
record = run_to_dict(run)
|
|
1395
|
+
print(f"Work item: {run.work_item_id}")
|
|
1396
|
+
print(f"Status: {run.status}")
|
|
1397
|
+
if run.error is not None:
|
|
1398
|
+
where = f" at the {run.error.step} step" if run.error.step else ""
|
|
1399
|
+
print(f"Failed{where}: {run.error.message}")
|
|
1400
|
+
print("Steps:")
|
|
1401
|
+
for name, step_status in record["steps"].items():
|
|
1402
|
+
print(f" {name}: {step_status}")
|
|
1403
|
+
print("Generated files:")
|
|
1404
|
+
for file_name in record["generated_files"]:
|
|
1405
|
+
print(f" {file_name}")
|
|
1406
|
+
return 0
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _allow_mock(args) -> bool:
|
|
1410
|
+
return bool(getattr(args, "allow_mock", False))
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def _run_agent_after_prepare(
|
|
1414
|
+
repo_root: Path,
|
|
1415
|
+
issue_key: str,
|
|
1416
|
+
agent: str,
|
|
1417
|
+
prompt_file: str | None = None,
|
|
1418
|
+
fix_mode: FixMode | None = None,
|
|
1419
|
+
) -> int:
|
|
1420
|
+
handoff = (
|
|
1421
|
+
agent_runner.RETRY_HANDOFF_PROMPT.format(prompt_file=prompt_file) if prompt_file else None
|
|
1422
|
+
)
|
|
1423
|
+
instruction = prompt_file or f".ai/{issue_key}/{TASK_ARTIFACT}"
|
|
1424
|
+
print()
|
|
1425
|
+
print(f"Launching {agent} to complete the workflow for {issue_key}.")
|
|
1426
|
+
for line in _launch_expectation_lines(fix_mode, retry=prompt_file is not None):
|
|
1427
|
+
print(line)
|
|
1428
|
+
result = agent_runner.run_agent(repo_root, issue_key, agent, prompt=handoff)
|
|
1429
|
+
if not result.ran:
|
|
1430
|
+
print(f"WARN: could not launch {agent}: {result.skipped_reason}", file=sys.stderr)
|
|
1431
|
+
print(f"Open {agent} manually from the target repo root and run:", file=sys.stderr)
|
|
1432
|
+
print(f" Read {instruction} and complete the workflow.", file=sys.stderr)
|
|
1433
|
+
return 1
|
|
1434
|
+
if result.returncode not in (0, None):
|
|
1435
|
+
print(f"WARN: {agent} exited with code {result.returncode}.", file=sys.stderr)
|
|
1436
|
+
return result.returncode
|
|
1437
|
+
return 0
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _report_bug_preflight_failure(args, code: str, message: str) -> None:
|
|
1441
|
+
"""Report a failure that happens before the run, in whichever mode is active.
|
|
1442
|
+
|
|
1443
|
+
--json-lines needs a terminal event even here: a consumer waiting for one
|
|
1444
|
+
cannot tell an aborted run from a slow one.
|
|
1445
|
+
"""
|
|
1446
|
+
if getattr(args, "json_lines", False):
|
|
1447
|
+
cli_json.emit_stream_failure(code, message)
|
|
1448
|
+
elif getattr(args, "json_output", False):
|
|
1449
|
+
cli_json.emit_failure("bug", code, message)
|
|
1450
|
+
else:
|
|
1451
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _record_review(repo_root: Path, args) -> int:
|
|
1455
|
+
"""`bugpilot record-review <ID>`: write what a completed review said.
|
|
1456
|
+
|
|
1457
|
+
The one writer of review_report.md. It records; it does not review, verify or
|
|
1458
|
+
judge — the report has no verdict field, and nothing here reads one into it.
|
|
1459
|
+
Text comes from the four options or, for the extension, from a JSON file, so
|
|
1460
|
+
a review never has to fit on a command line.
|
|
1461
|
+
"""
|
|
1462
|
+
|
|
1463
|
+
def fail(code: str, message: str) -> int:
|
|
1464
|
+
if args.json_output:
|
|
1465
|
+
cli_json.emit_failure("record-review", code, message, work_item_id=args.issue_key)
|
|
1466
|
+
else:
|
|
1467
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1468
|
+
return 1
|
|
1469
|
+
|
|
1470
|
+
flags = {key: getattr(args, key) for key, _heading in REVIEW_SECTIONS}
|
|
1471
|
+
if args.from_file is not None:
|
|
1472
|
+
if any(value is not None for value in flags.values()):
|
|
1473
|
+
return fail(errors.INVALID_INPUT, "Give the review either as --from-file or as text options, not both.")
|
|
1474
|
+
try:
|
|
1475
|
+
review = _review_from_file(Path(args.from_file))
|
|
1476
|
+
except (OSError, ValueError) as exc:
|
|
1477
|
+
return fail(errors.INVALID_INPUT, str(exc))
|
|
1478
|
+
else:
|
|
1479
|
+
review = ReviewInput(**{key: value or "" for key, value in flags.items()})
|
|
1480
|
+
|
|
1481
|
+
try:
|
|
1482
|
+
recorded = record_review(repo_root, args.issue_key, review, replace=args.replace)
|
|
1483
|
+
except WorkItemNotFoundError as exc:
|
|
1484
|
+
return fail(errors.WORK_ITEM_NOT_FOUND, str(exc))
|
|
1485
|
+
except ReviewReportExistsError as exc:
|
|
1486
|
+
return fail(errors.ARTIFACT_EXISTS, str(exc))
|
|
1487
|
+
except ValueError as exc:
|
|
1488
|
+
return fail(errors.INVALID_INPUT, str(exc))
|
|
1489
|
+
except OSError as exc:
|
|
1490
|
+
# The folder removed or unwritable mid-write — a clean or a Fresh run.
|
|
1491
|
+
return fail(errors.error_code_for(exc), f"The review result could not be written: {exc}")
|
|
1492
|
+
|
|
1493
|
+
relative = f".ai/{args.issue_key}/{recorded.path.name}"
|
|
1494
|
+
if args.json_output:
|
|
1495
|
+
cli_json.emit(
|
|
1496
|
+
cli_json.success(
|
|
1497
|
+
"record-review",
|
|
1498
|
+
work_item_id=args.issue_key,
|
|
1499
|
+
review_report=relative,
|
|
1500
|
+
replaced=recorded.replaced,
|
|
1501
|
+
)
|
|
1502
|
+
)
|
|
1503
|
+
return 0
|
|
1504
|
+
print(f"{'Replaced' if recorded.replaced else 'Recorded'} the review result: {relative}")
|
|
1505
|
+
print("This records what the review said. It does not verify the fix or confirm that tests passed.")
|
|
1506
|
+
return 0
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def _review_from_file(path: Path) -> ReviewInput:
|
|
1510
|
+
"""A review from a JSON object: the four sections, each a string or null."""
|
|
1511
|
+
# Bounded before it is read: four capped sections, generously escaped.
|
|
1512
|
+
if path.stat().st_size > 8 * len(REVIEW_SECTIONS) * MAX_SECTION_CHARS:
|
|
1513
|
+
raise ValueError("--from-file is far larger than any review record-review accepts.")
|
|
1514
|
+
try:
|
|
1515
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1516
|
+
except json.JSONDecodeError as exc:
|
|
1517
|
+
raise ValueError(f"--from-file is not valid JSON: {exc.msg}.") from exc
|
|
1518
|
+
if not isinstance(data, dict):
|
|
1519
|
+
raise ValueError("--from-file must hold one JSON object.")
|
|
1520
|
+
allowed = {key for key, _heading in REVIEW_SECTIONS}
|
|
1521
|
+
unknown = sorted(set(data) - allowed)
|
|
1522
|
+
if unknown:
|
|
1523
|
+
raise ValueError(f"--from-file has fields record-review does not know: {', '.join(unknown)}.")
|
|
1524
|
+
for key, value in data.items():
|
|
1525
|
+
if value is not None and not isinstance(value, str):
|
|
1526
|
+
raise ValueError(f"--from-file field {key} must be text.")
|
|
1527
|
+
return ReviewInput(**{key: data.get(key) or "" for key in allowed})
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _record_verification(repo_root: Path, args) -> int:
|
|
1531
|
+
"""`bugpilot record-verification <ID> --from-file <json>`: write recorded evidence.
|
|
1532
|
+
|
|
1533
|
+
The one writer of verification_report.md. It records the checks the developer
|
|
1534
|
+
gathered and the status they gave each; it runs no command and observes no
|
|
1535
|
+
result, so every status in the report is the user's. The checks come as a JSON
|
|
1536
|
+
file — transport only, never stored — so evidence never has to fit on a
|
|
1537
|
+
command line or pass through a shell.
|
|
1538
|
+
"""
|
|
1539
|
+
|
|
1540
|
+
def fail(code: str, message: str) -> int:
|
|
1541
|
+
if args.json_output:
|
|
1542
|
+
cli_json.emit_failure("record-verification", code, message, work_item_id=args.issue_key)
|
|
1543
|
+
else:
|
|
1544
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1545
|
+
return 1
|
|
1546
|
+
|
|
1547
|
+
if args.from_file is None:
|
|
1548
|
+
return fail(errors.INVALID_INPUT, "Give the checks as --from-file <json>.")
|
|
1549
|
+
try:
|
|
1550
|
+
checks = _checks_from_file(Path(args.from_file))
|
|
1551
|
+
except (OSError, ValueError) as exc:
|
|
1552
|
+
return fail(errors.INVALID_INPUT, str(exc))
|
|
1553
|
+
except RecursionError:
|
|
1554
|
+
return fail(errors.INVALID_INPUT, "--from-file is nested too deeply to be the checks record-verification takes.")
|
|
1555
|
+
|
|
1556
|
+
try:
|
|
1557
|
+
recorded = record_verification(repo_root, args.issue_key, checks, replace=args.replace)
|
|
1558
|
+
except WorkItemNotFoundError as exc:
|
|
1559
|
+
return fail(errors.WORK_ITEM_NOT_FOUND, str(exc))
|
|
1560
|
+
except VerificationReportExistsError as exc:
|
|
1561
|
+
return fail(errors.ARTIFACT_EXISTS, str(exc))
|
|
1562
|
+
except ValueError as exc:
|
|
1563
|
+
return fail(errors.INVALID_INPUT, str(exc))
|
|
1564
|
+
except OSError as exc:
|
|
1565
|
+
# The folder removed or unwritable mid-write — a clean or a Fresh run.
|
|
1566
|
+
return fail(errors.error_code_for(exc), f"The verification evidence could not be written: {exc}")
|
|
1567
|
+
|
|
1568
|
+
counts = {status: sum(check.status == status for check in checks) for status in ("passed", "failed", "not_run")}
|
|
1569
|
+
relative = f".ai/{args.issue_key}/{recorded.path.name}"
|
|
1570
|
+
if args.json_output:
|
|
1571
|
+
cli_json.emit(
|
|
1572
|
+
cli_json.success(
|
|
1573
|
+
"record-verification",
|
|
1574
|
+
work_item_id=args.issue_key,
|
|
1575
|
+
verification_report=relative,
|
|
1576
|
+
replaced=recorded.replaced,
|
|
1577
|
+
checks=counts,
|
|
1578
|
+
)
|
|
1579
|
+
)
|
|
1580
|
+
return 0
|
|
1581
|
+
print(f"{'Replaced' if recorded.replaced else 'Recorded'} the verification evidence: {relative}")
|
|
1582
|
+
print(summary_line(counts["passed"], counts["failed"], counts["not_run"]))
|
|
1583
|
+
print("BugPilot ran none of these checks: each status is the one you recorded.")
|
|
1584
|
+
return 0
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
_CHECK_FIELDS = ("name", "status", "type", "procedure", "evidence", "notes")
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def _checks_from_file(path: Path) -> list[VerificationCheck]:
|
|
1591
|
+
"""The checks from ``{"checks": [...]}``: name and status required, the rest optional."""
|
|
1592
|
+
# Bounded before it is read: capped checks of capped fields, generously escaped.
|
|
1593
|
+
if path.stat().st_size > 8 * MAX_CHECKS * (MAX_NAME_CHARS + 3 * MAX_TEXT_CHARS + 200):
|
|
1594
|
+
raise ValueError("--from-file is far larger than any evidence record-verification accepts.")
|
|
1595
|
+
try:
|
|
1596
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1597
|
+
except json.JSONDecodeError as exc:
|
|
1598
|
+
raise ValueError(f"--from-file is not valid JSON: {exc.msg}.") from exc
|
|
1599
|
+
if not isinstance(data, dict) or set(data) != {"checks"} or not isinstance(data["checks"], list):
|
|
1600
|
+
raise ValueError('--from-file must hold one JSON object, {"checks": [...]}.')
|
|
1601
|
+
checks: list[VerificationCheck] = []
|
|
1602
|
+
for number, item in enumerate(data["checks"], start=1):
|
|
1603
|
+
if not isinstance(item, dict):
|
|
1604
|
+
raise ValueError(f"Check {number} must be a JSON object.")
|
|
1605
|
+
unknown = sorted(set(item) - set(_CHECK_FIELDS))
|
|
1606
|
+
if unknown:
|
|
1607
|
+
raise ValueError(f"Check {number} has fields record-verification does not know: {', '.join(unknown)}.")
|
|
1608
|
+
for key in ("name", "status"):
|
|
1609
|
+
if not isinstance(item.get(key), str):
|
|
1610
|
+
raise ValueError(f"Check {number}: {key} is required and must be text.")
|
|
1611
|
+
for key in ("type", "procedure", "evidence", "notes"):
|
|
1612
|
+
if item.get(key) is not None and not isinstance(item[key], str):
|
|
1613
|
+
raise ValueError(f"Check {number}: {key} must be text.")
|
|
1614
|
+
checks.append(
|
|
1615
|
+
VerificationCheck(
|
|
1616
|
+
name=item["name"],
|
|
1617
|
+
status=item["status"],
|
|
1618
|
+
type=item.get("type") or "other",
|
|
1619
|
+
procedure=item.get("procedure") or "",
|
|
1620
|
+
evidence=item.get("evidence") or "",
|
|
1621
|
+
notes=item.get("notes") or "",
|
|
1622
|
+
)
|
|
1623
|
+
)
|
|
1624
|
+
return checks
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def _run_retry(repo_root: Path, args) -> int:
|
|
1628
|
+
"""`bugpilot bug <ID> --retry`: the three manual steps of a second attempt, as one.
|
|
1629
|
+
|
|
1630
|
+
Previously this meant running retry-prompt, editing user_feedback.md, then
|
|
1631
|
+
pasting the handoff into an agent. The retry loop is deliberately
|
|
1632
|
+
human-triggered — its input is the developer's feedback, not the model's own
|
|
1633
|
+
judgement (design 5.6).
|
|
1634
|
+
"""
|
|
1635
|
+
if not args.issue_key:
|
|
1636
|
+
message = "--retry needs the work item to retry, e.g. bugpilot bug JR-12345 --retry."
|
|
1637
|
+
if args.json_output:
|
|
1638
|
+
cli_json.emit_failure("bug", errors.INVALID_INPUT, message)
|
|
1639
|
+
else:
|
|
1640
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1641
|
+
return 1
|
|
1642
|
+
try:
|
|
1643
|
+
generated = workflow.retry_prompt_step(repo_root, args.issue_key)
|
|
1644
|
+
except FileNotFoundError as exc:
|
|
1645
|
+
if args.json_output:
|
|
1646
|
+
cli_json.emit_failure("bug", errors.WORK_ITEM_NOT_FOUND, str(exc), work_item_id=args.issue_key)
|
|
1647
|
+
else:
|
|
1648
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1649
|
+
return 1
|
|
1650
|
+
|
|
1651
|
+
feedback = f".ai/{args.issue_key}/user_feedback.md"
|
|
1652
|
+
prompt = f".ai/{args.issue_key}/agent_retry_prompt.md"
|
|
1653
|
+
if args.json_output:
|
|
1654
|
+
cli_json.emit(
|
|
1655
|
+
cli_json.success(
|
|
1656
|
+
"bug",
|
|
1657
|
+
work_item_id=args.issue_key,
|
|
1658
|
+
retry=True,
|
|
1659
|
+
feedback_file=feedback,
|
|
1660
|
+
retry_prompt=prompt,
|
|
1661
|
+
feedback_created="user_feedback" in generated,
|
|
1662
|
+
)
|
|
1663
|
+
)
|
|
1664
|
+
return 0
|
|
1665
|
+
|
|
1666
|
+
print(f"Generated {prompt}")
|
|
1667
|
+
if "user_feedback" in generated:
|
|
1668
|
+
# The template was just created, so it holds placeholders rather than the
|
|
1669
|
+
# developer's account of what went wrong. Retrying now would hand the agent
|
|
1670
|
+
# an empty correction — the one input this loop exists to carry.
|
|
1671
|
+
print(f"Created {feedback} — describe what the previous attempt got wrong,")
|
|
1672
|
+
print(f"then run: bugpilot bug {args.issue_key} --retry")
|
|
1673
|
+
return 0
|
|
1674
|
+
print(f"Using your existing {feedback}.")
|
|
1675
|
+
if args.launch_agent is None:
|
|
1676
|
+
print("Next manual agent instruction:")
|
|
1677
|
+
print(f" Read {prompt} and continue the workflow.")
|
|
1678
|
+
return 0
|
|
1679
|
+
return _run_agent_after_prepare(repo_root, args.issue_key, args.launch_agent, prompt_file=prompt)
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def _refuse_for_manual(repo_root: Path, command: str, work_item_id: str, json_output: bool) -> int | None:
|
|
1683
|
+
"""Refuse a Jira-only command for a hand-written work item (design 5.1).
|
|
1684
|
+
|
|
1685
|
+
Returns an exit code to return, or ``None`` to carry on. Without this the
|
|
1686
|
+
command would fail later with a confusing artifact error instead of saying
|
|
1687
|
+
the operation does not apply.
|
|
1688
|
+
"""
|
|
1689
|
+
issue = read_issue_quietly(repo_root, work_item_id)
|
|
1690
|
+
if issue is None or issue.source != SOURCE_MANUAL:
|
|
1691
|
+
return None
|
|
1692
|
+
message = (
|
|
1693
|
+
f"{command} only applies to a Jira work item; {work_item_id} was described by hand."
|
|
1694
|
+
)
|
|
1695
|
+
if json_output:
|
|
1696
|
+
cli_json.emit_failure(command, errors.JIRA_ONLY_COMMAND, message, work_item_id=work_item_id)
|
|
1697
|
+
else:
|
|
1698
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1699
|
+
return 1
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def _refuse_without_jira_target(repo_root: Path, command: str, work_item_id: str, json_output: bool) -> int | None:
|
|
1703
|
+
"""Refuse a Jira write when there is no issue to write back to (design 5.1)."""
|
|
1704
|
+
issue = read_issue_quietly(repo_root, work_item_id)
|
|
1705
|
+
if issue is None or issue.can_write_back:
|
|
1706
|
+
return None
|
|
1707
|
+
message = (
|
|
1708
|
+
f"{work_item_id} has no Jira issue to comment on; it was described by hand."
|
|
1709
|
+
)
|
|
1710
|
+
if json_output:
|
|
1711
|
+
cli_json.emit_failure(command, errors.NO_JIRA_TARGET, message, work_item_id=work_item_id)
|
|
1712
|
+
else:
|
|
1713
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1714
|
+
return 1
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _list_work_items(repo_root: Path, json_output: bool) -> int:
|
|
1718
|
+
"""List prepared work items. Unreadable entries degrade, they do not fail.
|
|
1719
|
+
|
|
1720
|
+
A local id carries no readable slug on purpose (design 3.4), so this command
|
|
1721
|
+
is where the title becomes visible in a terminal.
|
|
1722
|
+
"""
|
|
1723
|
+
ai_root = repo_root / ".ai"
|
|
1724
|
+
entries: list[dict[str, object]] = []
|
|
1725
|
+
# Only folders named like a work item. Anything else under .ai/ is not one
|
|
1726
|
+
# this tool made, and listing it would hand its name to every consumer of
|
|
1727
|
+
# this list — the extension's History, and from there a handoff's command
|
|
1728
|
+
# line (§37.70). Skipped, not touched: nothing here deletes or renames.
|
|
1729
|
+
directories = (
|
|
1730
|
+
sorted(path for path in ai_root.iterdir() if path.is_dir() and is_work_item_id(path.name))
|
|
1731
|
+
if ai_root.is_dir()
|
|
1732
|
+
else []
|
|
1733
|
+
)
|
|
1734
|
+
for path in directories:
|
|
1735
|
+
issue = read_issue_quietly(repo_root, path.name)
|
|
1736
|
+
prepared = (path / RUN_ARTIFACT).exists()
|
|
1737
|
+
entries.append(
|
|
1738
|
+
{
|
|
1739
|
+
"work_item_id": path.name,
|
|
1740
|
+
"source": issue.source if issue else None,
|
|
1741
|
+
"title": issue.title if issue else None,
|
|
1742
|
+
"prepared": prepared,
|
|
1743
|
+
}
|
|
1744
|
+
)
|
|
1745
|
+
if json_output:
|
|
1746
|
+
cli_json.emit(cli_json.success("list", work_items=entries))
|
|
1747
|
+
return 0
|
|
1748
|
+
if not entries:
|
|
1749
|
+
print("No work items found under .ai/.")
|
|
1750
|
+
return 0
|
|
1751
|
+
width = max(len(str(entry["work_item_id"])) for entry in entries)
|
|
1752
|
+
for entry in entries:
|
|
1753
|
+
source = entry["source"] or "-"
|
|
1754
|
+
title = entry["title"] or ""
|
|
1755
|
+
state = "prepared" if entry["prepared"] else "incomplete"
|
|
1756
|
+
print(f"{str(entry['work_item_id']).ljust(width)} {source:<7} {state:<10} {title}")
|
|
1757
|
+
return 0
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def _report_fix_mode_failure(exc: FixModeError) -> int:
|
|
1761
|
+
"""A regeneration path whose stored selection no longer works.
|
|
1762
|
+
|
|
1763
|
+
It says what to do next rather than falling back to Standard Fix: quietly
|
|
1764
|
+
regenerating a Conservative package as Standard is exactly the substitution
|
|
1765
|
+
the persisted selection exists to prevent.
|
|
1766
|
+
"""
|
|
1767
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1768
|
+
print("Run: bugpilot fix-mode list", file=sys.stderr)
|
|
1769
|
+
return 1
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _report_issue_failure(issue_key: str, exc: IssueArtifactError) -> int:
|
|
1773
|
+
"""A regeneration path whose issue.json cannot be used.
|
|
1774
|
+
|
|
1775
|
+
There is no older layout to fall back to, so the answer is to re-prepare.
|
|
1776
|
+
"""
|
|
1777
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1778
|
+
print(f"Run: bugpilot bug {issue_key}", file=sys.stderr)
|
|
1779
|
+
return 1
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
def _run_repository_profile_command(args, repo_root: Path) -> int:
|
|
1783
|
+
"""`bugpilot repository-profile [show|set]`: the profile every entry point reads.
|
|
1784
|
+
|
|
1785
|
+
`show` is read-only. `set` writes `.bugpilot/repository_profile.json`, which
|
|
1786
|
+
is repository configuration a team may commit, like `.bugpilot/fix_modes/`.
|
|
1787
|
+
The VS Code extension reads and writes the profile through this command, so
|
|
1788
|
+
the validation and Auto-detect live in one place.
|
|
1789
|
+
"""
|
|
1790
|
+
|
|
1791
|
+
def fail(message: str) -> int:
|
|
1792
|
+
if args.json_output:
|
|
1793
|
+
cli_json.emit_failure("repository-profile", errors.INVALID_INPUT, message)
|
|
1794
|
+
else:
|
|
1795
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1796
|
+
return 1
|
|
1797
|
+
|
|
1798
|
+
if args.action == "show" and (args.mode or args.from_file):
|
|
1799
|
+
return fail("--mode and --from-file are for `bugpilot repository-profile set`.")
|
|
1800
|
+
if args.action == "set":
|
|
1801
|
+
try:
|
|
1802
|
+
if args.from_file:
|
|
1803
|
+
path = Path(args.from_file)
|
|
1804
|
+
if path.stat().st_size > 64 * 1024:
|
|
1805
|
+
raise ValueError("--from-file is far larger than any repository profile.")
|
|
1806
|
+
try:
|
|
1807
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1808
|
+
except json.JSONDecodeError as exc:
|
|
1809
|
+
raise ValueError(f"--from-file is not valid JSON: {exc.msg}.") from exc
|
|
1810
|
+
profile = profile_from_payload(payload)
|
|
1811
|
+
elif args.mode:
|
|
1812
|
+
current, _warnings = load_repository_profile(repo_root)
|
|
1813
|
+
profile = RepositoryProfile(mode=args.mode, custom=current.custom)
|
|
1814
|
+
else:
|
|
1815
|
+
return fail("`bugpilot repository-profile set` needs --mode MODE or --from-file PATH.")
|
|
1816
|
+
save_repository_profile(repo_root, profile)
|
|
1817
|
+
except (OSError, ValueError) as exc:
|
|
1818
|
+
return fail(str(exc))
|
|
1819
|
+
|
|
1820
|
+
profile, warnings = load_repository_profile(repo_root)
|
|
1821
|
+
detected = detect_repository(repo_root)
|
|
1822
|
+
effective = resolve_repository_context(repo_root)
|
|
1823
|
+
saved = profile_path(repo_root).is_file()
|
|
1824
|
+
if args.json_output:
|
|
1825
|
+
cli_json.emit(
|
|
1826
|
+
cli_json.success(
|
|
1827
|
+
"repository-profile",
|
|
1828
|
+
profile={"mode": profile.mode, "custom": profile.custom.to_dict()},
|
|
1829
|
+
path=f"{PROJECT_CONFIG_DIR}/{PROFILE_FILE_NAME}",
|
|
1830
|
+
saved=saved,
|
|
1831
|
+
detected={"facts": detected.facts.to_dict(), "guidance_files": list(detected.guidance_files)},
|
|
1832
|
+
effective={
|
|
1833
|
+
"mode": effective.mode,
|
|
1834
|
+
"facts": effective.facts.to_dict(),
|
|
1835
|
+
"guidance_files": list(effective.guidance_files),
|
|
1836
|
+
},
|
|
1837
|
+
warnings=warnings,
|
|
1838
|
+
)
|
|
1839
|
+
)
|
|
1840
|
+
return 0
|
|
1841
|
+
|
|
1842
|
+
if args.action == "set":
|
|
1843
|
+
print(f"Saved the repository profile: {PROJECT_CONFIG_DIR}/{PROFILE_FILE_NAME}")
|
|
1844
|
+
origin = f"{PROJECT_CONFIG_DIR}/{PROFILE_FILE_NAME}" if saved else "the default; nothing saved"
|
|
1845
|
+
print(f"Repository profile: {PROFILE_MODE_LABELS[profile.mode]} ({origin})")
|
|
1846
|
+
for warning in warnings:
|
|
1847
|
+
print(f"WARN: {warning}")
|
|
1848
|
+
print("Auto-detect finds:")
|
|
1849
|
+
for name, value in detected.facts.items() or [("Nothing", "no language, framework or build system with confidence")]:
|
|
1850
|
+
print(f" {name}: {value}")
|
|
1851
|
+
if detected.guidance_files:
|
|
1852
|
+
print(f" Repository guidance: {', '.join(detected.guidance_files)}")
|
|
1853
|
+
custom = profile.custom.items()
|
|
1854
|
+
print("Custom details:" if custom else "Custom details: none saved")
|
|
1855
|
+
for name, value in custom:
|
|
1856
|
+
print(f" {name}: {value}")
|
|
1857
|
+
print()
|
|
1858
|
+
print("Change it with: bugpilot repository-profile set --mode auto|generic|custom")
|
|
1859
|
+
print(f"Custom details: bugpilot repository-profile set --from-file <json> (fields: {', '.join(key for key, _l, _m in PROFILE_FIELDS)})")
|
|
1860
|
+
return 0
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def _jira_site_state() -> dict[str, object]:
|
|
1864
|
+
"""Where the Jira site comes from, and the site if it is one Jira requests would use.
|
|
1865
|
+
|
|
1866
|
+
The environment wins over the config file, as in ``load_config``. A value
|
|
1867
|
+
that does not validate is never echoed: it may carry a password.
|
|
1868
|
+
"""
|
|
1869
|
+
from bugpilot.core.user_config import load_user_config
|
|
1870
|
+
|
|
1871
|
+
env = (os.getenv("JIRA_BASE_URL") or "").strip()
|
|
1872
|
+
raw, source = (env, "environment") if env else ((load_user_config().jira_base_url or "").strip(), "user configuration")
|
|
1873
|
+
if not raw:
|
|
1874
|
+
return {"site": None, "source": None}
|
|
1875
|
+
try:
|
|
1876
|
+
return {"site": normalize_jira_site(raw), "source": source}
|
|
1877
|
+
except JiraSiteError as exc:
|
|
1878
|
+
return {"site": None, "source": source, "problem": str(exc)}
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
def _run_jira_site_command(args) -> int:
|
|
1882
|
+
"""`bugpilot jira-site [show|set]`: the one Jira site the CLI and the extension share.
|
|
1883
|
+
|
|
1884
|
+
`set` validates with the same function every Jira request uses and writes
|
|
1885
|
+
only the site into `~/.bugpilot/config.toml`, keeping the email and token
|
|
1886
|
+
there. The extension's Jira Setup sends the site on standard input, so a
|
|
1887
|
+
mistyped one with a password in it never reaches a command line.
|
|
1888
|
+
"""
|
|
1889
|
+
from bugpilot.core.user_config import save_jira_site
|
|
1890
|
+
|
|
1891
|
+
def fail(message: str) -> int:
|
|
1892
|
+
if args.json_output:
|
|
1893
|
+
cli_json.emit_failure("jira-site", errors.INVALID_INPUT, message)
|
|
1894
|
+
else:
|
|
1895
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1896
|
+
return 1
|
|
1897
|
+
|
|
1898
|
+
if args.action == "show" and (args.url or args.stdin):
|
|
1899
|
+
return fail("A site is for `bugpilot jira-site set`.")
|
|
1900
|
+
warnings: list[str] = []
|
|
1901
|
+
if args.action == "set":
|
|
1902
|
+
if args.stdin and args.url:
|
|
1903
|
+
return fail("Give the site on the command line or with --stdin, not both.")
|
|
1904
|
+
raw = sys.stdin.read(4096) if args.stdin else (args.url or "")
|
|
1905
|
+
try:
|
|
1906
|
+
site = normalize_jira_site(raw.strip())
|
|
1907
|
+
except JiraSiteError as exc:
|
|
1908
|
+
return fail(f"{exc} Example: https://your-company.atlassian.net")
|
|
1909
|
+
save_jira_site(site)
|
|
1910
|
+
if (os.getenv("JIRA_BASE_URL") or "").strip():
|
|
1911
|
+
warnings.append("JIRA_BASE_URL is set in the environment, and BugPilot uses it instead of the saved site.")
|
|
1912
|
+
state = _jira_site_state()
|
|
1913
|
+
if args.json_output:
|
|
1914
|
+
cli_json.emit(cli_json.success("jira-site", **state, warnings=warnings))
|
|
1915
|
+
return 0
|
|
1916
|
+
if args.action == "set":
|
|
1917
|
+
print("Saved the Jira site in ~/.bugpilot/config.toml.")
|
|
1918
|
+
for warning in warnings:
|
|
1919
|
+
print(f"WARN: {warning}")
|
|
1920
|
+
if state.get("site"):
|
|
1921
|
+
print(f"Jira site: {state['site']} (from {state['source']})")
|
|
1922
|
+
elif state.get("problem"):
|
|
1923
|
+
print(f"Jira site: not usable ({state['problem']}) — from {state['source']}")
|
|
1924
|
+
else:
|
|
1925
|
+
print("Jira site: not configured. Set it with: bugpilot jira-site set https://your-company.atlassian.net")
|
|
1926
|
+
return 0
|
|
1927
|
+
|
|
1928
|
+
|
|
1929
|
+
def _run_project_settings_command(args, repo_root: Path) -> int:
|
|
1930
|
+
"""`bugpilot project-settings [show|set]`: the Verification Policy and branch naming every entry point reads.
|
|
1931
|
+
|
|
1932
|
+
`show` is read-only. `set` writes `.bugpilot/project_settings.json` through
|
|
1933
|
+
the same checks a run reads it with (no links; a branch template that could
|
|
1934
|
+
produce an unsafe ref is refused). The VS Code extension reads and writes
|
|
1935
|
+
through this command, so the validation lives in one place.
|
|
1936
|
+
"""
|
|
1937
|
+
|
|
1938
|
+
def fail(message: str) -> int:
|
|
1939
|
+
if args.json_output:
|
|
1940
|
+
cli_json.emit_failure("project-settings", errors.INVALID_INPUT, message)
|
|
1941
|
+
else:
|
|
1942
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
1943
|
+
return 1
|
|
1944
|
+
|
|
1945
|
+
if args.action == "show" and args.from_file:
|
|
1946
|
+
return fail("--from-file is for `bugpilot project-settings set`.")
|
|
1947
|
+
if args.action == "set":
|
|
1948
|
+
if not args.from_file:
|
|
1949
|
+
return fail("`bugpilot project-settings set` needs --from-file PATH.")
|
|
1950
|
+
try:
|
|
1951
|
+
path = Path(args.from_file)
|
|
1952
|
+
if path.stat().st_size > 64 * 1024:
|
|
1953
|
+
raise ValueError("--from-file is far larger than any project settings.")
|
|
1954
|
+
try:
|
|
1955
|
+
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
1956
|
+
except json.JSONDecodeError as exc:
|
|
1957
|
+
raise ValueError(f"--from-file is not valid JSON: {exc.msg}.") from exc
|
|
1958
|
+
except RecursionError as exc:
|
|
1959
|
+
raise ValueError("--from-file is nested too deeply to be project settings.") from exc
|
|
1960
|
+
settings, _warnings = settings_from_mapping(payload, strict=True)
|
|
1961
|
+
save_project_settings(repo_root, settings)
|
|
1962
|
+
except (OSError, ValueError) as exc:
|
|
1963
|
+
return fail(str(exc) if isinstance(exc, ValueError) else f"--from-file could not be read: {exc.strerror or exc}.")
|
|
1964
|
+
|
|
1965
|
+
settings, warnings = load_project_settings(repo_root)
|
|
1966
|
+
saved = settings_path(repo_root).is_file()
|
|
1967
|
+
if args.json_output:
|
|
1968
|
+
cli_json.emit(
|
|
1969
|
+
cli_json.success("project-settings", **project_settings_payload(settings, saved=saved, warnings=warnings))
|
|
1970
|
+
)
|
|
1971
|
+
return 0
|
|
1972
|
+
|
|
1973
|
+
if args.action == "set":
|
|
1974
|
+
print(f"Saved the project settings: {SETTINGS_SHOWN_PATH}")
|
|
1975
|
+
origin = SETTINGS_SHOWN_PATH if saved else "the defaults; nothing saved"
|
|
1976
|
+
print(f"Project settings ({origin}):")
|
|
1977
|
+
for warning in warnings:
|
|
1978
|
+
print(f"WARN: {warning}")
|
|
1979
|
+
for key, value in settings.verification.to_dict().items():
|
|
1980
|
+
print(f" verification.{key}: {str(value).lower()}")
|
|
1981
|
+
template = settings.branch_template or f"(default) {DEFAULT_BRANCH_TEMPLATE_LABEL}"
|
|
1982
|
+
print(f" branch_naming.template: {template}")
|
|
1983
|
+
print()
|
|
1984
|
+
print("Change them with: bugpilot project-settings set --from-file <json>")
|
|
1985
|
+
return 0
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
def _run_instructions_command(args, repo_root: Path) -> int:
|
|
1989
|
+
"""`bugpilot instructions [show|set]`: the two instruction files every entry point reads.
|
|
1990
|
+
|
|
1991
|
+
`show` is read-only. `set` writes one scope's file — or removes it, for empty
|
|
1992
|
+
text — through the same checks a run reads it with (no links, the size
|
|
1993
|
+
limit). The VS Code extension reads and writes through this command, sending
|
|
1994
|
+
the text on standard input, never on the command line or in a temporary file.
|
|
1995
|
+
"""
|
|
1996
|
+
|
|
1997
|
+
def fail(message: str) -> int:
|
|
1998
|
+
if args.json_output:
|
|
1999
|
+
cli_json.emit_failure("instructions", errors.INVALID_INPUT, message)
|
|
2000
|
+
else:
|
|
2001
|
+
print(f"ERROR: {message}", file=sys.stderr)
|
|
2002
|
+
return 1
|
|
2003
|
+
|
|
2004
|
+
if args.action == "show" and (args.from_file or args.stdin or args.clear):
|
|
2005
|
+
return fail("--from-file, --stdin and --clear are for `bugpilot instructions set`.")
|
|
2006
|
+
saved: InstructionFile | None = None
|
|
2007
|
+
if args.action == "set":
|
|
2008
|
+
if args.scope is None:
|
|
2009
|
+
return fail("`bugpilot instructions set` needs --scope user or --scope project.")
|
|
2010
|
+
try:
|
|
2011
|
+
if args.clear:
|
|
2012
|
+
text = ""
|
|
2013
|
+
elif args.stdin:
|
|
2014
|
+
text = _read_instruction_text(sys.stdin.buffer)
|
|
2015
|
+
elif args.from_file:
|
|
2016
|
+
with open(args.from_file, "rb") as source:
|
|
2017
|
+
text = _read_instruction_text(source)
|
|
2018
|
+
else:
|
|
2019
|
+
return fail("`bugpilot instructions set` needs --from-file PATH, --stdin or --clear.")
|
|
2020
|
+
saved = save_instructions(args.scope, repo_root, text)
|
|
2021
|
+
except (OSError, ValueError) as exc:
|
|
2022
|
+
return fail(str(exc) if isinstance(exc, ValueError) else f"The text could not be read: {exc.strerror or exc}.")
|
|
2023
|
+
|
|
2024
|
+
loaded = load_instructions(repo_root)
|
|
2025
|
+
files = [file for file in loaded.files if args.scope in (None, file.scope)]
|
|
2026
|
+
if args.json_output:
|
|
2027
|
+
cli_json.emit(
|
|
2028
|
+
cli_json.success(
|
|
2029
|
+
"instructions",
|
|
2030
|
+
**instructions_payload(loaded, include_text=True),
|
|
2031
|
+
warnings=list(loaded.warnings),
|
|
2032
|
+
)
|
|
2033
|
+
)
|
|
2034
|
+
return 0
|
|
2035
|
+
|
|
2036
|
+
if saved is not None:
|
|
2037
|
+
verb = "Saved" if saved.configured else "Removed"
|
|
2038
|
+
print(f"{verb} {saved.scope} instructions: {display_path(saved.scope)}")
|
|
2039
|
+
for file in files:
|
|
2040
|
+
label = "User Instructions" if file.scope == "user" else "Project / Team Instructions"
|
|
2041
|
+
state = (
|
|
2042
|
+
f"{file.characters:,} characters"
|
|
2043
|
+
if file.configured
|
|
2044
|
+
else f"not included: {file.problem}"
|
|
2045
|
+
if file.problem
|
|
2046
|
+
else "none configured"
|
|
2047
|
+
)
|
|
2048
|
+
print(f"{label} ({display_path(file.scope)}): {state}")
|
|
2049
|
+
if file.configured and saved is None:
|
|
2050
|
+
print("---")
|
|
2051
|
+
print(file.text)
|
|
2052
|
+
print("---")
|
|
2053
|
+
return 0
|
|
2054
|
+
|
|
2055
|
+
|
|
2056
|
+
def _read_instruction_text(stream) -> str:
|
|
2057
|
+
"""At most a little past the limit, decoded strictly: the save refuses anything longer."""
|
|
2058
|
+
data = stream.read(MAX_INSTRUCTION_CHARS * 4 + 4)
|
|
2059
|
+
if stream.read(1):
|
|
2060
|
+
raise ValueError(f"The text is longer than the {MAX_INSTRUCTION_CHARS:,} characters BugPilot includes.")
|
|
2061
|
+
try:
|
|
2062
|
+
return data.decode("utf-8-sig")
|
|
2063
|
+
except UnicodeDecodeError as exc:
|
|
2064
|
+
raise ValueError("The text is not UTF-8.") from exc
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
def _run_fix_mode_command(args, repo_root: Path) -> int:
|
|
2068
|
+
"""`bugpilot fix-mode …`: read the catalog, or change a custom mode in it.
|
|
2069
|
+
|
|
2070
|
+
`repo_root` is threaded in rather than taken from the process directory,
|
|
2071
|
+
because a project's modes belong to the repository being worked on — which
|
|
2072
|
+
is not always the directory a command was typed in, and is never the one a
|
|
2073
|
+
test runs from.
|
|
2074
|
+
"""
|
|
2075
|
+
store = FixModeStore(repo_root)
|
|
2076
|
+
try:
|
|
2077
|
+
catalog = store.load_catalog()
|
|
2078
|
+
except FixModeError as exc:
|
|
2079
|
+
return _fix_mode_error(args, exc)
|
|
2080
|
+
handlers = {
|
|
2081
|
+
"list": _fix_mode_list,
|
|
2082
|
+
"show": _fix_mode_show,
|
|
2083
|
+
"duplicate": _fix_mode_duplicate,
|
|
2084
|
+
"create": _fix_mode_create,
|
|
2085
|
+
"update": _fix_mode_update,
|
|
2086
|
+
"delete": _fix_mode_delete,
|
|
2087
|
+
}
|
|
2088
|
+
try:
|
|
2089
|
+
return handlers[args.action](args, store, catalog)
|
|
2090
|
+
except FixModeError as exc:
|
|
2091
|
+
return _fix_mode_error(args, exc)
|
|
2092
|
+
except OSError as exc:
|
|
2093
|
+
# A read-only checkout, a permission denied, a directory that vanished.
|
|
2094
|
+
# A user's environment problem, not a BugPilot defect, so it reads as a
|
|
2095
|
+
# message rather than a traceback.
|
|
2096
|
+
return _fix_mode_error(args, FixModeError(f"Fix Mode storage is not writable: {exc}"))
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def _fix_mode_error(args, exc: FixModeError) -> int:
|
|
2100
|
+
if getattr(args, "json_output", False):
|
|
2101
|
+
cli_json.emit_failure("fix-mode", errors.INVALID_INPUT, str(exc))
|
|
2102
|
+
return 1
|
|
2103
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
2104
|
+
return 1
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
def _fix_mode_summary(mode: FixMode) -> dict[str, object]:
|
|
2108
|
+
"""One mode as a picker needs it: metadata plus the line that describes it."""
|
|
2109
|
+
return {**fix_mode_metadata(mode), "description": mode.description}
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def _fix_mode_definition(mode: FixMode) -> dict[str, object]:
|
|
2113
|
+
"""The whole mode, including the six sections an editor edits."""
|
|
2114
|
+
definition = _fix_mode_summary(mode)
|
|
2115
|
+
definition.update({name: text for name, text in mode.instruction_sections()})
|
|
2116
|
+
return definition
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
def _fix_mode_issues(catalog: FixModeCatalog) -> list[dict[str, object]]:
|
|
2120
|
+
return [
|
|
2121
|
+
{"scope": issue.scope, "path": issue.path, "message": issue.message}
|
|
2122
|
+
for issue in catalog.issues
|
|
2123
|
+
]
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def _fix_mode_list(args, store: FixModeStore, catalog: FixModeCatalog) -> int:
|
|
2127
|
+
"""The effective list, or every physical definition with `--all-scopes`.
|
|
2128
|
+
|
|
2129
|
+
Two different questions. The selector asks which definition an id runs, and
|
|
2130
|
+
gets one mode per id. Management asks what exists on disk, and has to see
|
|
2131
|
+
both `user/my-safe` and `project/my-safe` — resolving that through the
|
|
2132
|
+
effective registry would make the shadowed one unaddressable.
|
|
2133
|
+
"""
|
|
2134
|
+
if args.mode_id:
|
|
2135
|
+
# `fix-mode list standard` used to print the whole table and drop the
|
|
2136
|
+
# id. Accepting an argument and ignoring it is the CLI equivalent of a
|
|
2137
|
+
# silent fallback, so it is refused with the command that was meant.
|
|
2138
|
+
raise FixModeError(
|
|
2139
|
+
f"bugpilot fix-mode list takes no mode id. To see one mode, run: "
|
|
2140
|
+
f"bugpilot fix-mode show {args.mode_id}"
|
|
2141
|
+
)
|
|
2142
|
+
effective = catalog.effective_modes()
|
|
2143
|
+
if args.json_output:
|
|
2144
|
+
payload: dict[str, object] = {
|
|
2145
|
+
"default_mode_id": catalog.effective_registry().default.id,
|
|
2146
|
+
"issues": _fix_mode_issues(catalog),
|
|
2147
|
+
}
|
|
2148
|
+
if args.all_scopes:
|
|
2149
|
+
for scope in ("builtin", "user", "project"):
|
|
2150
|
+
payload[scope] = [
|
|
2151
|
+
{
|
|
2152
|
+
**_fix_mode_summary(mode),
|
|
2153
|
+
"scope": scope,
|
|
2154
|
+
"effective": catalog.is_effective(mode),
|
|
2155
|
+
}
|
|
2156
|
+
for mode in catalog.scoped(scope)
|
|
2157
|
+
]
|
|
2158
|
+
else:
|
|
2159
|
+
payload["modes"] = [_fix_mode_summary(mode) for mode in effective]
|
|
2160
|
+
cli_json.emit(cli_json.success("fix-mode", **payload))
|
|
2161
|
+
return 0
|
|
2162
|
+
|
|
2163
|
+
rows = (
|
|
2164
|
+
[(scope, mode) for scope, mode in scoped_modes(catalog)]
|
|
2165
|
+
if args.all_scopes
|
|
2166
|
+
else [(mode.source, mode) for mode in effective]
|
|
2167
|
+
)
|
|
2168
|
+
width = max(len(mode.id) for _, mode in rows)
|
|
2169
|
+
name_width = max(len(mode.name) for _, mode in rows)
|
|
2170
|
+
print(f"{'ID'.ljust(width)} {'Name'.ljust(name_width)} Kind Source")
|
|
2171
|
+
for scope, mode in rows:
|
|
2172
|
+
note = "" if not args.all_scopes or catalog.is_effective(mode) else " (overridden)"
|
|
2173
|
+
print(
|
|
2174
|
+
f"{mode.id.ljust(width)} {mode.name.ljust(name_width)} "
|
|
2175
|
+
f"{mode.execution_kind.ljust(12)} {scope}{note}"
|
|
2176
|
+
)
|
|
2177
|
+
for issue in catalog.issues:
|
|
2178
|
+
print(f"WARN: {issue.scope} Fix Mode {issue.path}: {issue.message}", file=sys.stderr)
|
|
2179
|
+
print()
|
|
2180
|
+
print("Select one with: bugpilot bug <work item> --fix-mode <id>")
|
|
2181
|
+
return 0
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
def _fix_mode_show(args, store: FixModeStore, catalog: FixModeCatalog) -> int:
|
|
2185
|
+
mode_id = _require_mode_id(args, "show")
|
|
2186
|
+
mode = (
|
|
2187
|
+
store.read(args.scope, mode_id)
|
|
2188
|
+
if args.scope
|
|
2189
|
+
else catalog.effective_registry().resolve(mode_id)
|
|
2190
|
+
)
|
|
2191
|
+
if args.json_output:
|
|
2192
|
+
cli_json.emit(cli_json.success("fix-mode", mode=_fix_mode_definition(mode)))
|
|
2193
|
+
return 0
|
|
2194
|
+
for line in _fix_mode_show_lines(mode):
|
|
2195
|
+
print(line)
|
|
2196
|
+
return 0
|
|
2197
|
+
|
|
2198
|
+
|
|
2199
|
+
def _fix_mode_duplicate(args, store: FixModeStore, catalog: FixModeCatalog) -> int:
|
|
2200
|
+
"""Copy any mode into a writable scope. The way a custom mode starts."""
|
|
2201
|
+
source_id = _require_mode_id(args, "duplicate")
|
|
2202
|
+
if not args.new_id:
|
|
2203
|
+
raise FixModeError("bugpilot fix-mode duplicate needs a new id for the copy.")
|
|
2204
|
+
mode = store.duplicate(
|
|
2205
|
+
source_id,
|
|
2206
|
+
args.new_id,
|
|
2207
|
+
_require_write_scope(args),
|
|
2208
|
+
name=args.name,
|
|
2209
|
+
registry=catalog.effective_registry(),
|
|
2210
|
+
)
|
|
2211
|
+
return _fix_mode_written(args, mode, f"Duplicated {source_id} as {mode.id} ({mode.source}).")
|
|
2212
|
+
|
|
2213
|
+
|
|
2214
|
+
def _fix_mode_create(args, store: FixModeStore, catalog: FixModeCatalog) -> int:
|
|
2215
|
+
mode_id = _require_mode_id(args, "create")
|
|
2216
|
+
mode = store.create(_require_write_scope(args), mode_id, _fix_mode_payload(args))
|
|
2217
|
+
return _fix_mode_written(args, mode, f"Created {mode.id} ({mode.source}).")
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
def _fix_mode_update(args, store: FixModeStore, catalog: FixModeCatalog) -> int:
|
|
2221
|
+
mode_id = _require_mode_id(args, "update")
|
|
2222
|
+
scope = _require_write_scope(args)
|
|
2223
|
+
# The concurrency guard is checked before the payload is even read: a save
|
|
2224
|
+
# that cannot be safe is not worth loading a file for, and "which version did
|
|
2225
|
+
# you last see" is the more useful thing to be told first.
|
|
2226
|
+
expected = _require_expected_version(args)
|
|
2227
|
+
mode = store.update(scope, mode_id, _fix_mode_payload(args), expected)
|
|
2228
|
+
return _fix_mode_written(args, mode, f"Updated {mode.id} to version {mode.version}.")
|
|
2229
|
+
|
|
2230
|
+
|
|
2231
|
+
def _fix_mode_delete(args, store: FixModeStore, catalog: FixModeCatalog) -> int:
|
|
2232
|
+
mode_id = _require_mode_id(args, "delete")
|
|
2233
|
+
mode = store.delete(
|
|
2234
|
+
_require_write_scope(args), mode_id, _require_expected_version(args)
|
|
2235
|
+
)
|
|
2236
|
+
if args.json_output:
|
|
2237
|
+
cli_json.emit(cli_json.success("fix-mode", deleted=_fix_mode_summary(mode)))
|
|
2238
|
+
return 0
|
|
2239
|
+
print(f"Deleted {mode.id} ({mode.source}).")
|
|
2240
|
+
print("Prepared work items that recorded it will ask for another mode before regenerating.")
|
|
2241
|
+
return 0
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
|
+
def _fix_mode_written(args, mode: FixMode, message: str) -> int:
|
|
2245
|
+
if args.json_output:
|
|
2246
|
+
cli_json.emit(cli_json.success("fix-mode", mode=_fix_mode_definition(mode)))
|
|
2247
|
+
return 0
|
|
2248
|
+
print(message)
|
|
2249
|
+
return 0
|
|
2250
|
+
|
|
2251
|
+
|
|
2252
|
+
def _require_mode_id(args, action: str) -> str:
|
|
2253
|
+
if not args.mode_id:
|
|
2254
|
+
raise FixModeError(f"bugpilot fix-mode {action} needs a mode id.")
|
|
2255
|
+
return args.mode_id
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
def _require_write_scope(args) -> str:
|
|
2259
|
+
"""Which directory a mutation writes to, always stated rather than guessed.
|
|
2260
|
+
|
|
2261
|
+
There is no default: `user` and `project` mean different things to a team,
|
|
2262
|
+
and choosing one silently would put a personal workflow in a repository or a
|
|
2263
|
+
team's in one developer's home directory.
|
|
2264
|
+
"""
|
|
2265
|
+
if not args.scope:
|
|
2266
|
+
raise FixModeError("This command needs --scope user or --scope project.")
|
|
2267
|
+
return args.scope
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def _require_expected_version(args) -> int:
|
|
2271
|
+
if args.expected_version is None:
|
|
2272
|
+
raise FixModeError(
|
|
2273
|
+
"This command needs --expected-version, the version you last saw. It is "
|
|
2274
|
+
"what stops one save from overwriting another."
|
|
2275
|
+
)
|
|
2276
|
+
return args.expected_version
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
def _fix_mode_payload(args) -> object:
|
|
2280
|
+
"""The definition, read from a file rather than the command line.
|
|
2281
|
+
|
|
2282
|
+
Six multiline sections do not belong in argv: a command line has a length
|
|
2283
|
+
limit, and quoting rules that differ per shell. A file has neither problem,
|
|
2284
|
+
and keeps the mode's text data rather than something a shell might read.
|
|
2285
|
+
"""
|
|
2286
|
+
if not args.from_file:
|
|
2287
|
+
raise FixModeError("This command needs --from-file <json> with the mode definition.")
|
|
2288
|
+
path = Path(args.from_file)
|
|
2289
|
+
try:
|
|
2290
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
2291
|
+
except json.JSONDecodeError as exc:
|
|
2292
|
+
raise FixModeError(f"{path} is not valid JSON: {exc}.") from exc
|
|
2293
|
+
except OSError as exc:
|
|
2294
|
+
raise FixModeError(f"{path} could not be read: {exc}.") from exc
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
def _fix_mode_show_lines(mode: FixMode) -> list[str]:
|
|
2298
|
+
"""One mode in full, as a person reads it rather than as Python spells it."""
|
|
2299
|
+
lines = [
|
|
2300
|
+
f"{mode.name} ({mode.id})",
|
|
2301
|
+
"",
|
|
2302
|
+
mode.description,
|
|
2303
|
+
"",
|
|
2304
|
+
f"Version: {mode.version}",
|
|
2305
|
+
f"Source: {mode.source}",
|
|
2306
|
+
f"Execution kind: {mode.execution_kind}",
|
|
2307
|
+
]
|
|
2308
|
+
if mode.based_on:
|
|
2309
|
+
lines.append(f"Based on: {mode.based_on}")
|
|
2310
|
+
if mode.based_on_version is not None:
|
|
2311
|
+
lines.append(f"Based on version: {mode.based_on_version}")
|
|
2312
|
+
if mode.is_investigation:
|
|
2313
|
+
lines += [
|
|
2314
|
+
"",
|
|
2315
|
+
"This mode does not change source code in its first pass: it produces a",
|
|
2316
|
+
"diagnosis and a proposed fix plan, then asks before implementing.",
|
|
2317
|
+
]
|
|
2318
|
+
headings = {
|
|
2319
|
+
"objective": "Objective",
|
|
2320
|
+
"investigation": "Investigation",
|
|
2321
|
+
"implementation": "Implementation",
|
|
2322
|
+
"verification": "Verification",
|
|
2323
|
+
"constraints": "Constraints",
|
|
2324
|
+
"completion": "Completion Requirements",
|
|
2325
|
+
}
|
|
2326
|
+
for name, text in mode.instruction_sections():
|
|
2327
|
+
lines += ["", headings.get(name, name.title()), "", f" {text.strip()}"]
|
|
2328
|
+
return lines
|
|
2329
|
+
|
|
2330
|
+
|
|
2331
|
+
def _build_bug_request(repo_root: Path, args) -> InvestigationRequest:
|
|
2332
|
+
"""Turn `bugpilot bug` arguments into an InvestigationRequest.
|
|
2333
|
+
|
|
2334
|
+
Exactly one input is required. Checked here rather than with an argparse
|
|
2335
|
+
mutually-exclusive group because the Jira side is a positional, and argparse
|
|
2336
|
+
words that combination badly.
|
|
2337
|
+
"""
|
|
2338
|
+
description = args.description
|
|
2339
|
+
if args.description_file:
|
|
2340
|
+
path = Path(args.description_file)
|
|
2341
|
+
try:
|
|
2342
|
+
description = path.read_text(encoding="utf-8")
|
|
2343
|
+
except OSError as exc:
|
|
2344
|
+
raise ValueError(f"Could not read --description-file {path}: {exc}") from exc
|
|
2345
|
+
|
|
2346
|
+
if args.issue_key and description:
|
|
2347
|
+
raise ValueError("Give either an issue key or a description, not both.")
|
|
2348
|
+
if not args.issue_key and not description:
|
|
2349
|
+
raise ValueError(
|
|
2350
|
+
"Nothing to investigate. Pass a Jira issue key (bugpilot bug JR-12345) "
|
|
2351
|
+
"or describe the bug (bugpilot bug --description \"...\")."
|
|
2352
|
+
)
|
|
2353
|
+
|
|
2354
|
+
# Paired by position, so a count that does not match would describe the
|
|
2355
|
+
# wrong file: refused rather than guessed at.
|
|
2356
|
+
if args.attachment_descriptions and len(args.attachment_descriptions) != len(args.attachments):
|
|
2357
|
+
raise ValueError(
|
|
2358
|
+
"Give one --attach-description per --attach, in the same order "
|
|
2359
|
+
"(an empty one for a file without a description), or none at all."
|
|
2360
|
+
)
|
|
2361
|
+
options = InvestigationOptions(
|
|
2362
|
+
hint=args.hint,
|
|
2363
|
+
keywords=list(args.keywords),
|
|
2364
|
+
focus_files=list(args.focus_files),
|
|
2365
|
+
ignore_paths=list(args.ignore_paths),
|
|
2366
|
+
attachments=list(args.attachments),
|
|
2367
|
+
attachment_descriptions=list(args.attachment_descriptions),
|
|
2368
|
+
replace_attachments=bool(args.replace_attachments),
|
|
2369
|
+
)
|
|
2370
|
+
# A zero empties the results and a negative slices from the end; both look
|
|
2371
|
+
# like a broken search rather than a rejected argument.
|
|
2372
|
+
if args.max_files is not None:
|
|
2373
|
+
if args.max_files < 1:
|
|
2374
|
+
raise ValueError("--max-files must be at least 1.")
|
|
2375
|
+
options.max_files = args.max_files
|
|
2376
|
+
if args.max_search_lines is not None:
|
|
2377
|
+
if args.max_search_lines < 1:
|
|
2378
|
+
raise ValueError("--max-search-lines must be at least 1.")
|
|
2379
|
+
options.max_search_lines = args.max_search_lines
|
|
2380
|
+
options.git_history = _git_history_options(args)
|
|
2381
|
+
options.similar_fixes = _similar_fixes_options(args)
|
|
2382
|
+
|
|
2383
|
+
plan = InvestigationPlan(
|
|
2384
|
+
code_search=not (args.skip_code_search or args.only_issue_details),
|
|
2385
|
+
git_history=not (args.skip_git_history or args.only_issue_details),
|
|
2386
|
+
similar_fixes=not (args.skip_similar_fixes or args.only_issue_details),
|
|
2387
|
+
build_context=not args.only_issue_details,
|
|
2388
|
+
)
|
|
2389
|
+
|
|
2390
|
+
if description and getattr(args, "jira_comment", False):
|
|
2391
|
+
raise ValueError(
|
|
2392
|
+
"--jira-comment asks the agent to post to Jira, which a hand-written bug has no target for."
|
|
2393
|
+
)
|
|
2394
|
+
|
|
2395
|
+
# The id only: core resolves it, once per run, so the CLI never becomes a
|
|
2396
|
+
# second place that knows which modes exist or which scope wins.
|
|
2397
|
+
fix_mode_id = getattr(args, "fix_mode", None)
|
|
2398
|
+
# Likewise the branch policy: argparse has checked it is one of the three.
|
|
2399
|
+
branch_policy = getattr(args, "branch_policy", None)
|
|
2400
|
+
# And the Repository Profile override, for this run only.
|
|
2401
|
+
repository_profile = getattr(args, "repository_profile", None)
|
|
2402
|
+
|
|
2403
|
+
if args.issue_key:
|
|
2404
|
+
request = workflow.jira_request(args.issue_key, options)
|
|
2405
|
+
request.plan = plan
|
|
2406
|
+
request.fix_mode_id = fix_mode_id
|
|
2407
|
+
request.branch_policy = branch_policy
|
|
2408
|
+
request.repository_profile = repository_profile
|
|
2409
|
+
return request
|
|
2410
|
+
# repo_root makes the local id collision-safe: ids have one-second
|
|
2411
|
+
# granularity and a fresh run would wipe a same-second neighbour.
|
|
2412
|
+
spec = bug_spec_from_description(description, title=args.title, repo_root=repo_root)
|
|
2413
|
+
return InvestigationRequest(
|
|
2414
|
+
spec=spec,
|
|
2415
|
+
options=options,
|
|
2416
|
+
plan=plan,
|
|
2417
|
+
fix_mode_id=fix_mode_id,
|
|
2418
|
+
branch_policy=branch_policy,
|
|
2419
|
+
repository_profile=repository_profile,
|
|
2420
|
+
)
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
def _bug_progress_printer(issue_key: str, resolved_steps: list[str] | None = None, source: str = "jira"):
|
|
2424
|
+
"""Progress lines numbered over the steps this run will actually take.
|
|
2425
|
+
|
|
2426
|
+
A partial plan or a manual bug runs fewer steps, and counting to a total that
|
|
2427
|
+
never arrives reads like the run stalled. With the default Jira plan the
|
|
2428
|
+
numbering is unchanged.
|
|
2429
|
+
"""
|
|
2430
|
+
labels = {
|
|
2431
|
+
"doctor": "Checking environment...",
|
|
2432
|
+
"fetch": f"Fetching Jira issue {issue_key}...",
|
|
2433
|
+
"parse": "Parsing Jira details..." if source == "jira" else "Parsing bug details...",
|
|
2434
|
+
"keywords": "Extracting keywords...",
|
|
2435
|
+
"memory_search": "Searching memory...",
|
|
2436
|
+
"code_search": "Searching codebase...",
|
|
2437
|
+
"git_context": "Collecting git context...",
|
|
2438
|
+
"context": "Building bug context...",
|
|
2439
|
+
"prompt": "Generating agent task package...",
|
|
2440
|
+
}
|
|
2441
|
+
running = [step for step in labels if resolved_steps is None or step in resolved_steps]
|
|
2442
|
+
total = len(running)
|
|
2443
|
+
messages = {
|
|
2444
|
+
step: f"[{index}/{total}] {labels[step]}" for index, step in enumerate(running, start=1)
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
def print_progress(event: str) -> None:
|
|
2448
|
+
if event == "clean_start":
|
|
2449
|
+
print(f"Cleaning previous workflow artifacts for {issue_key}...")
|
|
2450
|
+
elif event == "clean_done":
|
|
2451
|
+
print(f"Cleaned previous workflow artifacts for {issue_key}.")
|
|
2452
|
+
elif event == "clean_none":
|
|
2453
|
+
print(f"No previous workflow artifacts found for {issue_key}.")
|
|
2454
|
+
elif event in messages:
|
|
2455
|
+
print(messages[event])
|
|
2456
|
+
|
|
2457
|
+
return print_progress
|
|
2458
|
+
|
|
2459
|
+
|
|
2460
|
+
def _print_key_generated_artifacts(repo_root: Path, issue_key: str) -> None:
|
|
2461
|
+
key_files = list(CORE_ARTIFACTS)
|
|
2462
|
+
existing = [f".ai/{issue_key}/{file_name}" for file_name in key_files if (repo_root / ".ai" / issue_key / file_name).exists()]
|
|
2463
|
+
if not existing:
|
|
2464
|
+
return
|
|
2465
|
+
print("Generated:")
|
|
2466
|
+
for file_name in existing:
|
|
2467
|
+
print(f" {file_name}")
|
|
2468
|
+
|
|
2469
|
+
|
|
2470
|
+
def _print_log_hint(repo_root: Path, issue_key: str) -> None:
|
|
2471
|
+
if (repo_root / ".ai" / issue_key / RUN_ARTIFACT).exists():
|
|
2472
|
+
print(f"Run: bugpilot status {issue_key} for step status.", file=sys.stderr)
|
|
2473
|
+
|
|
2474
|
+
|
|
2475
|
+
def _auto_jira_comment_enabled(args) -> bool:
|
|
2476
|
+
if getattr(args, "no_jira_comment", False):
|
|
2477
|
+
return False
|
|
2478
|
+
if getattr(args, "jira_comment", False):
|
|
2479
|
+
return True
|
|
2480
|
+
return os.getenv("BUGPILOT_AUTO_JIRA_COMMENT", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
2481
|
+
|
|
2482
|
+
|
|
2483
|
+
def _post_jira_comment_auto(repo_root: Path, issue_key: str, quiet: bool = False) -> None:
|
|
2484
|
+
"""Post the analysis summary as a Jira comment (best-effort, non-fatal).
|
|
2485
|
+
|
|
2486
|
+
Runs right after the fix results are summarized so Jira notifies watchers by
|
|
2487
|
+
email before the developer decides whether to commit. A failure here never
|
|
2488
|
+
fails summarize-results.
|
|
2489
|
+
"""
|
|
2490
|
+
try:
|
|
2491
|
+
workflow.jira_comment_draft_step(repo_root, issue_key, strict=False)
|
|
2492
|
+
result = workflow.jira_comment_step(repo_root, issue_key, execute=True)
|
|
2493
|
+
except (JiraCommentPostError, JiraFetchError) as exc:
|
|
2494
|
+
message = getattr(exc, "message", None) or getattr(getattr(exc, "result", None), "error_message", None) or str(exc)
|
|
2495
|
+
print(f"WARN: auto Jira comment not posted: {message}", file=sys.stderr)
|
|
2496
|
+
print("Post manually when ready: bugpilot jira-comment-draft "
|
|
2497
|
+
f"{issue_key} then bugpilot jira-comment {issue_key} --execute", file=sys.stderr)
|
|
2498
|
+
return
|
|
2499
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
2500
|
+
print(f"WARN: auto Jira comment not posted: {exc}", file=sys.stderr)
|
|
2501
|
+
return
|
|
2502
|
+
if not quiet:
|
|
2503
|
+
print(f"Posted Jira comment for {issue_key}. Jira will notify watchers by email.")
|
|
2504
|
+
print(f" Comment ID: {result.get('comment_id') or '(not returned)'}")
|
|
2505
|
+
|
|
2506
|
+
|
|
2507
|
+
def _notify_at_commit_gate(repo_root: Path, issue_key: str) -> None:
|
|
2508
|
+
"""Send the notification email at the commit decision point (best-effort)."""
|
|
2509
|
+
try:
|
|
2510
|
+
result = workflow.notify_step(repo_root, issue_key, execute=True)
|
|
2511
|
+
except EmailSendError as exc:
|
|
2512
|
+
print(f"WARN: commit-gate email not sent: {exc}", file=sys.stderr)
|
|
2513
|
+
_print_email_env_hint()
|
|
2514
|
+
return
|
|
2515
|
+
_print_notify_result(result)
|
|
2516
|
+
|
|
2517
|
+
|
|
2518
|
+
def _print_notify_result(result: dict) -> None:
|
|
2519
|
+
issue_key = result.get("issue_key")
|
|
2520
|
+
if result.get("draft_path"):
|
|
2521
|
+
print(f"Email draft: .ai/{issue_key}/email_draft.md")
|
|
2522
|
+
if result.get("eml_path"):
|
|
2523
|
+
print(f"Outlook-ready file: .ai/{issue_key}/notification.eml")
|
|
2524
|
+
if not result.get("execute"):
|
|
2525
|
+
print("Preview only. No email was sent.")
|
|
2526
|
+
print(f" To send automatically: bugpilot notify {issue_key} --execute (needs SMTP or Graph configured)")
|
|
2527
|
+
print(f" To send via Outlook: .\\scripts\\send-via-outlook.ps1 {issue_key}")
|
|
2528
|
+
return
|
|
2529
|
+
if result.get("sent"):
|
|
2530
|
+
recipients = result.get("recipients") or ()
|
|
2531
|
+
print(f"Sent notification email via {result.get('transport', 'smtp')} to: {', '.join(recipients)}")
|
|
2532
|
+
else:
|
|
2533
|
+
print(f"WARN: email not sent. {result.get('skipped_reason', '')}".rstrip())
|
|
2534
|
+
_print_email_env_hint()
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
def _print_email_env_hint() -> None:
|
|
2538
|
+
print("To enable automatic email, configure one transport:", file=sys.stderr)
|
|
2539
|
+
print(
|
|
2540
|
+
" Graph (recommended for Microsoft 365): GRAPH_TENANT_ID, GRAPH_CLIENT_ID, "
|
|
2541
|
+
"GRAPH_CLIENT_SECRET, BUGPILOT_EMAIL_FROM, BUGPILOT_EMAIL_TO.",
|
|
2542
|
+
file=sys.stderr,
|
|
2543
|
+
)
|
|
2544
|
+
print(
|
|
2545
|
+
" SMTP: SMTP_HOST, BUGPILOT_EMAIL_FROM, BUGPILOT_EMAIL_TO "
|
|
2546
|
+
"(and SMTP_USERNAME/SMTP_PASSWORD if the relay needs auth).",
|
|
2547
|
+
file=sys.stderr,
|
|
2548
|
+
)
|
|
2549
|
+
print("Store secrets in a secrets manager; do not hardcode them.", file=sys.stderr)
|
|
2550
|
+
print("No transport configured? Send via Outlook: .\\scripts\\send-via-outlook.ps1 <ISSUE>", file=sys.stderr)
|
|
2551
|
+
|
|
2552
|
+
|
|
2553
|
+
def _print_jira_error(exc: JiraFetchError) -> None:
|
|
2554
|
+
print(f"ERROR: {exc.result.error_message}", file=sys.stderr)
|
|
2555
|
+
print("Mock fallback is disabled by default.", file=sys.stderr)
|
|
2556
|
+
print("Use --allow-mock only for demo/testing fallback.", file=sys.stderr)
|
|
2557
|
+
print("No mock Jira artifacts were generated.", file=sys.stderr)
|
|
2558
|
+
|
|
2559
|
+
|
|
2560
|
+
def _print_jira_validate_error(exc: JiraFetchError) -> None:
|
|
2561
|
+
print(f"ERROR: {exc.result.error_message}", file=sys.stderr)
|
|
2562
|
+
print("jira-validate requires real Jira credentials (JIRA_BASE_URL, JIRA_EMAIL, JIRA_TOKEN).", file=sys.stderr)
|
|
2563
|
+
print("No Jira artifacts were generated.", file=sys.stderr)
|
|
2564
|
+
|
|
2565
|
+
|
|
2566
|
+
def _print_jira_validate_summary(issue_key: str, summary: dict) -> None:
|
|
2567
|
+
print(f"Jira validation: {issue_key}")
|
|
2568
|
+
print(f" source: {summary.get('source', '')}")
|
|
2569
|
+
print(f" issue type: {summary.get('issue_type', '') or '(not specified)'}")
|
|
2570
|
+
print(f" status: {summary.get('status', '') or '(not specified)'}")
|
|
2571
|
+
print(f" priority: {summary.get('priority', '') or '(not specified)'}")
|
|
2572
|
+
print(f" comments: {summary.get('comment_count', 0)}")
|
|
2573
|
+
print(f" attachments: {summary.get('attachment_count', 0)}")
|
|
2574
|
+
print(f" description: {'yes' if summary.get('has_description') else 'no'}")
|
|
2575
|
+
print(f" reproduction steps found: {'yes' if summary.get('has_reproduction_steps') else 'no'}")
|
|
2576
|
+
count = summary.get("missing_information_count", 0)
|
|
2577
|
+
print(f" missing information: {count} item(s)")
|
|
2578
|
+
print(f"Generated: .ai/{issue_key}/{ISSUE_ARTIFACT}")
|
|
2579
|
+
print(f"Generated: .ai/{issue_key}/jira_field_report.md")
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
def _print_clean_result(result, include_memory: bool) -> None:
|
|
2583
|
+
if f".ai/{result.issue_key}/" in result.deleted_paths:
|
|
2584
|
+
print(f"Cleaned workflow artifacts for {result.issue_key}:")
|
|
2585
|
+
print(f" deleted: .ai/{result.issue_key}/")
|
|
2586
|
+
else:
|
|
2587
|
+
print(f"No workflow artifacts found for {result.issue_key}.")
|
|
2588
|
+
|
|
2589
|
+
memory_path = f".ai_memory/bugs/{result.issue_key}.md"
|
|
2590
|
+
if include_memory:
|
|
2591
|
+
if memory_path in result.deleted_paths:
|
|
2592
|
+
print(f" deleted memory: {memory_path}")
|
|
2593
|
+
else:
|
|
2594
|
+
print(f" memory not found: {memory_path}")
|
|
2595
|
+
else:
|
|
2596
|
+
print(f"Preserved memory entry: {memory_path}")
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
__all__ = [
|
|
2600
|
+
"build_context",
|
|
2601
|
+
"extract_keywords",
|
|
2602
|
+
"fetch_issue",
|
|
2603
|
+
"generate_task",
|
|
2604
|
+
"main",
|
|
2605
|
+
"parse_issue",
|
|
2606
|
+
"add_memory_entry",
|
|
2607
|
+
"search_memory",
|
|
2608
|
+
]
|
|
2609
|
+
|
|
2610
|
+
|
|
2611
|
+
if __name__ == "__main__":
|
|
2612
|
+
# `python -m bugpilot` is the documented form, but `python -m bugpilot.cli`
|
|
2613
|
+
# is the one people try when debugging an import problem — and without this
|
|
2614
|
+
# it printed nothing and exited 0, which reads as "it worked".
|
|
2615
|
+
raise SystemExit(main())
|