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.
Files changed (59) hide show
  1. bugpilot/__init__.py +5 -0
  2. bugpilot/__main__.py +5 -0
  3. bugpilot/cli.py +2615 -0
  4. bugpilot/cli_json.py +173 -0
  5. bugpilot/core/__init__.py +1 -0
  6. bugpilot/core/agent_runner.py +138 -0
  7. bugpilot/core/artifact_io.py +40 -0
  8. bugpilot/core/artifacts.py +47 -0
  9. bugpilot/core/attachments.py +247 -0
  10. bugpilot/core/branch_policy.py +223 -0
  11. bugpilot/core/cleanup.py +58 -0
  12. bugpilot/core/code_files.py +99 -0
  13. bugpilot/core/config.py +242 -0
  14. bugpilot/core/context.py +436 -0
  15. bugpilot/core/copilot.py +47 -0
  16. bugpilot/core/delivery_instructions.py +106 -0
  17. bugpilot/core/doctor.py +66 -0
  18. bugpilot/core/email_notify.py +316 -0
  19. bugpilot/core/errors.py +105 -0
  20. bugpilot/core/executables.py +87 -0
  21. bugpilot/core/fix_mode_state.py +212 -0
  22. bugpilot/core/fix_mode_store.py +600 -0
  23. bugpilot/core/fix_modes.py +412 -0
  24. bugpilot/core/fix_report.py +124 -0
  25. bugpilot/core/git_history.py +1579 -0
  26. bugpilot/core/git_ops.py +254 -0
  27. bugpilot/core/handoff.py +127 -0
  28. bugpilot/core/identity.py +112 -0
  29. bugpilot/core/input_adapters.py +97 -0
  30. bugpilot/core/instructions.py +337 -0
  31. bugpilot/core/issue.py +487 -0
  32. bugpilot/core/jira.py +886 -0
  33. bugpilot/core/jira_adf.py +167 -0
  34. bugpilot/core/jira_parse.py +442 -0
  35. bugpilot/core/keywords.py +386 -0
  36. bugpilot/core/logging_utils.py +28 -0
  37. bugpilot/core/memory.py +151 -0
  38. bugpilot/core/models.py +322 -0
  39. bugpilot/core/project_settings.py +283 -0
  40. bugpilot/core/prompts.py +567 -0
  41. bugpilot/core/repository_profile.py +739 -0
  42. bugpilot/core/retrieval.py +485 -0
  43. bugpilot/core/review_changes.py +155 -0
  44. bugpilot/core/review_report.py +171 -0
  45. bugpilot/core/run.py +171 -0
  46. bugpilot/core/safe_paths.py +173 -0
  47. bugpilot/core/search.py +765 -0
  48. bugpilot/core/search_terms.py +310 -0
  49. bugpilot/core/setup.py +212 -0
  50. bugpilot/core/user_config.py +211 -0
  51. bugpilot/core/verification_report.py +313 -0
  52. bugpilot/core/workflow.py +2385 -0
  53. bugpilot/mcp_server.py +651 -0
  54. bugpilot-0.1.0.dist-info/METADATA +270 -0
  55. bugpilot-0.1.0.dist-info/RECORD +59 -0
  56. bugpilot-0.1.0.dist-info/WHEEL +5 -0
  57. bugpilot-0.1.0.dist-info/entry_points.txt +3 -0
  58. bugpilot-0.1.0.dist-info/licenses/LICENSE +122 -0
  59. 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"
@@ -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
@@ -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")