remote-coder 0.4.1__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 (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/config.py ADDED
@@ -0,0 +1,130 @@
1
+ import os
2
+ from functools import lru_cache
3
+ from pathlib import Path
4
+ from typing import Self
5
+
6
+ from pydantic import Field, SecretStr, field_validator, model_validator
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ from app.models import CodexSandboxMode, ModelName
10
+
11
+
12
+ def remote_coder_home() -> Path:
13
+ """Stable per-user config/state home so the CLI works from any directory.
14
+
15
+ Overridable with REMOTE_CODER_HOME; defaults to ~/.remote-coder. Holds the
16
+ global `.env` written by `remote-coder init`.
17
+ """
18
+ raw = os.environ.get("REMOTE_CODER_HOME", "").strip()
19
+ if raw:
20
+ return Path(raw).expanduser()
21
+ return Path.home() / ".remote-coder"
22
+
23
+
24
+ class Settings(BaseSettings):
25
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
26
+
27
+ telegram_bot_token: SecretStr | None = Field(
28
+ default=None,
29
+ description=(
30
+ "Optional seed only: written into projects.json when the registry file is missing "
31
+ "or lists no projects. Runtime bots use per-project bot_token in the registry."
32
+ ),
33
+ )
34
+ telegram_allowed_chat_ids: list[int] = Field(
35
+ default_factory=list,
36
+ description="Optional seed only: initial allowed_chat_ids for the seeded default_project record.",
37
+ )
38
+ telegram_allowed_user_ids: list[int] = Field(
39
+ default_factory=list,
40
+ description="Optional seed only: initial allowed_user_ids for the seeded default_project record.",
41
+ )
42
+ telegram_webhook_secret: SecretStr | None = Field(
43
+ default=None,
44
+ description="Optional seed only: initial webhook_secret for the seeded default_project record.",
45
+ )
46
+ telegram_webhook_public_base_url: str | None = Field(
47
+ default=None,
48
+ description=(
49
+ "Runtime public HTTPS base URL used to refresh per-project Telegram webhooks "
50
+ "after project create/update in the admin UI."
51
+ ),
52
+ )
53
+
54
+ default_model: ModelName = ModelName.CLAUDE
55
+ default_project: str = "remote-coder"
56
+ project_root: Path = Field(default_factory=remote_coder_home)
57
+ worktree_base_dir: Path = Field(default_factory=lambda: remote_coder_home() / "worktrees")
58
+ job_timeout_seconds: int = 1800
59
+ keep_worktree_on_success: bool = True
60
+ projects_config_path: Path | None = None
61
+ git_remote_name: str = "origin"
62
+
63
+ # 프로젝트+채팅별 대화 기억(SQLite). 미설정 시 PROJECT_ROOT/.remote-coder/conversations.sqlite3
64
+ conversation_db_path: Path | None = None
65
+ # 작업 메타데이터(SQLite). 미설정 시 PROJECT_ROOT/.remote-coder/jobs.sqlite3
66
+ job_db_path: Path | None = None
67
+ conversation_recent_limit: int = 10
68
+
69
+ # Codex `codex exec` 샌드박스. 기본 workspace-write (워크트리 내 파일 수정 허용). read-only는 편집 불가.
70
+ codex_sandbox: CodexSandboxMode = CodexSandboxMode.WORKSPACE_WRITE
71
+
72
+ @model_validator(mode="after")
73
+ def _normalize_telegram_seed_fields(self) -> Self:
74
+ if self.telegram_bot_token is not None:
75
+ if not self.telegram_bot_token.get_secret_value().strip():
76
+ self.telegram_bot_token = None
77
+ if self.telegram_webhook_secret is not None:
78
+ if not self.telegram_webhook_secret.get_secret_value().strip():
79
+ self.telegram_webhook_secret = None
80
+ if self.telegram_webhook_public_base_url is not None:
81
+ base = self.telegram_webhook_public_base_url.strip().rstrip("/")
82
+ self.telegram_webhook_public_base_url = base or None
83
+ return self
84
+
85
+ @model_validator(mode="after")
86
+ def _default_conversation_db_path(self) -> Self:
87
+ if self.conversation_db_path is None:
88
+ self.conversation_db_path = (self.project_root / ".remote-coder" / "conversations.sqlite3").resolve()
89
+ return self
90
+
91
+ @model_validator(mode="after")
92
+ def _default_job_db_path(self) -> Self:
93
+ if self.job_db_path is None:
94
+ self.job_db_path = (self.project_root / ".remote-coder" / "jobs.sqlite3").resolve()
95
+ return self
96
+
97
+ @field_validator("telegram_allowed_chat_ids", mode="before")
98
+ @classmethod
99
+ def parse_allowed_chat_ids(cls, value: object) -> list[int]:
100
+ if value in (None, ""):
101
+ return []
102
+ if isinstance(value, list):
103
+ return [int(v) for v in value]
104
+ if isinstance(value, (int, float)):
105
+ return [int(value)]
106
+ if isinstance(value, str):
107
+ parsed = [item.strip() for item in value.split(",") if item.strip()]
108
+ return [int(v) for v in parsed]
109
+ raise ValueError("TELEGRAM_ALLOWED_CHAT_IDS must be list, int, or comma-separated string")
110
+
111
+ @field_validator("telegram_allowed_user_ids", mode="before")
112
+ @classmethod
113
+ def parse_allowed_user_ids(cls, value: object) -> list[int]:
114
+ if value in (None, ""):
115
+ return []
116
+ if isinstance(value, list):
117
+ return [int(v) for v in value]
118
+ if isinstance(value, (int, float)):
119
+ return [int(value)]
120
+ if isinstance(value, str):
121
+ parsed = [item.strip() for item in value.split(",") if item.strip()]
122
+ return [int(v) for v in parsed]
123
+ raise ValueError("TELEGRAM_ALLOWED_USER_IDS must be list, int, or comma-separated string")
124
+
125
+
126
+ @lru_cache
127
+ def get_settings() -> Settings:
128
+ home = remote_coder_home()
129
+ # cwd ".env" overrides the global home file so in-repo development keeps working.
130
+ return Settings(_env_file=(home / ".env", Path(".env")))
app/git/__init__.py ADDED
File without changes
app/git/ai_commit.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import tempfile
5
+
6
+ from app.models import ModelName
7
+ from app.monitoring.events import EventLogger
8
+
9
+ _log = EventLogger("app.git.ai_commit", "git.commit")
10
+
11
+
12
+ class AiCommitBodyGenerator:
13
+ _PROMPT = (
14
+ "You are writing a git commit message.\n"
15
+ "First, output one line: \"title: <concise summary under 72 chars>\"\n"
16
+ "Then output 2-3 bullet points describing what was changed.\n"
17
+ "Focus on WHAT changed, not how.\n\n"
18
+ "Do not copy the raw user request into the title.\n"
19
+ "User request: {instruction}\n"
20
+ "Changed files: {files}\n\n"
21
+ "Output format (exactly):\n"
22
+ "title: <title here>\n"
23
+ "- <bullet 1>\n"
24
+ "- <bullet 2>"
25
+ )
26
+
27
+ def generate(
28
+ self,
29
+ instruction: str,
30
+ changed_files: list[str],
31
+ model_name: ModelName = ModelName.CLAUDE,
32
+ timeout: int = 30,
33
+ ) -> tuple[str | None, str | None]:
34
+ prompt = self._PROMPT.format(
35
+ instruction=instruction.strip(),
36
+ files=", ".join(changed_files) if changed_files else "(none)",
37
+ )
38
+ argv = self._build_argv(model_name, prompt)
39
+ try:
40
+ with tempfile.TemporaryDirectory() as tmpdir:
41
+ result = subprocess.run(
42
+ argv,
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=timeout,
46
+ cwd=tmpdir,
47
+ check=False,
48
+ shell=False,
49
+ )
50
+ except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
51
+ _log.warning("ai commit generation failed model=%s: %s", model_name.value, exc)
52
+ return None, None
53
+
54
+ if result.returncode != 0 or not result.stdout.strip():
55
+ _log.warning(
56
+ "ai commit generation failed model=%s exit=%d",
57
+ model_name.value,
58
+ result.returncode,
59
+ )
60
+ return None, None
61
+
62
+ return self._parse_output(result.stdout)
63
+
64
+ @staticmethod
65
+ def _build_argv(model_name: ModelName, prompt: str) -> list[str]:
66
+ argv_by_model: dict[ModelName, list[str]] = {
67
+ ModelName.CLAUDE: ["claude", "-p", prompt, "--dangerously-skip-permissions"],
68
+ ModelName.CODEX: ["codex", "exec", "--sandbox", "read-only", prompt],
69
+ ModelName.GEMINI: ["gemini", "-p", prompt],
70
+ }
71
+ argv = argv_by_model.get(model_name)
72
+ if argv is None:
73
+ raise ValueError(f"Unsupported model for commit body generation: {model_name}")
74
+ return argv
75
+
76
+ @staticmethod
77
+ def _parse_output(stdout: str) -> tuple[str | None, str | None]:
78
+ ai_title: str | None = None
79
+ bullet_lines: list[str] = []
80
+ for line in stdout.strip().splitlines():
81
+ stripped = line.strip()
82
+ if ai_title is None and stripped.startswith("title: "):
83
+ ai_title = stripped[len("title: "):].strip()
84
+ elif stripped.startswith("- "):
85
+ bullet_lines.append(stripped)
86
+
87
+ ai_body = "\n".join(bullet_lines) if bullet_lines else None
88
+ return ai_title or None, ai_body
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from abc import ABC, abstractmethod
5
+ from datetime import UTC, datetime
6
+
7
+
8
+ class BranchNamingStrategy(ABC):
9
+ @abstractmethod
10
+ def make_branch_name(self, instruction: str) -> str:
11
+ raise NotImplementedError
12
+
13
+
14
+ class TimestampSlugStrategy(BranchNamingStrategy):
15
+ def make_branch_name(self, instruction: str) -> str:
16
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", instruction.strip().lower()).strip("-")
17
+ slug = slug or "task"
18
+ if len(slug) > 30:
19
+ slug = slug[:30].rstrip("-")
20
+ ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
21
+ return f"remote-{slug}-{ts}"
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+
6
+ class CommitMessageFormatter:
7
+ _OPTION_PATTERN = re.compile(
8
+ r"\b(?:model|branch|project)\s*:\s*\S+|\bno\s+commit\b",
9
+ flags=re.IGNORECASE,
10
+ )
11
+ _SPEAKER_PREFIX_PATTERN = re.compile(r"^user\s*:\s*", flags=re.IGNORECASE)
12
+ _PARENTHETICAL_EXAMPLE_PATTERN = re.compile(r"\(ex>?.*?\)", flags=re.IGNORECASE)
13
+ _WHITESPACE_PATTERN = re.compile(r"\s+")
14
+ _REQUEST_MARKERS = (
15
+ "please",
16
+ "can you",
17
+ "could you",
18
+ "would you",
19
+ )
20
+ _FIX_KEYWORDS = (
21
+ "bug",
22
+ "error",
23
+ "fix",
24
+ "issue",
25
+ "patch",
26
+ "repair",
27
+ "resolve",
28
+ )
29
+ _REFACTOR_KEYWORDS = (
30
+ "cleanup",
31
+ "extract",
32
+ "refactor",
33
+ "rename",
34
+ "restructure",
35
+ "simplify",
36
+ )
37
+ _CHORE_KEYWORDS = (
38
+ "build",
39
+ "chore",
40
+ "ci",
41
+ "config",
42
+ "dependency",
43
+ "deps",
44
+ "docs",
45
+ "documentation",
46
+ "readme",
47
+ "test",
48
+ )
49
+
50
+ @classmethod
51
+ def format(
52
+ cls,
53
+ job_id: str,
54
+ instruction: str,
55
+ changed_files: list[str],
56
+ ai_body: str | None = None,
57
+ ai_title: str | None = None,
58
+ ) -> str:
59
+ commit_type = cls._infer_type(instruction, changed_files)
60
+ title = cls._safe_ai_title(ai_title) or cls._build_title(
61
+ instruction,
62
+ changed_files,
63
+ commit_type,
64
+ )
65
+ body = cls._safe_ai_body(ai_body)
66
+ if body is None:
67
+ bullets = cls._build_bullets(commit_type, changed_files)
68
+ body = "\n".join(f"- {bullet}" for bullet in bullets)
69
+ return f"{commit_type}: {title}\n\n{body}\n\ncommitted by remote-coder: {job_id}"
70
+
71
+ @classmethod
72
+ def _infer_type(cls, instruction: str, changed_files: list[str]) -> str:
73
+ lowered = instruction.casefold()
74
+ if any(keyword in lowered for keyword in cls._FIX_KEYWORDS):
75
+ return "fix"
76
+ if changed_files and all(cls._is_chore_path(path) for path in changed_files):
77
+ return "chore"
78
+ if any(keyword in lowered for keyword in cls._REFACTOR_KEYWORDS):
79
+ return "refactor"
80
+ if any(keyword in lowered for keyword in cls._CHORE_KEYWORDS):
81
+ return "chore"
82
+ return "feat"
83
+
84
+ @classmethod
85
+ def _build_title(cls, instruction: str, changed_files: list[str], commit_type: str) -> str:
86
+ summary = cls._instruction_summary(instruction)
87
+ if summary:
88
+ return summary
89
+
90
+ if any(path.startswith("app/monitoring/") for path in changed_files):
91
+ return "update monitoring behavior"
92
+ if any(path.startswith("app/telegram/") for path in changed_files):
93
+ return "update telegram behavior"
94
+ if any(path.startswith("app/admin/") for path in changed_files):
95
+ return "update admin behavior"
96
+ if any(path.startswith("app/git/") for path in changed_files):
97
+ return "update git workflow"
98
+ if any(path.startswith("docs/") or path == "README.md" for path in changed_files):
99
+ return "update project documentation"
100
+ scoped_title = cls._build_scoped_title(commit_type, changed_files)
101
+ if scoped_title:
102
+ return scoped_title
103
+ return "update requested behavior"
104
+
105
+ @classmethod
106
+ def _instruction_summary(cls, instruction: str, max_length: int = 72) -> str:
107
+ line = cls._first_meaningful_instruction_line(instruction)
108
+ if not line:
109
+ return ""
110
+
111
+ line = cls._SPEAKER_PREFIX_PATTERN.sub("", line)
112
+ line = cls._OPTION_PATTERN.sub("", line)
113
+ line = cls._PARENTHETICAL_EXAMPLE_PATTERN.sub("", line)
114
+ line = cls._WHITESPACE_PATTERN.sub(" ", line).strip()
115
+ line = line.strip().strip("-:;,.!?").strip("\"'`")
116
+ if not line:
117
+ return ""
118
+ if not cls._is_ascii_text(line):
119
+ return ""
120
+
121
+ if cls._mentions_monitor_model_metrics(line):
122
+ return "show current model and token usage in monitor model"
123
+ if cls._looks_like_raw_request(line):
124
+ return ""
125
+ return cls._truncate_at_word(cls._lowercase_initial_ascii(line), max_length)
126
+
127
+ @classmethod
128
+ def _safe_ai_title(
129
+ cls,
130
+ ai_title: str | None,
131
+ max_length: int = 72,
132
+ ) -> str | None:
133
+ if ai_title is None:
134
+ return None
135
+ title = cls._WHITESPACE_PATTERN.sub(" ", ai_title).strip()
136
+ title = title.strip().strip("-:;,.!?").strip("\"'`")
137
+ if not title or len(title) > max_length:
138
+ return None
139
+ if not cls._is_ascii_text(title):
140
+ return None
141
+ if cls._looks_like_raw_request(title):
142
+ return None
143
+ return cls._lowercase_initial_ascii(title)
144
+
145
+ @classmethod
146
+ def _safe_ai_body(cls, ai_body: str | None) -> str | None:
147
+ if ai_body is None:
148
+ return None
149
+ lines = [line.rstrip() for line in ai_body.strip().splitlines() if line.strip()]
150
+ if not lines:
151
+ return None
152
+ body = "\n".join(lines)
153
+ if not cls._is_ascii_text(body):
154
+ return None
155
+ return body
156
+
157
+ @classmethod
158
+ def _looks_like_raw_request(cls, text: str) -> bool:
159
+ lowered = text.casefold()
160
+ return any(marker in lowered for marker in cls._REQUEST_MARKERS)
161
+
162
+ @classmethod
163
+ def _build_scoped_title(cls, commit_type: str, changed_files: list[str]) -> str:
164
+ scope = cls._common_source_scope(changed_files)
165
+ if not scope:
166
+ return ""
167
+ verb = {
168
+ "fix": "fix",
169
+ "refactor": "refactor",
170
+ "chore": "maintain",
171
+ }.get(commit_type, "update")
172
+ return f"{verb} {scope}"
173
+
174
+ @staticmethod
175
+ def _common_source_scope(changed_files: list[str]) -> str:
176
+ source_files = [
177
+ path
178
+ for path in changed_files
179
+ if "/" in path and not path.startswith(("tests/", "docs/", "."))
180
+ ]
181
+ if not source_files:
182
+ return ""
183
+
184
+ split_paths = [path.split("/")[:-1] for path in source_files]
185
+ common: list[str] = []
186
+ for parts in zip(*split_paths):
187
+ if len(set(parts)) != 1:
188
+ break
189
+ common.append(parts[0])
190
+ if common:
191
+ return f"{'/'.join(common)} source"
192
+ return f"{source_files[0].split('/', 1)[0]} source"
193
+
194
+ _CURRENT_REQUEST_MARKERS = (
195
+ "[Current request]",
196
+ "[/current request]",
197
+ )
198
+ _CONTEXT_NOISE_PREFIXES = (
199
+ "job_id=",
200
+ "message_id=",
201
+ )
202
+
203
+ @classmethod
204
+ def _first_meaningful_instruction_line(cls, instruction: str) -> str:
205
+ skip_depth = 0
206
+ for raw_line in instruction.splitlines():
207
+ line = raw_line.strip()
208
+ if not line:
209
+ continue
210
+ if line in cls._CURRENT_REQUEST_MARKERS:
211
+ continue
212
+ if line.startswith("[/") and line.endswith("]"):
213
+ skip_depth = max(0, skip_depth - 1)
214
+ continue
215
+ if line.startswith("[") and line.endswith("]"):
216
+ skip_depth += 1
217
+ continue
218
+ if skip_depth > 0:
219
+ continue
220
+ if any(line.startswith(prefix) for prefix in cls._CONTEXT_NOISE_PREFIXES):
221
+ continue
222
+ return line
223
+ return ""
224
+
225
+ @staticmethod
226
+ def _mentions_monitor_model_metrics(text: str) -> bool:
227
+ lowered = text.casefold()
228
+ return "monitor model" in lowered and "token" in lowered
229
+
230
+ @staticmethod
231
+ def _is_ascii_text(text: str) -> bool:
232
+ return text.isascii()
233
+
234
+ @staticmethod
235
+ def _truncate_at_word(text: str, max_length: int) -> str:
236
+ if len(text) <= max_length:
237
+ return text
238
+ clipped = text[: max_length + 1].rsplit(" ", 1)[0].strip()
239
+ if not clipped:
240
+ clipped = text[:max_length].strip()
241
+ return clipped.rstrip(".,;:")
242
+
243
+ @staticmethod
244
+ def _lowercase_initial_ascii(text: str) -> str:
245
+ if len(text) >= 2 and "A" <= text[0] <= "Z" and not ("A" <= text[1] <= "Z"):
246
+ return text[0].lower() + text[1:]
247
+ return text
248
+
249
+ @classmethod
250
+ def _build_bullets(cls, commit_type: str, changed_files: list[str]) -> list[str]:
251
+ first_bullets = {
252
+ "fix": "AI agent fixed the requested behavior",
253
+ "refactor": "AI agent refactored the requested area",
254
+ "chore": "AI agent maintained project assets",
255
+ "feat": "AI agent implemented the requested change",
256
+ }
257
+ bullets: list[str] = [first_bullets.get(commit_type, "AI agent implemented the requested change")]
258
+
259
+ if any(path.startswith("tests/") for path in changed_files):
260
+ bullets.append("AI agent updated automated coverage where applicable")
261
+ elif any(cls._is_doc_path(path) for path in changed_files):
262
+ bullets.append("AI agent refreshed related documentation where applicable")
263
+ return bullets[:2]
264
+
265
+ @staticmethod
266
+ def _is_doc_path(path: str) -> bool:
267
+ return path == "README.md" or path.startswith("docs/")
268
+
269
+ @classmethod
270
+ def _is_chore_path(cls, path: str) -> bool:
271
+ return (
272
+ cls._is_doc_path(path)
273
+ or path.startswith("tests/")
274
+ or path.endswith(".yml")
275
+ or path.endswith(".yaml")
276
+ or path.endswith(".toml")
277
+ or path.endswith(".ini")
278
+ or path.endswith(".cfg")
279
+ )