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
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ from app.jobs.schemas import Job
8
+ from app.jobs.store import JobStore
9
+ from app.models import ModelName
10
+ from app.monitoring.events import EventLogger
11
+ from app.projects.registry import ProjectRegistry
12
+ from app.telegram.confirmations import InMemoryConfirmationStore, PendingConfirmation
13
+ from app.telegram.conversation import SQLiteConversationStore
14
+ from app.telegram.model_preferences import InMemoryModelPreferenceStore, ModelPreference
15
+
16
+ if TYPE_CHECKING:
17
+ from app.admin.advanced_settings import FileAdvancedSettingsStore
18
+ from app.git.service import GitWorktreeService
19
+ from app.jobs.manager import JobManager
20
+
21
+ _cmd_evt = EventLogger("app.telegram.command", "telegram.command")
22
+
23
+
24
+ @dataclass
25
+ class TelegramMessage:
26
+ chat_id: int
27
+ user_id: int | None
28
+ text: str
29
+
30
+
31
+ @dataclass
32
+ class CommandContext:
33
+ job_store: JobStore
34
+ default_model: ModelName
35
+ project_registry: ProjectRegistry
36
+ model_preferences: InMemoryModelPreferenceStore
37
+ project_name: str | None
38
+ git_service: GitWorktreeService
39
+ git_remote_name: str
40
+ confirmation_store: InMemoryConfirmationStore
41
+ conversation_store: SQLiteConversationStore | None = None
42
+ job_manager: JobManager | None = None
43
+ advanced_settings_store: FileAdvancedSettingsStore | None = None
44
+
45
+
46
+ def format_usage(*lines: str) -> str:
47
+ return "Usage\n\n" + "\n".join(f"- {line}" for line in lines)
48
+
49
+
50
+ MODEL_USAGE = "<claude|codex|gemini>"
51
+
52
+
53
+ @dataclass
54
+ class InlineButton:
55
+ label: str
56
+ callback_data: str
57
+
58
+
59
+ @dataclass
60
+ class CommandResponse:
61
+ text: str
62
+ inline_buttons: list[list["InlineButton"]] | None = None
63
+ skip_notifier_body_i18n: bool = False
64
+
65
+
66
+ def _help_response_skips_notifier_body_i18n(message_text: str) -> bool:
67
+ _ = message_text
68
+ return False
69
+
70
+
71
+ HELP_TEXT = "\n".join(
72
+ [
73
+ "Help",
74
+ "",
75
+ "Send work requests as regular messages.",
76
+ "",
77
+ "Options",
78
+ "- model:",
79
+ "- branch:",
80
+ "- no commit",
81
+ "- plan: <natural language> or /plan <natural language> - plan mode (plan only; no code changes)",
82
+ "- ask: <natural language> or /ask <natural language> - ask mode (analysis and answers; no code edits)",
83
+ "- Korean aliases 계획: and 질문: instead of plan:/ask: (colons `:` or full-width `:` allowed)",
84
+ "",
85
+ "Commands:",
86
+ "- /model <claude|codex|gemini>: Change the default model",
87
+ "- /status <job_id>: Check job status",
88
+ "- /branch [name]: Show or switch branches",
89
+ "- /pull: Pull all remote branch updates",
90
+ "- /rebase [branch]: Rebase a branch",
91
+ "- /pr [branch]: Open a GitHub PR for a branch",
92
+ "- /monitor <model|memory|branch|worktrees|code|project>: Monitoring",
93
+ "- /clear <branch|worktrees|memory>: Cleanup (confirmation required)",
94
+ "- /reports [count]: Conversation memory report",
95
+ "- /init: Reset this chat's settings",
96
+ "- /stop <job_id>: Stop a running job",
97
+ "- /fix <commit|source> [job_id]: Re-do a job's commit/source (amend + force-with-lease push)",
98
+ "- /start: Inline menu",
99
+ ]
100
+ )
101
+
102
+ HELP_AGENT_TOPIC = "\n".join(
103
+ [
104
+ "AGENTS mode (agent)",
105
+ "",
106
+ "Natural-language coding tasks. The agent can modify code in the current project; when there are "
107
+ "changes it can create or update a branch, commit, and push.",
108
+ "",
109
+ "Examples",
110
+ "- fix the login validation bug",
111
+ "- model: codex branch: remote-auth strengthen tests",
112
+ "- no commit just verify the doc wording",
113
+ "",
114
+ "A job is accepted after project/branch/model checks via `y`/`Y` or inline buttons.",
115
+ ]
116
+ )
117
+
118
+ HELP_PLAN_TOPIC = "\n".join(
119
+ [
120
+ "Plan mode (plan)",
121
+ "",
122
+ "Receive change plans only; no code edits. Like agent mode, a job is accepted after confirmation "
123
+ "(`y`/`Y` or inline buttons).",
124
+ "",
125
+ "Examples",
126
+ "- plan: summarize the login validation flow",
127
+ "- /plan model: codex list only API boundary risks",
128
+ "- 계획:refactor steps (full-width colon)",
129
+ "",
130
+ "See /help for more options.",
131
+ ]
132
+ )
133
+
134
+ HELP_ASK_TOPIC = "\n".join(
135
+ [
136
+ "Ask mode (ask)",
137
+ "",
138
+ "Answer questions using the repository; no code edits, commits, or pushes. Jobs are accepted like "
139
+ "agent mode after confirmation (`y`/`Y` or inline buttons).",
140
+ "",
141
+ "Examples",
142
+ "- ask: how do I run pytest in this project?",
143
+ "- /ask explain JobManager.run stages",
144
+ "- 질문:what this error line means",
145
+ "",
146
+ "See /help for more options.",
147
+ ]
148
+ )
149
+
150
+
151
+ def _button_rows(buttons: list[InlineButton], per_row: int = 2) -> list[list[InlineButton]]:
152
+ return [buttons[i : i + per_row] for i in range(0, len(buttons), per_row)]
153
+
154
+
155
+ def _job_button_label(job: Job) -> str:
156
+ return f"{job.id} ({job.status.value})"
157
+
158
+
159
+ def _confirmation_buttons_enabled(ctx: CommandContext) -> bool:
160
+ if ctx.advanced_settings_store is None:
161
+ return False
162
+ return ctx.advanced_settings_store.get().natural_job_confirmation_buttons_enabled
163
+
164
+
165
+ def effective_project_name_for_chat(ctx: CommandContext, chat_id: int) -> str | None:
166
+ _ = chat_id
167
+ return ctx.project_name
168
+
169
+
170
+ def effective_model_for_chat(ctx: CommandContext, chat_id: int, project_name: str | None) -> ModelName:
171
+ explicit = ctx.model_preferences.get_explicit(project_name, chat_id)
172
+ if explicit is not None:
173
+ return explicit
174
+ if project_name:
175
+ entry = ctx.project_registry.get(project_name)
176
+ if entry is not None:
177
+ return entry.default_model
178
+ return ctx.default_model
179
+
180
+
181
+ def effective_model_selection_for_chat(
182
+ ctx: CommandContext,
183
+ chat_id: int,
184
+ project_name: str | None,
185
+ ) -> ModelPreference:
186
+ explicit = ctx.model_preferences.get_explicit_selection(project_name, chat_id)
187
+ if explicit is not None:
188
+ return explicit
189
+ if project_name:
190
+ entry = ctx.project_registry.get(project_name)
191
+ if entry is not None:
192
+ return ModelPreference(entry.default_model)
193
+ return ModelPreference(ctx.default_model)
194
+
195
+
196
+ class TelegramCommand(ABC):
197
+ name: str
198
+ menu_text: str | None = None
199
+ description: str | None = None
200
+
201
+ @abstractmethod
202
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
203
+ raise NotImplementedError
204
+
205
+ def get_inline_buttons(
206
+ self,
207
+ message: TelegramMessage | None = None,
208
+ ctx: CommandContext | None = None,
209
+ ) -> list[list[InlineButton]] | None:
210
+ _ = (message, ctx)
211
+ return None
212
+
213
+
214
+ class ConfirmableCommand(TelegramCommand):
215
+ @abstractmethod
216
+ def confirm(
217
+ self,
218
+ message: TelegramMessage,
219
+ ctx: CommandContext,
220
+ pending: PendingConfirmation,
221
+ ) -> str:
222
+ raise NotImplementedError
@@ -0,0 +1,366 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from threading import Lock
5
+
6
+ from app.telegram.commands.base import (
7
+ CommandContext,
8
+ InlineButton,
9
+ TelegramCommand,
10
+ TelegramMessage,
11
+ _button_rows,
12
+ _cmd_evt,
13
+ effective_project_name_for_chat,
14
+ format_usage,
15
+ )
16
+
17
+
18
+ class BranchCommand(TelegramCommand):
19
+ name = "/branch"
20
+ description = "Show the current branch or switch to a local branch"
21
+
22
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
23
+ tokens = message.text.strip().split()
24
+ if len(tokens) > 2:
25
+ return format_usage("/branch", "/branch <branch>")
26
+
27
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
28
+ if not project_name:
29
+ return "No project is registered. Add one in /projects."
30
+ entry = ctx.project_registry.get(project_name)
31
+ if not entry or not entry.enabled:
32
+ return f"Project not found or disabled: {project_name}"
33
+
34
+ root = entry.root_path
35
+
36
+ if len(tokens) == 1:
37
+ try:
38
+ current = ctx.git_service.get_current_branch(root)
39
+ except RuntimeError as exc:
40
+ return f"/branch failed: {exc}"
41
+ return f"Project: {project_name}\nCurrent branch: {current}"
42
+
43
+ branch = tokens[1]
44
+ from app.git.service import GitWorktreeService
45
+
46
+ err = GitWorktreeService.validate_branch_token(branch)
47
+ if err:
48
+ return err
49
+
50
+ if not ctx.git_service.local_branch_exists(root, branch):
51
+ return f"Branch not found: `{branch}` (only local branches can be selected)"
52
+
53
+ try:
54
+ ctx.git_service.switch_branch(root, branch)
55
+ except RuntimeError as exc:
56
+ return f"/branch failed: {exc}"
57
+ return f"Project: {project_name}\n`{branch}` selected (git switch)."
58
+
59
+ def get_inline_buttons(
60
+ self,
61
+ message: TelegramMessage | None = None,
62
+ ctx: CommandContext | None = None,
63
+ ) -> list[list[InlineButton]] | None:
64
+ if message is None or ctx is None:
65
+ return None
66
+ if len(message.text.strip().split()) != 1:
67
+ return None
68
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
69
+ if not project_name:
70
+ return None
71
+ entry = ctx.project_registry.get(project_name)
72
+ if not entry or not entry.enabled:
73
+ return None
74
+ try:
75
+ branches = ctx.git_service.list_local_branches(entry.root_path)
76
+ except RuntimeError:
77
+ return None
78
+ if not isinstance(branches, list):
79
+ return None
80
+ buttons = [InlineButton(branch, f"/branch {branch}") for branch in branches]
81
+ return _button_rows(buttons, per_row=1) if buttons else None
82
+
83
+
84
+ class RebaseCommand(TelegramCommand):
85
+ name = "/rebase"
86
+ description = "Rebase a branch onto main and push it"
87
+ _inflight_guard = Lock()
88
+ _inflight_keys: set[tuple[str, str, str]] = set()
89
+
90
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
91
+ tokens = message.text.strip().split()
92
+ if len(tokens) > 2:
93
+ return format_usage("/rebase", "/rebase <branch>")
94
+ if len(tokens) == 2:
95
+ branch = tokens[1]
96
+ from app.git.service import GitWorktreeService
97
+
98
+ err = GitWorktreeService.validate_branch_token(branch)
99
+ if err:
100
+ return err
101
+ else:
102
+ branches = self._list_rebase_candidates(message, ctx)
103
+ if not branches:
104
+ return "No branch is available to rebase. Specify one with /rebase <branch>."
105
+ return "Choose a branch to rebase."
106
+
107
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
108
+ if not project_name:
109
+ return "No project is registered. Add one in /projects."
110
+ entry = ctx.project_registry.get(project_name)
111
+ if not entry or not entry.enabled:
112
+ return f"Project not found or disabled: {project_name}"
113
+
114
+ inflight_key = (str(entry.root_path.resolve()), ctx.git_remote_name, branch)
115
+ if not self._mark_inflight(inflight_key):
116
+ return f"`{branch}` rebase/merge is already running. Wait for the completion message."
117
+
118
+ ops_base = entry.worktree_base_dir / "_rebase_ops"
119
+ try:
120
+ if not self._remote_branch_exists(entry.root_path, branch, ctx):
121
+ return (
122
+ f"`{branch}` remote branch was not found on `{ctx.git_remote_name}`. "
123
+ "It may have already been rebased/merged and deleted, or not pushed yet."
124
+ )
125
+ summary = ctx.git_service.rebase_branch_onto_main_and_merge(
126
+ entry.root_path,
127
+ branch,
128
+ ctx.git_remote_name,
129
+ ops_base,
130
+ )
131
+ if self._delete_rebased_branch_enabled(ctx):
132
+ ctx.git_service.delete_remote_branches(entry.root_path, ctx.git_remote_name, [branch])
133
+ ctx.git_service.delete_local_branches(entry.root_path, [branch])
134
+ summary += f"\nDeleted branch `{branch}` locally and from `{ctx.git_remote_name}`."
135
+ return summary
136
+ except RuntimeError as exc:
137
+ return f"/rebase failed: {exc}"
138
+ finally:
139
+ self._clear_inflight(inflight_key)
140
+
141
+ @classmethod
142
+ def _mark_inflight(cls, key: tuple[str, str, str]) -> bool:
143
+ with cls._inflight_guard:
144
+ if key in cls._inflight_keys:
145
+ return False
146
+ cls._inflight_keys.add(key)
147
+ return True
148
+
149
+ @classmethod
150
+ def _clear_inflight(cls, key: tuple[str, str, str]) -> None:
151
+ with cls._inflight_guard:
152
+ cls._inflight_keys.discard(key)
153
+
154
+ def _delete_rebased_branch_enabled(self, ctx: CommandContext) -> bool:
155
+ if ctx.advanced_settings_store is None:
156
+ return True
157
+ return ctx.advanced_settings_store.get().delete_rebased_branch_enabled
158
+
159
+ def get_inline_buttons(
160
+ self,
161
+ message: TelegramMessage | None = None,
162
+ ctx: CommandContext | None = None,
163
+ ) -> list[list[InlineButton]] | None:
164
+ if message is None or ctx is None:
165
+ return None
166
+ if len(message.text.strip().split()) != 1:
167
+ return None
168
+ branches = self._list_rebase_candidates(message, ctx)
169
+ buttons = [InlineButton(branch, f"/rebase {branch}") for branch in branches]
170
+ return _button_rows(buttons, per_row=1) if buttons else None
171
+
172
+ def _list_rebase_candidates(self, message: TelegramMessage, ctx: CommandContext) -> list[str]:
173
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
174
+ if not project_name:
175
+ return []
176
+ entry = ctx.project_registry.get(project_name)
177
+ if not entry or not entry.enabled:
178
+ return []
179
+ try:
180
+ main_branch = ctx.git_service.resolve_integrate_branch(entry.root_path)
181
+ branches = ctx.git_service.list_local_branches(entry.root_path)
182
+ except RuntimeError:
183
+ return []
184
+ if not isinstance(branches, list):
185
+ return []
186
+ try:
187
+ remote_branch_list = ctx.git_service.list_remote_branches_matching(entry.root_path, ctx.git_remote_name, "")
188
+ except RuntimeError:
189
+ return []
190
+ if not isinstance(remote_branch_list, list):
191
+ return []
192
+ remote_branches = set(remote_branch_list)
193
+ excluded = {main_branch, "main", "master"}
194
+ return [branch for branch in branches if branch not in excluded and branch in remote_branches]
195
+
196
+ def _remote_branch_exists(self, root_path, branch: str, ctx: CommandContext) -> bool:
197
+ try:
198
+ remote_branches = ctx.git_service.list_remote_branches_matching(root_path, ctx.git_remote_name, "")
199
+ except RuntimeError:
200
+ return False
201
+ if not isinstance(remote_branches, list):
202
+ return False
203
+ return branch in remote_branches
204
+
205
+
206
+ def _branch_to_pr_title(branch: str) -> str:
207
+ slug = branch
208
+ if slug.startswith("remote-"):
209
+ slug = slug[len("remote-"):]
210
+ slug = re.sub(r"-\d{8}-\d{6}$", "", slug)
211
+ title = slug.replace("-", " ").strip()
212
+ if title and title.isascii():
213
+ return title
214
+ return "remote coder changes"
215
+
216
+
217
+ def _ascii_pr_text(text: str, fallback: str) -> str:
218
+ normalized = re.sub(r"\s+", " ", text).strip()
219
+ if normalized and normalized.isascii():
220
+ return normalized
221
+ return fallback
222
+
223
+
224
+ class PullCommand(TelegramCommand):
225
+ name = "/pull"
226
+ menu_text = "Pull all remote branch updates"
227
+ description = "Fetch remote branches and pull the current branch"
228
+
229
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
230
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
231
+ if not project_name:
232
+ return "No project is registered. Add one in /projects."
233
+
234
+ entry = ctx.project_registry.get(project_name)
235
+ if not entry or not entry.enabled:
236
+ return f"Project not found or disabled: {project_name}"
237
+
238
+ try:
239
+ summary = ctx.git_service.pull_repository(entry.root_path, ctx.git_remote_name)
240
+ _cmd_evt.info("pull success project=%s", project_name, chat_id=message.chat_id)
241
+ return f"✅ {project_name}: {summary}"
242
+ except RuntimeError as exc:
243
+ _cmd_evt.error("pull failed project=%s err=%s", project_name, str(exc), chat_id=message.chat_id)
244
+ return f"❌ {project_name} pull failed: {exc}"
245
+
246
+
247
+ class PrCommand(TelegramCommand):
248
+ name = "/pr"
249
+ menu_text = "Choose a branch for the PR."
250
+ description = "Create a GitHub Pull Request for a selected branch"
251
+
252
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
253
+ tokens = message.text.strip().split()
254
+ if len(tokens) > 2:
255
+ return format_usage("/pr", "/pr <branch>")
256
+ if len(tokens) == 2:
257
+ branch = tokens[1]
258
+ else:
259
+ branches = self._list_pr_candidates(message, ctx)
260
+ if not branches:
261
+ return "No branch is available for PR creation. Specify one with /pr <branch>."
262
+ return "Choose a branch for the PR."
263
+ from app.git.service import GitWorktreeService
264
+
265
+ err = GitWorktreeService.validate_branch_token(branch)
266
+ if err:
267
+ return err
268
+
269
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
270
+ if not project_name:
271
+ return "No project is registered. Add one in /projects."
272
+ entry = ctx.project_registry.get(project_name)
273
+ if not entry or not entry.enabled:
274
+ return f"Project not found or disabled: {project_name}"
275
+
276
+ try:
277
+ base_branch = ctx.git_service.resolve_integrate_branch(entry.root_path)
278
+ except RuntimeError as exc:
279
+ return f"/pr failed: {exc}"
280
+
281
+ title, body = self._build_pr_content(branch, project_name, message.chat_id, ctx)
282
+
283
+ try:
284
+ pr_url = ctx.git_service.create_github_pr(
285
+ entry.root_path,
286
+ branch,
287
+ base_branch,
288
+ title,
289
+ body,
290
+ )
291
+ except RuntimeError as exc:
292
+ return f"/pr failed: {exc}"
293
+
294
+ return f"PR created:\n{pr_url}"
295
+
296
+ def get_inline_buttons(
297
+ self,
298
+ message: TelegramMessage | None = None,
299
+ ctx: CommandContext | None = None,
300
+ ) -> list[list[InlineButton]] | None:
301
+ if message is None or ctx is None:
302
+ return None
303
+ if len(message.text.strip().split()) != 1:
304
+ return None
305
+ branches = self._list_pr_candidates(message, ctx)
306
+ buttons = [InlineButton(branch, f"/pr {branch}") for branch in branches]
307
+ return _button_rows(buttons, per_row=1) if buttons else None
308
+
309
+ def _list_pr_candidates(self, message: TelegramMessage, ctx: CommandContext) -> list[str]:
310
+ project_name = effective_project_name_for_chat(ctx, message.chat_id)
311
+ if not project_name:
312
+ return []
313
+ entry = ctx.project_registry.get(project_name)
314
+ if not entry or not entry.enabled:
315
+ return []
316
+ try:
317
+ main_branch = ctx.git_service.resolve_integrate_branch(entry.root_path)
318
+ branches = ctx.git_service.list_local_branches(entry.root_path)
319
+ except RuntimeError:
320
+ return []
321
+ if not isinstance(branches, list):
322
+ return []
323
+ from app.git.service import GitWorktreeService
324
+
325
+ excluded = {main_branch, "main", "master"}
326
+ return [
327
+ branch
328
+ for branch in branches
329
+ if branch not in excluded and GitWorktreeService.validate_branch_token(branch) is None
330
+ ]
331
+
332
+ def _build_pr_content(
333
+ self,
334
+ branch: str,
335
+ project_name: str,
336
+ chat_id: int,
337
+ ctx: CommandContext,
338
+ ) -> tuple[str, str]:
339
+ if ctx.conversation_store is None:
340
+ return _branch_to_pr_title(branch), f"Work branch: `{branch}`"
341
+
342
+ entries = ctx.conversation_store.get_entries_for_branch(project_name, chat_id, branch)
343
+ if not entries:
344
+ return _branch_to_pr_title(branch), f"Work branch: `{branch}`"
345
+
346
+ title = _ascii_pr_text(entries[0][0], _branch_to_pr_title(branch))[:70].rstrip()
347
+
348
+ body_parts: list[str] = [f"Work branch: `{branch}`\n\n", "## Work request\n"]
349
+ for i, (user_text, job_result) in enumerate(entries, 1):
350
+ if len(entries) > 1:
351
+ body_parts.append(f"### Request {i}\n")
352
+ safe_user_text = _ascii_pr_text(
353
+ user_text,
354
+ "Request omitted because it contains non-ASCII text.",
355
+ )
356
+ body_parts.append(f"**Request:** {safe_user_text}\n")
357
+ if job_result:
358
+ safe_job_result = _ascii_pr_text(
359
+ job_result,
360
+ "AI result omitted because it contains non-ASCII text.",
361
+ )
362
+ body_parts.append(f"\n**AI result:**\n{safe_job_result}\n")
363
+ if i < len(entries):
364
+ body_parts.append("\n---\n")
365
+
366
+ return title, "\n".join(body_parts)