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.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- 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)
|