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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Which branch an agent works and commits on: the developer's choice.
|
|
2
|
+
|
|
3
|
+
BugPilot never runs ``git branch``, ``git checkout``, ``git switch``,
|
|
4
|
+
``git add``, ``git commit`` or ``git push`` itself. What it controls is what
|
|
5
|
+
the task tells the agent, and that used to be one rule for everyone: create or
|
|
6
|
+
switch to ``feature/<work-item>-<slug>`` before editing. A hand-written bug
|
|
7
|
+
gets a new ``local_<timestamp>`` id on every run, so every run named a new
|
|
8
|
+
branch; and a developer already working on a branch of their own was moved off
|
|
9
|
+
it.
|
|
10
|
+
|
|
11
|
+
Three policies, recorded per work item with the hint and the Fix Mode:
|
|
12
|
+
|
|
13
|
+
- ``current`` (the default): work on the branch that is checked out. Never
|
|
14
|
+
create or switch one — except that on ``main``/``master`` or a detached HEAD
|
|
15
|
+
the agent stops and asks before creating the suggested branch.
|
|
16
|
+
- ``per-issue``: one branch for the work item, created once and reused — the
|
|
17
|
+
name is recorded the first time and every later preparation names the same
|
|
18
|
+
one.
|
|
19
|
+
- ``ask``: before editing, the agent asks whether to stay on the current
|
|
20
|
+
branch or create or switch to the suggested one.
|
|
21
|
+
|
|
22
|
+
The lifecycle rule under all three: preparing the same work item again — Run,
|
|
23
|
+
Rebuild Context, a retry, a new attempt — never calls for a new branch. Only
|
|
24
|
+
the policy decides when a branch is created or switched.
|
|
25
|
+
|
|
26
|
+
Whatever the policy, ``main`` and ``master`` are protected: nothing is edited,
|
|
27
|
+
committed or pushed on them, and a commit is never made on a detached HEAD. No
|
|
28
|
+
policy and no setting can relax that.
|
|
29
|
+
|
|
30
|
+
Every sentence about branches in a task, a guardrail, the delivery checks and
|
|
31
|
+
a retry prompt comes from this module.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
|
|
38
|
+
from .identity import is_local_work_item_id
|
|
39
|
+
|
|
40
|
+
BRANCH_POLICY_CURRENT = "current"
|
|
41
|
+
BRANCH_POLICY_PER_ISSUE = "per-issue"
|
|
42
|
+
BRANCH_POLICY_ASK = "ask"
|
|
43
|
+
|
|
44
|
+
BRANCH_POLICIES: tuple[str, ...] = (BRANCH_POLICY_CURRENT, BRANCH_POLICY_PER_ISSUE, BRANCH_POLICY_ASK)
|
|
45
|
+
DEFAULT_BRANCH_POLICY = BRANCH_POLICY_CURRENT
|
|
46
|
+
|
|
47
|
+
# Never written to, under any policy. Not configurable.
|
|
48
|
+
PROTECTED_BRANCHES: tuple[str, ...] = ("main", "master")
|
|
49
|
+
|
|
50
|
+
# What a recorded branch name may look like: a plain ref, nothing to escape.
|
|
51
|
+
_BRANCH_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}")
|
|
52
|
+
|
|
53
|
+
_POLICY_NAMES = {
|
|
54
|
+
BRANCH_POLICY_CURRENT: "use the current branch",
|
|
55
|
+
BRANCH_POLICY_PER_ISSUE: "one branch per work item",
|
|
56
|
+
BRANCH_POLICY_ASK: "ask the developer",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# The lifecycle rule, said in every task.
|
|
60
|
+
_NO_NEW_BRANCH_FOR_REPREPARING = (
|
|
61
|
+
"- Preparing this work item again — Run, Rebuild Context, a retry, a new attempt — "
|
|
62
|
+
"does not call for a new branch."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_branch_policy(value: str | None) -> str | None:
|
|
67
|
+
"""``value`` if it is a policy, ``None`` if absent; anything else is refused."""
|
|
68
|
+
if value is None:
|
|
69
|
+
return None
|
|
70
|
+
policy = value.strip().lower()
|
|
71
|
+
if policy not in BRANCH_POLICIES:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Unknown branch policy {value!r}. Use one of: {', '.join(BRANCH_POLICIES)}."
|
|
74
|
+
)
|
|
75
|
+
return policy
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve_branch_policy(requested: str | None, recorded: str | None) -> str:
|
|
79
|
+
"""The policy in force: the request's, else what the work item recorded, else the default.
|
|
80
|
+
|
|
81
|
+
A recorded value that is not a policy (an issue.json edited by hand, or
|
|
82
|
+
written by a later version) is ignored rather than fatal: the default is safe.
|
|
83
|
+
"""
|
|
84
|
+
if requested is not None:
|
|
85
|
+
return check_branch_policy(requested) or DEFAULT_BRANCH_POLICY
|
|
86
|
+
if recorded in BRANCH_POLICIES:
|
|
87
|
+
return recorded # type: ignore[return-value]
|
|
88
|
+
return DEFAULT_BRANCH_POLICY
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def usable_branch_name(value: object) -> str | None:
|
|
92
|
+
"""A recorded branch name, if it is one a task may quote; else ``None``.
|
|
93
|
+
|
|
94
|
+
The record is read back into ``task.md`` inside backticks, so a name that is
|
|
95
|
+
not a plain ref (a hand-edited ``issue.json``) is dropped and derived again
|
|
96
|
+
rather than quoted.
|
|
97
|
+
"""
|
|
98
|
+
if not isinstance(value, str):
|
|
99
|
+
return None
|
|
100
|
+
name = value.strip()
|
|
101
|
+
return name if _BRANCH_NAME_RE.fullmatch(name) and ".." not in name else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def records_branch_name(policy: str) -> bool:
|
|
105
|
+
"""Whether the work item's branch is recorded, so later preparations reuse it.
|
|
106
|
+
|
|
107
|
+
``current`` names a branch only as a suggestion for main/master, so it is
|
|
108
|
+
derived afresh; the other two may have the agent create one, and that one
|
|
109
|
+
is the branch every later preparation must name.
|
|
110
|
+
"""
|
|
111
|
+
return policy != BRANCH_POLICY_CURRENT
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _existing_branch_clause(issue_key: str) -> str:
|
|
115
|
+
"""For a Jira work item: a branch already made for its key counts as its branch."""
|
|
116
|
+
if is_local_work_item_id(issue_key):
|
|
117
|
+
return ""
|
|
118
|
+
return f", or another existing branch whose name contains `{issue_key}`"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def branch_instructions(policy: str, branch: str, issue_key: str = "") -> str:
|
|
122
|
+
"""``task.md``'s Branch Instructions section, heading included."""
|
|
123
|
+
lines = [f"- Branch policy: {_POLICY_NAMES[policy]}."]
|
|
124
|
+
if policy == BRANCH_POLICY_PER_ISSUE:
|
|
125
|
+
lines += [
|
|
126
|
+
f"- Branch name: `{branch}`",
|
|
127
|
+
"- Check the current branch before editing.",
|
|
128
|
+
"- Never work directly on main/master.",
|
|
129
|
+
f"- Create or switch to `{branch}` before editing files — from a detached HEAD too. "
|
|
130
|
+
f"If it already exists{_existing_branch_clause(issue_key)}, switch to it rather than creating another.",
|
|
131
|
+
f"{_NO_NEW_BRANCH_FOR_REPREPARING} Reuse this one.",
|
|
132
|
+
]
|
|
133
|
+
elif policy == BRANCH_POLICY_ASK:
|
|
134
|
+
lines += [
|
|
135
|
+
f"- Suggested branch name: `{branch}`",
|
|
136
|
+
"- Check the current branch before editing.",
|
|
137
|
+
"- Never work directly on main/master.",
|
|
138
|
+
"- Before editing, tell the developer the current branch and the suggested branch "
|
|
139
|
+
f"`{branch}`, and ask whether to stay on the current branch or to create or switch to "
|
|
140
|
+
"the suggested one; then do as they answer.",
|
|
141
|
+
"- If the current branch is `main` or `master`, or HEAD is detached, staying is not an "
|
|
142
|
+
f"option: ask only whether to create or switch to `{branch}`.",
|
|
143
|
+
_NO_NEW_BRANCH_FOR_REPREPARING,
|
|
144
|
+
]
|
|
145
|
+
else:
|
|
146
|
+
lines += [
|
|
147
|
+
"- Check the current branch before editing.",
|
|
148
|
+
"- Work on the branch that is currently checked out. Do not create or switch branches.",
|
|
149
|
+
"- Edit only on the current branch.",
|
|
150
|
+
"- Never work directly on main/master.",
|
|
151
|
+
_NO_NEW_BRANCH_FOR_REPREPARING,
|
|
152
|
+
"- If the current branch is `main` or `master`, or HEAD is detached, stop before editing "
|
|
153
|
+
f'and ask the developer: "You are on a protected branch / detached HEAD. Create `{branch}` '
|
|
154
|
+
'and continue?" Create and switch to it only if they explicitly agree.',
|
|
155
|
+
]
|
|
156
|
+
return "## Branch Instructions\n\n" + "\n".join(lines) + "\n\n"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def retry_branch_section(policy: str, branch: str | None = None) -> str:
|
|
160
|
+
"""A retry prompt's Branch section: the same policy, and no new branch."""
|
|
161
|
+
if policy == BRANCH_POLICY_PER_ISSUE:
|
|
162
|
+
where = f"the work item's branch, `{branch}`" if branch else "the work item's branch"
|
|
163
|
+
elif policy == BRANCH_POLICY_ASK:
|
|
164
|
+
where = "the branch the developer chose for the first attempt"
|
|
165
|
+
else:
|
|
166
|
+
where = "the current branch"
|
|
167
|
+
return (
|
|
168
|
+
"## Branch\n\n"
|
|
169
|
+
f"- Branch policy: {_POLICY_NAMES[policy]}, as in the first attempt.\n"
|
|
170
|
+
f"- Continue on {where}. A retry does not call for a new branch.\n"
|
|
171
|
+
"- Never work directly on main/master.\n\n"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def branch_editing_guardrail(policy: str, branch: str | None = None) -> str:
|
|
176
|
+
"""The editing guardrail's branch line."""
|
|
177
|
+
if policy == BRANCH_POLICY_PER_ISSUE:
|
|
178
|
+
named = f", `{branch}`" if branch else " named above"
|
|
179
|
+
return f"- Edit only on the work item's branch{named}; never on main/master.\n"
|
|
180
|
+
if policy == BRANCH_POLICY_ASK:
|
|
181
|
+
return "- Edit only on the branch the developer chose; never on main/master.\n"
|
|
182
|
+
return (
|
|
183
|
+
"- Edit only on the current branch, never on main/master. Do not create or switch branches "
|
|
184
|
+
"unless the developer explicitly approves it from main/master or a detached HEAD.\n"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def delivery_branch_checks(policy: str, issue_key: str, branch: str | None) -> str:
|
|
189
|
+
"""The branch checks before staging, after "not main/master" and "not detached"."""
|
|
190
|
+
if policy == BRANCH_POLICY_PER_ISSUE:
|
|
191
|
+
if branch:
|
|
192
|
+
return (
|
|
193
|
+
f"- Verify the current branch is the work item's branch: `{branch}`"
|
|
194
|
+
f"{_existing_branch_clause(issue_key)}.\n"
|
|
195
|
+
f"- If needed, ask whether to create or switch to `{branch}` before editing or delivery.\n"
|
|
196
|
+
)
|
|
197
|
+
# No name to hold the branch to: the old checks, which still say the
|
|
198
|
+
# branch is this work item's.
|
|
199
|
+
return (
|
|
200
|
+
"- Verify the current branch starts with `feature/` or another accepted feature prefix.\n"
|
|
201
|
+
f"- Verify the current branch includes `{issue_key}`.\n"
|
|
202
|
+
)
|
|
203
|
+
if policy == BRANCH_POLICY_ASK:
|
|
204
|
+
return (
|
|
205
|
+
"- Verify the current branch is the one the developer chose before editing. If they chose "
|
|
206
|
+
f"to stay on their branch, it does not have to start with `feature/` or include `{issue_key}`.\n"
|
|
207
|
+
)
|
|
208
|
+
return (
|
|
209
|
+
"- Commit on the current branch. It does not have to start with `feature/` "
|
|
210
|
+
f"or include `{issue_key}`.\n"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def delivery_branch_stop(policy: str, issue_key: str, branch: str | None = None) -> str:
|
|
215
|
+
"""The commit offer's last branch rule, if the policy has one."""
|
|
216
|
+
if policy != BRANCH_POLICY_PER_ISSUE:
|
|
217
|
+
return ""
|
|
218
|
+
if branch:
|
|
219
|
+
return (
|
|
220
|
+
f"If the current branch is not `{branch}`{_existing_branch_clause(issue_key)}, "
|
|
221
|
+
"stop and ask the developer before committing.\n\n"
|
|
222
|
+
)
|
|
223
|
+
return f"If {issue_key} is not in the current branch name, stop and ask the developer before committing.\n\n"
|
bugpilot/core/cleanup.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Safe cleanup helpers for generated bugpilot artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .identity import validate_work_item_id
|
|
9
|
+
from .safe_paths import owned_path, remove_owned_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CleanResult:
|
|
14
|
+
issue_key: str
|
|
15
|
+
deleted_paths: list[str] = field(default_factory=list)
|
|
16
|
+
preserved_paths: list[str] = field(default_factory=list)
|
|
17
|
+
missing_paths: list[str] = field(default_factory=list)
|
|
18
|
+
warnings: list[str] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_issue_key(issue_key: str) -> None:
|
|
22
|
+
"""Deprecated alias for :func:`identity.validate_work_item_id`.
|
|
23
|
+
|
|
24
|
+
Kept so existing callers keep working; new code should call
|
|
25
|
+
``validate_work_item_id`` directly.
|
|
26
|
+
"""
|
|
27
|
+
validate_work_item_id(issue_key)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clean_issue_artifacts(repo_root: Path, issue_key: str, include_memory: bool = False) -> CleanResult:
|
|
31
|
+
"""Delete one work item's generated artifacts, and its memory entry if asked.
|
|
32
|
+
|
|
33
|
+
Every path is checked before anything is deleted (``safe_paths.owned_path``):
|
|
34
|
+
a link or junction at ``.ai``, ``.ai/<id>``, ``.ai_memory`` or below raises
|
|
35
|
+
:class:`~bugpilot.core.safe_paths.UnsafePathError` and nothing is removed —
|
|
36
|
+
not the folder, not the memory entry. Both ``clean`` and ``bug --fresh``
|
|
37
|
+
come through here.
|
|
38
|
+
"""
|
|
39
|
+
validate_issue_key(issue_key)
|
|
40
|
+
result = CleanResult(issue_key=issue_key)
|
|
41
|
+
|
|
42
|
+
workflow_dir = owned_path(repo_root, (".ai", issue_key))
|
|
43
|
+
memory_file = owned_path(repo_root, (".ai_memory", "bugs", f"{issue_key}.md")) if include_memory else None
|
|
44
|
+
|
|
45
|
+
if remove_owned_path(workflow_dir):
|
|
46
|
+
result.deleted_paths.append(f".ai/{issue_key}/")
|
|
47
|
+
else:
|
|
48
|
+
result.missing_paths.append(f".ai/{issue_key}/")
|
|
49
|
+
|
|
50
|
+
if memory_file is not None:
|
|
51
|
+
if remove_owned_path(memory_file):
|
|
52
|
+
result.deleted_paths.append(f".ai_memory/bugs/{issue_key}.md")
|
|
53
|
+
else:
|
|
54
|
+
result.missing_paths.append(f".ai_memory/bugs/{issue_key}.md")
|
|
55
|
+
else:
|
|
56
|
+
result.preserved_paths.append(f".ai_memory/bugs/{issue_key}.md")
|
|
57
|
+
|
|
58
|
+
return result
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Which files code search looks at, and which of them are documentation.
|
|
2
|
+
|
|
3
|
+
One list, because there were three and they disagreed. `search.INCLUDE_GLOBS`
|
|
4
|
+
built ripgrep's `-g` flags, `search._is_included_path` re-stated the same
|
|
5
|
+
suffixes inline to filter rg's output, and `keywords._CODE_EXT` decided whether
|
|
6
|
+
`widget.cpp` in a bug report looked like a file name. The third had drifted:
|
|
7
|
+
it recognised `.ts`, `.go`, `.java`, `.c`, `.hxx` and `.qml`, and the first two
|
|
8
|
+
did not — so a bug naming `reader.ts` produced a keyword for a file the search
|
|
9
|
+
would never open. Nine extensions were recognised and unsearchable.
|
|
10
|
+
|
|
11
|
+
The split between implementation and documentation is here for the same reason
|
|
12
|
+
the extensions are: `search.py` ranks with it and `context.py` selects with it,
|
|
13
|
+
and a second opinion about whether `.md` is documentation is a bug waiting to
|
|
14
|
+
happen.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
#: Implementation: source, headers, UI definitions, build files.
|
|
22
|
+
#:
|
|
23
|
+
#: Extensions rather than languages, because that is what both consumers have.
|
|
24
|
+
#: Adding one here makes it searchable, filterable and recognisable as a file
|
|
25
|
+
#: name in a bug report, in one edit.
|
|
26
|
+
CODE_SUFFIXES: frozenset[str] = frozenset(
|
|
27
|
+
{
|
|
28
|
+
# C and C++
|
|
29
|
+
"c", "cc", "cpp", "cxx", "h", "hpp", "hxx",
|
|
30
|
+
# Qt
|
|
31
|
+
"ui", "qrc", "qml",
|
|
32
|
+
# build
|
|
33
|
+
"cmake",
|
|
34
|
+
# everything else bugpilot has been pointed at
|
|
35
|
+
"py", "ts", "js", "java", "cs", "go", "rs",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
#: Files with no useful suffix that are still implementation.
|
|
40
|
+
CODE_FILENAMES: frozenset[str] = frozenset({"CMakeLists.txt"})
|
|
41
|
+
|
|
42
|
+
#: Prose. Searched — a design document naming the subsystem is a real lead — but
|
|
43
|
+
#: never allowed to displace implementation, which is what §33.2 is about.
|
|
44
|
+
#:
|
|
45
|
+
#: `txt` is deliberately absent. §33.2's requirement was to align the *code*
|
|
46
|
+
#: extensions the extractor already recognised; the documentation formats were
|
|
47
|
+
#: an addition of mine, and generic `.txt` turned out to be the wrong one. It is
|
|
48
|
+
#: the most common extension for things that are not prose at all —
|
|
49
|
+
#: `requirements.txt`, licence text, generated file lists, test fixtures, data
|
|
50
|
+
#: dumps — so searching it buys noise and scan time rather than leads. It was
|
|
51
|
+
#: also the cause of `CMakeLists.txt` classifying as documentation.
|
|
52
|
+
#:
|
|
53
|
+
#: `CMakeLists.txt` stays searchable through `CODE_FILENAMES`, which is where it
|
|
54
|
+
#: belongs: it is a build file, not a document.
|
|
55
|
+
DOC_SUFFIXES: frozenset[str] = frozenset({"md", "rst", "adoc"})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def search_globs() -> list[str]:
|
|
59
|
+
"""The `-g` patterns ripgrep is given, implementation and documentation."""
|
|
60
|
+
suffixes = sorted(CODE_SUFFIXES | DOC_SUFFIXES)
|
|
61
|
+
return [f"*.{suffix}" for suffix in suffixes] + sorted(CODE_FILENAMES)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _suffix(path: str) -> str:
|
|
65
|
+
return Path(path).suffix.lower().lstrip(".")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_searchable(path: str) -> bool:
|
|
69
|
+
"""Whether a path rg returned is one this search is interested in.
|
|
70
|
+
|
|
71
|
+
rg is already told the globs; this filters its output too, because a glob
|
|
72
|
+
and a returned path can disagree — a symlink, an odd separator, a file whose
|
|
73
|
+
name matches a pattern by accident.
|
|
74
|
+
"""
|
|
75
|
+
return Path(path).name in CODE_FILENAMES or _suffix(path) in (CODE_SUFFIXES | DOC_SUFFIXES)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_documentation(path: str) -> bool:
|
|
79
|
+
"""Prose rather than implementation. Never both.
|
|
80
|
+
|
|
81
|
+
Mostly by extension: a `.md` under `src/` is still prose, and a path-based
|
|
82
|
+
guess ("does it live in docs/") was wrong for README.md, which sits at the
|
|
83
|
+
root of every repository bugpilot has been pointed at.
|
|
84
|
+
|
|
85
|
+
The named-file check is the exception, and it is the whole reason this is
|
|
86
|
+
not a one-line suffix test. `CMakeLists.txt` ends in `.txt`, so a pure
|
|
87
|
+
suffix rule called it documentation *and* implementation at once. Ranking
|
|
88
|
+
read the second answer and was right; the artifact label and the corpus
|
|
89
|
+
metric read the first and were wrong — which would have quietly inflated
|
|
90
|
+
"documentation in top 5" on exactly the CMake-heavy repositories §33 is
|
|
91
|
+
aimed at.
|
|
92
|
+
"""
|
|
93
|
+
if Path(path).name in CODE_FILENAMES:
|
|
94
|
+
return False
|
|
95
|
+
return _suffix(path) in DOC_SUFFIXES
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_implementation(path: str) -> bool:
|
|
99
|
+
return Path(path).name in CODE_FILENAMES or _suffix(path) in CODE_SUFFIXES
|
bugpilot/core/config.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Configuration helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .user_config import load_user_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
WORKFLOW_STEPS = [
|
|
13
|
+
"doctor",
|
|
14
|
+
"fetch",
|
|
15
|
+
"parse",
|
|
16
|
+
"keywords",
|
|
17
|
+
"memory_search",
|
|
18
|
+
"code_search",
|
|
19
|
+
"git_context",
|
|
20
|
+
"context",
|
|
21
|
+
"prompt",
|
|
22
|
+
"agent_instructions",
|
|
23
|
+
"memory_add",
|
|
24
|
+
"agent_fix",
|
|
25
|
+
# What `summarize-results` did: the Result Overview was rendered (`pass`) or
|
|
26
|
+
# could not be (`fail`). A command having run, never a verdict on the fix.
|
|
27
|
+
# `manual_validation` and `final_review_prompt` were listed here too, and
|
|
28
|
+
# marked `pass` by printing a checklist and a prompt — read back by
|
|
29
|
+
# `status` as a validation and a review that never happened. Neither is a
|
|
30
|
+
# workflow step, so neither is recorded (§37.70).
|
|
31
|
+
"result_summary",
|
|
32
|
+
"memory_update",
|
|
33
|
+
"delivery_check",
|
|
34
|
+
"commit_plan",
|
|
35
|
+
"push_plan",
|
|
36
|
+
"notify",
|
|
37
|
+
"jira_comment_draft",
|
|
38
|
+
"jira_comment",
|
|
39
|
+
"retry_prompt",
|
|
40
|
+
"manual_result",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class AppConfig:
|
|
46
|
+
repo_root: Path
|
|
47
|
+
jira_base_url: str | None
|
|
48
|
+
jira_email: str | None
|
|
49
|
+
jira_token: str | None
|
|
50
|
+
copilot_command: str
|
|
51
|
+
claude_command: str
|
|
52
|
+
claude_args: str
|
|
53
|
+
copilot_args: str
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def has_jira_credentials(self) -> bool:
|
|
57
|
+
return bool(self.jira_base_url and self.jira_email and self.jira_token)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class EmailConfig:
|
|
62
|
+
"""SMTP configuration for outbound notification email.
|
|
63
|
+
|
|
64
|
+
Credentials are read from the environment only. bugpilot never hardcodes or
|
|
65
|
+
persists SMTP secrets; use a secrets manager to inject them at runtime.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
host: str | None
|
|
69
|
+
port: int
|
|
70
|
+
username: str | None
|
|
71
|
+
password: str | None
|
|
72
|
+
use_ssl: bool
|
|
73
|
+
use_starttls: bool
|
|
74
|
+
sender: str | None
|
|
75
|
+
recipients: tuple[str, ...]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_configured(self) -> bool:
|
|
79
|
+
# Enough to send: a server, a sender, and at least one recipient.
|
|
80
|
+
return bool(self.host and self.sender and self.recipients)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def uses_auth(self) -> bool:
|
|
84
|
+
return bool(self.username)
|
|
85
|
+
|
|
86
|
+
def missing_fields(self) -> list[str]:
|
|
87
|
+
missing: list[str] = []
|
|
88
|
+
if not self.host:
|
|
89
|
+
missing.append("SMTP_HOST")
|
|
90
|
+
if not self.sender:
|
|
91
|
+
missing.append("BUGPILOT_EMAIL_FROM")
|
|
92
|
+
if not self.recipients:
|
|
93
|
+
missing.append("BUGPILOT_EMAIL_TO")
|
|
94
|
+
if self.username and not self.password:
|
|
95
|
+
missing.append("SMTP_PASSWORD")
|
|
96
|
+
return missing
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class GraphConfig:
|
|
101
|
+
"""Microsoft Graph (client-credentials) settings for sending mail over HTTPS.
|
|
102
|
+
|
|
103
|
+
This is the transport of choice when the tenant disables SMTP client
|
|
104
|
+
authentication. All values come from the environment; the client secret is
|
|
105
|
+
never hardcoded, logged, or persisted by bugpilot.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
tenant_id: str | None
|
|
109
|
+
client_id: str | None
|
|
110
|
+
client_secret: str | None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_configured(self) -> bool:
|
|
114
|
+
return bool(self.tenant_id and self.client_id and self.client_secret)
|
|
115
|
+
|
|
116
|
+
def missing_fields(self) -> list[str]:
|
|
117
|
+
missing: list[str] = []
|
|
118
|
+
if not self.tenant_id:
|
|
119
|
+
missing.append("GRAPH_TENANT_ID")
|
|
120
|
+
if not self.client_id:
|
|
121
|
+
missing.append("GRAPH_CLIENT_ID")
|
|
122
|
+
if not self.client_secret:
|
|
123
|
+
missing.append("GRAPH_CLIENT_SECRET")
|
|
124
|
+
return missing
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_config(repo_root: Path) -> AppConfig:
|
|
128
|
+
# Precedence for every Jira setting: environment variables first (power
|
|
129
|
+
# users / CI), then the `bugpilot setup` config file
|
|
130
|
+
# (~/.bugpilot/config.toml). The site is one of them now — there is no
|
|
131
|
+
# built-in default, so an unconfigured install reports itself as such
|
|
132
|
+
# instead of aiming at whichever tenant happened to be hard-coded.
|
|
133
|
+
user = load_user_config()
|
|
134
|
+
return AppConfig(
|
|
135
|
+
repo_root=repo_root,
|
|
136
|
+
jira_base_url=_clean_env("JIRA_BASE_URL") or user.jira_base_url,
|
|
137
|
+
jira_email=_clean_env("JIRA_EMAIL") or user.jira_email,
|
|
138
|
+
jira_token=_clean_env("JIRA_TOKEN") or user.jira_token,
|
|
139
|
+
copilot_command=os.getenv("BUGPILOT_COPILOT_COMMAND", "copilot"),
|
|
140
|
+
claude_command=os.getenv("BUGPILOT_CLAUDE_COMMAND", "claude"),
|
|
141
|
+
# Interactive by default, but auto-accept file edits so the agent is not
|
|
142
|
+
# blocked on every edit. For a fully unattended run (also skipping shell
|
|
143
|
+
# prompts for git / bugpilot), set BUGPILOT_CLAUDE_ARGS to include
|
|
144
|
+
# --dangerously-skip-permissions.
|
|
145
|
+
claude_args=os.getenv("BUGPILOT_CLAUDE_ARGS", "--permission-mode acceptEdits"),
|
|
146
|
+
copilot_args=os.getenv("BUGPILOT_COPILOT_ARGS", ""),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_email_config() -> EmailConfig:
|
|
151
|
+
return EmailConfig(
|
|
152
|
+
host=_clean_env("SMTP_HOST"),
|
|
153
|
+
port=_env_int("SMTP_PORT", 587),
|
|
154
|
+
username=_clean_env("SMTP_USERNAME"),
|
|
155
|
+
password=os.getenv("SMTP_PASSWORD") or None,
|
|
156
|
+
use_ssl=_env_bool("SMTP_USE_SSL", False),
|
|
157
|
+
use_starttls=_env_bool("SMTP_USE_STARTTLS", True),
|
|
158
|
+
sender=_clean_env("BUGPILOT_EMAIL_FROM"),
|
|
159
|
+
recipients=_parse_recipients(os.getenv("BUGPILOT_EMAIL_TO")),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_graph_config() -> GraphConfig:
|
|
164
|
+
return GraphConfig(
|
|
165
|
+
tenant_id=_clean_env("GRAPH_TENANT_ID"),
|
|
166
|
+
client_id=_clean_env("GRAPH_CLIENT_ID"),
|
|
167
|
+
client_secret=os.getenv("GRAPH_CLIENT_SECRET") or None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _clean_env(name: str) -> str | None:
|
|
172
|
+
value = os.getenv(name)
|
|
173
|
+
if value is None:
|
|
174
|
+
return None
|
|
175
|
+
value = value.strip()
|
|
176
|
+
return value or None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _env_int(name: str, default: int) -> int:
|
|
180
|
+
raw = _clean_env(name)
|
|
181
|
+
if raw is None:
|
|
182
|
+
return default
|
|
183
|
+
try:
|
|
184
|
+
return int(raw)
|
|
185
|
+
except ValueError:
|
|
186
|
+
return default
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
190
|
+
raw = _clean_env(name)
|
|
191
|
+
if raw is None:
|
|
192
|
+
return default
|
|
193
|
+
return raw.lower() in {"1", "true", "yes", "on"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_recipients(raw: str | None) -> tuple[str, ...]:
|
|
197
|
+
if not raw:
|
|
198
|
+
return ()
|
|
199
|
+
parts = [item.strip() for item in raw.replace(";", ",").split(",")]
|
|
200
|
+
return tuple(item for item in parts if item)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def issue_dir(repo_root: Path, issue_key: str) -> Path:
|
|
204
|
+
"""Where a work item's files are read from. Writers use :func:`writable_issue_dir`."""
|
|
205
|
+
return repo_root / ".ai" / issue_key
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def writable_issue_dir(repo_root: Path, issue_key: str, *, create: bool = True) -> Path:
|
|
209
|
+
"""``.ai/<issue_key>/``, checked before anything is written into it.
|
|
210
|
+
|
|
211
|
+
The id is validated — it becomes a path segment — and every component is
|
|
212
|
+
checked by ``safe_paths.writable_dir``: a link or junction at ``.ai`` or
|
|
213
|
+
``.ai/<id>`` raises ``UnsafePathError`` and nothing is written. With
|
|
214
|
+
``create=False`` a missing folder is returned for the caller to report.
|
|
215
|
+
"""
|
|
216
|
+
from .safe_paths import writable_dir
|
|
217
|
+
|
|
218
|
+
_checked_id(issue_key)
|
|
219
|
+
return writable_dir(repo_root, (".ai", issue_key), create=create)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _checked_id(issue_key: str) -> None:
|
|
223
|
+
"""The id as a path segment: anything but a work item id is an unsafe path."""
|
|
224
|
+
from .identity import validate_work_item_id
|
|
225
|
+
from .safe_paths import UnsafePathError
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
validate_work_item_id(issue_key)
|
|
229
|
+
except ValueError as exc:
|
|
230
|
+
raise UnsafePathError(str(exc)) from exc
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def memory_dir(repo_root: Path) -> Path:
|
|
234
|
+
return repo_root / ".ai_memory" / "bugs"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def writable_memory_file(repo_root: Path, issue_key: str, *, create: bool = True) -> Path:
|
|
238
|
+
"""``.ai_memory/bugs/<issue_key>.md``, checked like :func:`writable_issue_dir`."""
|
|
239
|
+
from .safe_paths import refuse_link, writable_dir
|
|
240
|
+
|
|
241
|
+
_checked_id(issue_key)
|
|
242
|
+
return refuse_link(writable_dir(repo_root, (".ai_memory", "bugs"), create=create) / f"{issue_key}.md")
|