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
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
|
app/git/branch_naming.py
ADDED
|
@@ -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
|
+
)
|