forgeo-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
forgeo/git.py ADDED
@@ -0,0 +1,100 @@
1
+ """Git operations for Forgeo.
2
+
3
+ Everything happens on a single branch (``main`` by default): commit whatever
4
+ the agent changed, then push. No branches, no PRs, no merge strategies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import shutil
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+
15
+ class GitError(RuntimeError):
16
+ """Raised when a git command cannot be executed or fails."""
17
+
18
+
19
+ class GitManager:
20
+ """Run git commands against a single repository (via the git CLI)."""
21
+
22
+ def __init__(
23
+ self, repo_path: str | Path, *, timeout_seconds: float = 120
24
+ ) -> None:
25
+ self.repo_path = Path(repo_path)
26
+ self.timeout_seconds = timeout_seconds
27
+
28
+ def _run(self, *args: str, check: bool = True) -> str:
29
+ """Execute ``git -C <repo> <args>`` and return combined output."""
30
+ if not shutil.which("git"):
31
+ raise GitError("the 'git' executable was not found on PATH")
32
+ try:
33
+ proc = subprocess.run(
34
+ ["git", "-C", str(self.repo_path), *args],
35
+ capture_output=True,
36
+ text=True,
37
+ check=False,
38
+ timeout=self.timeout_seconds,
39
+ )
40
+ except subprocess.TimeoutExpired as exc:
41
+ raise GitError(f"git {args[0]} timed out") from exc
42
+ if check and proc.returncode != 0:
43
+ raise GitError(
44
+ f"git {args[0]} failed (exit {proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}"
45
+ )
46
+ return proc.stdout.strip()
47
+
48
+ def ensure_branch(self, branch: str) -> None:
49
+ """Switch to ``branch``, creating it from HEAD when it does not exist."""
50
+ try:
51
+ self._run("rev-parse", "--verify", f"refs/heads/{branch}")
52
+ except GitError:
53
+ self._run("switch", "-c", branch)
54
+ return
55
+ self._run("switch", branch)
56
+
57
+ def is_clean(self) -> bool:
58
+ """Return whether the working tree has no changes."""
59
+ return not bool(self._run("status", "--porcelain"))
60
+
61
+ def commit_all(self, message: str) -> str | None:
62
+ """Stage all changes and commit; returns the short sha, or ``None`` if nothing to commit."""
63
+ self._run("add", "-A")
64
+ if not bool(self._run("status", "--porcelain")):
65
+ return None
66
+ self._run("commit", "-m", message)
67
+ return self._run("rev-parse", "--short", "HEAD")
68
+
69
+ def push(self, remote: str, branch: str) -> None:
70
+ """Push ``branch`` to ``remote``."""
71
+ self._run("push", remote, branch)
72
+
73
+ def reset_hard(self) -> None:
74
+ """Discard all uncommitted changes in the working tree.
75
+
76
+ Reverts tracked files and removes untracked ones (Forgeo only
77
+ ever discards work after having verified the tree was clean, so
78
+ everything removed here was produced by the agent).
79
+ """
80
+ self._run("reset", "--hard", "HEAD")
81
+ self._run("clean", "-fd")
82
+
83
+ # ------------------------------------------------------------------ #
84
+ # Async wrappers (run git in a worker thread) #
85
+ # ------------------------------------------------------------------ #
86
+
87
+ async def a_ensure_branch(self, branch: str) -> None:
88
+ await asyncio.to_thread(self.ensure_branch, branch)
89
+
90
+ async def a_is_clean(self) -> bool:
91
+ return await asyncio.to_thread(self.is_clean)
92
+
93
+ async def a_commit_all(self, message: str) -> str | None:
94
+ return await asyncio.to_thread(self.commit_all, message)
95
+
96
+ async def a_push(self, remote: str, branch: str) -> None:
97
+ await asyncio.to_thread(self.push, remote, branch)
98
+
99
+ async def a_reset_hard(self) -> None:
100
+ await asyncio.to_thread(self.reset_hard)
forgeo/instances.py ADDED
@@ -0,0 +1,187 @@
1
+ """Instance registry: a stable name for every configured forgeo.
2
+
3
+ Each forgeo is configured by its own ``forgeo.yaml`` and runs as its own
4
+ daemon process, but nothing on the host knows how many factories exist or
5
+ how to find their configs. The registry gives every forgeo a unique name
6
+ mapped to the absolute path of its ``forgeo.yaml``, so the CLI can resolve
7
+ a config by name and a single command can enumerate every forgeo.
8
+
9
+ The registry is a YAML file mapping instance names to config paths. It
10
+ lives at ``$FORGEO_REGISTRY`` or ``~/.config/forgeo/instances.yaml`` and is
11
+ created on the first write. Writes are atomic (temp file + rename), so a
12
+ crash mid-write never corrupts the registry.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ import yaml
23
+
24
+ from forgeo.config import load_config
25
+ from forgeo.daemon import is_lock_held
26
+ from forgeo.io import atomic_write_text
27
+ from forgeo.models import ForgeoConfig
28
+
29
+ INSTANCE_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
30
+
31
+ DEFAULT_REGISTRY = Path.home() / ".config" / "forgeo" / "instances.yaml"
32
+
33
+
34
+ def registry_path() -> Path:
35
+ """Path of the registry file: ``$FORGEO_REGISTRY`` or the default."""
36
+ env = os.environ.get("FORGEO_REGISTRY")
37
+ if env:
38
+ return Path(env).expanduser()
39
+ return DEFAULT_REGISTRY
40
+
41
+
42
+ def load_registry() -> dict[str, str]:
43
+ """Load the registry as ``{instance name: absolute config path}``.
44
+
45
+ A missing or unreadable file reads as an empty registry.
46
+ """
47
+ path = registry_path()
48
+ if not path.exists():
49
+ return {}
50
+ payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
51
+ if not isinstance(payload, dict):
52
+ return {}
53
+ return {
54
+ str(name): str(config)
55
+ for name, config in payload.items()
56
+ if isinstance(name, str) and isinstance(config, str)
57
+ }
58
+
59
+
60
+ def save_registry(registry: dict[str, str]) -> None:
61
+ """Persist ``registry`` atomically (temp file + rename)."""
62
+ atomic_write_text(
63
+ registry_path(),
64
+ yaml.safe_dump(dict(sorted(registry.items())), sort_keys=False),
65
+ )
66
+
67
+
68
+ def resolve_instance(name: str) -> Path | None:
69
+ """Absolute config path for ``name``, or ``None`` when it is not registered."""
70
+ config_path = load_registry().get(name)
71
+ return Path(config_path) if config_path else None
72
+
73
+
74
+ def _validate_name(name: str) -> None:
75
+ """Raise ``ValueError`` unless ``name`` matches the allowed pattern."""
76
+ if not INSTANCE_NAME_RE.fullmatch(name):
77
+ raise ValueError(
78
+ f"invalid instance name {name!r}: must match ^[a-zA-Z0-9._-]+$"
79
+ )
80
+
81
+
82
+ def add_instance(name: str, config_path: str | Path) -> str:
83
+ """Register ``name`` -> the absolute path of ``config_path``.
84
+
85
+ Validates that the name is well-formed, that it is not already
86
+ registered, and that the config file loads. Returns ``name``.
87
+
88
+ Raises:
89
+ ValueError: Invalid or duplicate name, or a config that fails to
90
+ load (a bad payload raises pydantic's ``ValidationError``).
91
+ FileNotFoundError: The config file does not exist.
92
+ """
93
+ _validate_name(name)
94
+ registry = load_registry()
95
+ if name in registry:
96
+ raise ValueError(f"instance {name!r} is already registered")
97
+ absolute = Path(config_path).expanduser().resolve()
98
+ load_config(absolute)
99
+ registry[name] = str(absolute)
100
+ save_registry(registry)
101
+ return name
102
+
103
+
104
+ def ensure_registered(name: str, config_path: str | Path) -> bool:
105
+ """Register ``name`` -> the absolute path of ``config_path`` when missing.
106
+
107
+ Unlike :func:`add_instance` this never raises: a name that is already
108
+ registered (whatever it points at), fails the instance-name pattern, or
109
+ maps to a config that cannot be loaded is left untouched. Returns
110
+ ``True`` only when the instance was newly registered.
111
+ """
112
+ if name in load_registry():
113
+ return False
114
+ if not INSTANCE_NAME_RE.fullmatch(name):
115
+ return False
116
+ try:
117
+ add_instance(name, config_path)
118
+ except (ValueError, FileNotFoundError, yaml.YAMLError):
119
+ return False
120
+ return True
121
+
122
+
123
+ def remove_instance(name: str) -> bool:
124
+ """Unregister ``name``; never touches its config file or repository.
125
+
126
+ Returns ``True`` when the instance was registered and removed.
127
+ """
128
+ registry = load_registry()
129
+ if name not in registry:
130
+ return False
131
+ del registry[name]
132
+ save_registry(registry)
133
+ return True
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class InstanceInfo:
138
+ """One registered instance plus its live state."""
139
+
140
+ name: str
141
+ config_path: Path
142
+ repo: Path | None
143
+ daemon_running: bool
144
+ config: ForgeoConfig | None = None
145
+
146
+
147
+ def _load_info(name: str, config_path: Path) -> InstanceInfo:
148
+ """Build the live state for one registered instance."""
149
+ try:
150
+ config = load_config(config_path)
151
+ except (ValueError, OSError, yaml.YAMLError):
152
+ return InstanceInfo(name, config_path, repo=None, daemon_running=False)
153
+ return InstanceInfo(
154
+ name=name,
155
+ config_path=config_path,
156
+ repo=config.repo,
157
+ daemon_running=is_lock_held(config.backlog.with_suffix(".lock")),
158
+ config=config,
159
+ )
160
+
161
+
162
+ def get_instance(name: str) -> InstanceInfo | None:
163
+ """Build the live state for one registered instance, or ``None``.
164
+
165
+ Equivalent to looking up a single entry of :func:`list_instances`; an
166
+ instance whose config can no longer be loaded is still returned, with
167
+ ``repo=None``, ``daemon_running=False`` and ``config=None``.
168
+ """
169
+ config_path = resolve_instance(name)
170
+ if config_path is None:
171
+ return None
172
+ return _load_info(name, config_path)
173
+
174
+
175
+ def list_instances() -> list[InstanceInfo]:
176
+ """Return every registered instance, sorted by name.
177
+
178
+ Each entry carries the config path, the configured repository, and
179
+ whether that instance's daemon currently holds its backlog lock. An
180
+ instance whose config can no longer be loaded is still listed, with
181
+ ``repo=None`` and ``daemon_running=False``.
182
+ """
183
+ registry = load_registry()
184
+ return [
185
+ _load_info(name, Path(config_path))
186
+ for name, config_path in sorted(registry.items())
187
+ ]
forgeo/io.py ADDED
@@ -0,0 +1,30 @@
1
+ """Small filesystem helpers shared across Forgeo modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+
10
+ def atomic_write_text(path: str | Path, content: str) -> None:
11
+ """Atomically write ``content`` to ``path`` (temp file + rename).
12
+
13
+ A crash mid-write never leaves a partial file at ``path``: the content is
14
+ first written to a temporary file in the same directory and then moved
15
+ over ``path`` with ``os.replace``. The parent directory is created when
16
+ missing. The temporary file is cleaned up if the write fails.
17
+ """
18
+ path = Path(path)
19
+ path.parent.mkdir(parents=True, exist_ok=True)
20
+ fd, tmp_name = tempfile.mkstemp(
21
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
22
+ )
23
+ try:
24
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
25
+ handle.write(content)
26
+ os.replace(tmp_name, path)
27
+ except BaseException:
28
+ if os.path.exists(tmp_name):
29
+ os.unlink(tmp_name)
30
+ raise
forgeo/models.py ADDED
@@ -0,0 +1,252 @@
1
+ """The only data contracts Forgeo needs.
2
+
3
+ A task lives in the backlog, gets executed by the agent, and changes status
4
+ exactly once per run. A forgeo config describes one repository and how the
5
+ forgeo should work on it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import enum
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+
14
+ from pydantic import BaseModel, Field, field_validator, model_validator
15
+
16
+ DEFAULT_REFACTOR_PROMPT = (
17
+ "Review the codebase for improvement opportunities that do not change "
18
+ "behavior: dead code, duplication, overly complex functions, missing "
19
+ "tests, outdated comments. Apply the safe improvements you find and run "
20
+ "the test suite to verify nothing broke. If nothing needs refactoring, "
21
+ "make no changes."
22
+ )
23
+
24
+
25
+ def _validate_agent_command(value: str | list[str] | None) -> str | list[str] | None:
26
+ """Shared validation: an agent command must be a non-blank string or list."""
27
+ if value is None:
28
+ return value
29
+ if isinstance(value, str) and not value.strip():
30
+ raise ValueError("agent_command must not be blank")
31
+ if isinstance(value, list) and not value:
32
+ raise ValueError("agent_command must not be an empty list")
33
+ return value
34
+
35
+
36
+ def _utcnow() -> datetime:
37
+ return datetime.now(UTC)
38
+
39
+
40
+ class TaskStatus(str, enum.Enum):
41
+ OPEN = "OPEN"
42
+ BLOCKED = "BLOCKED"
43
+ COMPLETED = "COMPLETED"
44
+ FAILED = "FAILED"
45
+
46
+
47
+ class ExecutionStatus(str, enum.Enum):
48
+ SUCCESS = "SUCCESS"
49
+ BLOCKED = "BLOCKED"
50
+ ERROR = "ERROR"
51
+
52
+
53
+ class SandboxMode(str, enum.Enum):
54
+ """How the agent process is isolated from the host machine.
55
+
56
+ ``NONE`` runs the agent directly on the host with the user's full
57
+ privileges (the default, unchanged behavior); ``DOCKER`` runs the agent
58
+ inside a ``docker run --rm`` container.
59
+ """
60
+
61
+ NONE = "none"
62
+ DOCKER = "docker"
63
+
64
+
65
+ class RunKind(str, enum.Enum):
66
+ """What kind of work a finished cycle performed."""
67
+
68
+ TASK = "task"
69
+ REFACTOR = "refactor"
70
+
71
+
72
+ class RunOutcome(str, enum.Enum):
73
+ """The outcome of a finished cycle.
74
+
75
+ ``SUCCESS``, ``BLOCKED`` and ``ERROR`` mirror the agent execution status;
76
+ the remaining values cover cycles that paused or never ran the agent.
77
+ """
78
+
79
+ SUCCESS = "SUCCESS"
80
+ BLOCKED = "BLOCKED"
81
+ ERROR = "ERROR"
82
+ PAUSED = "PAUSED"
83
+ DIRTY = "DIRTY"
84
+ SKIPPED = "SKIPPED"
85
+
86
+
87
+ class RunRecord(BaseModel):
88
+ """A durable, queryable record of one finished forgeo cycle.
89
+
90
+ One JSON object per line in ``runs.jsonl``, next to the backlog.
91
+ """
92
+
93
+ started_at: datetime
94
+ finished_at: datetime
95
+ kind: RunKind | None = None
96
+ task_id: str | None = None
97
+ task_title: str | None = None
98
+ outcome: RunOutcome
99
+ agent_exit_code: int | None = None
100
+ commit_sha: str | None = None
101
+ duration_seconds: float
102
+
103
+
104
+ class Task(BaseModel):
105
+ """A unit of work Forgeo executes with the coding agent."""
106
+
107
+ id: str
108
+ title: str
109
+ description: str
110
+ dependencies: list[str] = Field(default_factory=list)
111
+ acceptance_criteria: list[str] = Field(default_factory=list)
112
+ files_to_modify: list[str] = Field(default_factory=list)
113
+ status: TaskStatus = TaskStatus.OPEN
114
+ created_at: datetime = Field(default_factory=_utcnow)
115
+ updated_at: datetime = Field(default_factory=_utcnow)
116
+ agent_command: str | list[str] | None = Field(default=None)
117
+ agent_timeout_seconds: float | None = Field(default=None, gt=0)
118
+
119
+ @property
120
+ def instruction(self) -> str:
121
+ """The full instruction handed to the agent for this task."""
122
+ lines = [self.title, ""]
123
+ if self.description:
124
+ lines.append(self.description)
125
+ if self.acceptance_criteria:
126
+ lines.append("Acceptance criteria:")
127
+ lines.extend(f"- {criterion}" for criterion in self.acceptance_criteria)
128
+ return "\n".join(lines)
129
+
130
+ @field_validator("description")
131
+ @classmethod
132
+ def _description_not_blank(cls, value: str) -> str:
133
+ if not value.strip():
134
+ raise ValueError("description must be a non-blank string")
135
+ return value
136
+
137
+ @field_validator("agent_command")
138
+ @classmethod
139
+ def _command_not_blank(cls, value: str | list[str] | None) -> str | list[str] | None:
140
+ return _validate_agent_command(value)
141
+
142
+
143
+ class ExecutionResult(BaseModel):
144
+ """The outcome of one agent run."""
145
+
146
+ status: ExecutionStatus
147
+ output_logs: list[str] = Field(default_factory=list)
148
+ questions: list[str] = Field(default_factory=list)
149
+ error: str | None = None
150
+ exit_code: int | None = None
151
+
152
+
153
+ class RepoContext(BaseModel):
154
+ """Where the agent works: the repository checkout and its branch."""
155
+
156
+ repo_path: Path = Path(".")
157
+ branch: str = "main"
158
+
159
+
160
+ class ForgeoConfig(BaseModel):
161
+ """Everything needed to run one forgeo on one repository.
162
+
163
+ Attributes:
164
+ name: Display name of this forgeo (used in logs and commit messages).
165
+ repo: Path of the git repository Forgeo works on.
166
+ interval_minutes: How often a scheduled run happens.
167
+ backlog: Path of the JSON backlog file (created on first use).
168
+ blocker_file: Where ``BLOCKER.md`` is written when the agent needs
169
+ human input. Keep it outside the repository so it is never
170
+ committed.
171
+ agent_command: Shell command (or argv list) that runs the coding
172
+ agent. Exit 0 = success, ``blocked_exit_code`` = needs human
173
+ input, anything else = error. The task is available to the
174
+ process as the ``FORGEO_TASK`` environment variable.
175
+ agent_timeout_seconds: Kill the agent process after this many seconds
176
+ (``None`` = never; a run that overruns the interval simply makes
177
+ the next iteration skip).
178
+ agent_env: Extra environment variables for the agent process.
179
+ agent_sandbox: Isolation mode for the agent process: ``none`` (the
180
+ default, runs directly on the host) or ``docker`` (runs inside a
181
+ container). See the README for the docker image expectations.
182
+ agent_sandbox_image: Container image used when ``agent_sandbox`` is
183
+ ``docker``. Required in that mode; it must contain the agent CLI
184
+ and a POSIX shell.
185
+ agent_sandbox_network: Docker network for the sandboxed agent
186
+ (``--network``). Default ``none`` (networking disabled); set to
187
+ e.g. ``bridge`` or ``host`` to re-enable it.
188
+ agent_sandbox_mounts: Host paths mounted read-only into the sandboxed
189
+ container at the same absolute path (agent credentials/config).
190
+ Nothing is mounted unless listed here.
191
+ blocked_exit_code: Exit code the agent uses to signal that it needs
192
+ human input.
193
+ remote: Git remote to push to (e.g. ``origin``). When omitted the
194
+ forgeo only commits locally.
195
+ branch: Branch everything is committed to (default ``main``).
196
+ git_timeout_seconds: Kill a git subprocess after this many seconds
197
+ (default 120). Raise for slow remotes.
198
+ refactor_prompt: Instruction used for the refactoring run that
199
+ happens when the backlog has no runnable task.
200
+ log_file: Where the scheduled forgeo writes its log.
201
+ telegram_bot_token: Telegram bot token for blocked-run
202
+ notifications. Disabled unless ``telegram_chat_id`` is also set.
203
+ telegram_chat_id: Chat ID that receives blocked-run notifications.
204
+ Disabled unless ``telegram_bot_token`` is also set.
205
+ """
206
+
207
+ name: str = "forgeo"
208
+ repo: Path = Field(default=Path("."))
209
+ interval_minutes: int = Field(default=60, ge=1)
210
+ backlog: Path = Field(default=Path("backlog.json"))
211
+ blocker_file: Path = Field(default=Path("BLOCKER.md"))
212
+ agent_command: str | list[str]
213
+ agent_timeout_seconds: float | None = Field(default=None, gt=0)
214
+ agent_env: dict[str, str] = Field(default_factory=dict)
215
+ agent_sandbox: SandboxMode = SandboxMode.NONE
216
+ agent_sandbox_image: str | None = None
217
+ agent_sandbox_network: str = "none"
218
+ agent_sandbox_mounts: list[str] = Field(default_factory=list)
219
+ blocked_exit_code: int = Field(default=2)
220
+ remote: str | None = None
221
+ branch: str = "main"
222
+ git_timeout_seconds: float = Field(default=120, gt=0)
223
+ refactor_prompt: str = DEFAULT_REFACTOR_PROMPT
224
+ log_file: str = "forgeo.log"
225
+ telegram_bot_token: str | None = None
226
+ telegram_chat_id: str | None = None
227
+
228
+ @field_validator("agent_command")
229
+ @classmethod
230
+ def _command_not_blank(cls, value: str | list[str]) -> str | list[str] | None:
231
+ return _validate_agent_command(value)
232
+
233
+ @field_validator("agent_sandbox_network")
234
+ @classmethod
235
+ def _network_not_blank(cls, value: str) -> str:
236
+ if not value.strip():
237
+ raise ValueError("agent_sandbox_network must not be blank")
238
+ return value
239
+
240
+ @field_validator("agent_sandbox_mounts")
241
+ @classmethod
242
+ def _mounts_not_blank(cls, value: list[str]) -> list[str]:
243
+ for mount in value:
244
+ if not mount.strip():
245
+ raise ValueError("agent_sandbox_mounts must not contain blank paths")
246
+ return value
247
+
248
+ @model_validator(mode="after")
249
+ def _docker_requires_image(self) -> ForgeoConfig:
250
+ if self.agent_sandbox is SandboxMode.DOCKER and not (self.agent_sandbox_image or "").strip():
251
+ raise ValueError("agent_sandbox_image is required when agent_sandbox is 'docker'")
252
+ return self
forgeo/notify.py ADDED
@@ -0,0 +1,77 @@
1
+ """Optional Telegram notifications for blocked runs.
2
+
3
+ The feature is disabled unless both ``telegram_bot_token`` and
4
+ ``telegram_chat_id`` are set in Forgeo config. Uses only the standard
5
+ library and never raises: a failing notification is logged as a warning and
6
+ the outcome of Forgeo cycle is left unchanged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import urllib.parse
13
+ import urllib.request
14
+ from dataclasses import dataclass
15
+
16
+ from forgeo.models import ForgeoConfig
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ SEND_MESSAGE_URL = "https://api.telegram.org/bot{token}/sendMessage"
21
+ REQUEST_TIMEOUT = 5.0
22
+ REASON_LINES = 8
23
+
24
+
25
+ @dataclass
26
+ class BlockedNotice:
27
+ """The payload of one blocked-run notification."""
28
+
29
+ task_id: str
30
+ task_title: str
31
+ reason: str
32
+
33
+
34
+ def blocked_notice_text(forgeo_name: str, notice: BlockedNotice) -> str:
35
+ """Compose the message body: forgeo name, task id/title, and the reason."""
36
+ lines = [
37
+ f"\u26d4 {forgeo_name} is blocked",
38
+ f"Task {notice.task_id}: {notice.task_title}",
39
+ "",
40
+ *notice.reason.splitlines()[:REASON_LINES],
41
+ ]
42
+ return "\n".join(lines)
43
+
44
+
45
+ def send_blocked_notice(config: ForgeoConfig, notice: BlockedNotice) -> bool:
46
+ """Send one ``sendMessage`` request; returns True when delivered.
47
+
48
+ Returns ``False`` without a warning when the feature is not configured
49
+ (no notification is expected). Returns ``False`` and logs a warning when
50
+ Telegram rejects or is unreachable — a notification failure never changes
51
+ the outcome of Forgeo cycle.
52
+ """
53
+ if not config.telegram_bot_token or not config.telegram_chat_id:
54
+ return False
55
+ payload = {
56
+ "chat_id": config.telegram_chat_id,
57
+ "text": blocked_notice_text(config.name, notice),
58
+ }
59
+ url = SEND_MESSAGE_URL.format(token=config.telegram_bot_token)
60
+ request = urllib.request.Request(
61
+ url,
62
+ data=urllib.parse.urlencode(payload).encode("utf-8"),
63
+ )
64
+ try:
65
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
66
+ if response.status != 200:
67
+ logger.warning(
68
+ "Telegram notification failed: HTTP %s from %s.",
69
+ response.status,
70
+ url,
71
+ )
72
+ return False
73
+ except (OSError, ValueError) as exc:
74
+ logger.warning("Telegram notification failed: %s", exc)
75
+ return False
76
+ logger.info("Telegram notification sent for blocked run of task %s.", notice.task_id)
77
+ return True