codex-autorunner 0.1.2__py3-none-any.whl → 1.0.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.
Files changed (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -9,7 +9,6 @@ import re
9
9
  import time
10
10
  from contextlib import suppress
11
11
  from dataclasses import dataclass
12
- from os import getenv
13
12
  from pathlib import Path
14
13
  from typing import TYPE_CHECKING, Any, Optional
15
14
 
@@ -26,20 +25,14 @@ from .....agents.opencode.runtime import (
26
25
  opencode_missing_env,
27
26
  split_model_id,
28
27
  )
29
- from .....core.config import load_hub_config, load_repo_config
30
28
  from .....core.logging_utils import log_event
31
29
  from .....core.state import now_iso
32
30
  from .....core.text_delta_coalescer import TextDeltaCoalescer
33
- from .....core.utils import canonicalize_path
34
- from .....integrations.github.service import GitHubError, GitHubService
35
- from .....manifest import load_manifest
36
31
  from ....app_server.client import (
37
32
  CodexAppServerDisconnected,
38
33
  )
39
34
  from ...adapter import (
40
35
  InlineButton,
41
- PrFlowStartCallback,
42
- TelegramCallbackQuery,
43
36
  TelegramMessage,
44
37
  build_inline_keyboard,
45
38
  encode_cancel_callback,
@@ -47,7 +40,6 @@ from ...adapter import (
47
40
  from ...config import AppServerUnavailableError
48
41
  from ...constants import (
49
42
  MAX_TOPIC_THREAD_HISTORY,
50
- OPENCODE_TURN_TIMEOUT_SECONDS,
51
43
  PLACEHOLDER_TEXT,
52
44
  QUEUED_PLACEHOLDER_TEXT,
53
45
  RESUME_PREVIEW_ASSISTANT_LIMIT,
@@ -411,7 +403,7 @@ class GitHubCommands(SharedHelpers):
411
403
  result = await self._wait_for_turn_result(
412
404
  setup.client,
413
405
  turn_context.turn_handle,
414
- timeout_seconds=self._config.app_server_turn_timeout_seconds,
406
+ timeout_seconds=self._config.agent_turn_timeout_seconds.get("codex"),
415
407
  topic_key=topic_key,
416
408
  chat_id=message.chat_id,
417
409
  thread_id=message.thread_id,
@@ -1212,6 +1204,7 @@ class GitHubCommands(SharedHelpers):
1212
1204
  setup.client,
1213
1205
  session_id=setup.review_session_id,
1214
1206
  workspace_path=str(setup.workspace_root),
1207
+ model_payload=model_payload,
1215
1208
  progress_session_ids=watched_session_ids,
1216
1209
  permission_policy=setup.permission_policy,
1217
1210
  permission_handler=(
@@ -1226,9 +1219,12 @@ class GitHubCommands(SharedHelpers):
1226
1219
  )
1227
1220
  with suppress(asyncio.TimeoutError):
1228
1221
  await asyncio.wait_for(ready_event.wait(), timeout=2.0)
1229
- timeout_task = asyncio.create_task(
1230
- asyncio.sleep(OPENCODE_TURN_TIMEOUT_SECONDS)
1222
+ timeout_seconds = self._config.agent_turn_timeout_seconds.get(
1223
+ "opencode"
1231
1224
  )
1225
+ timeout_task: Optional[asyncio.Task] = None
1226
+ if timeout_seconds is not None and timeout_seconds > 0:
1227
+ timeout_task = asyncio.create_task(asyncio.sleep(timeout_seconds))
1232
1228
  command_task = asyncio.create_task(
1233
1229
  setup.client.send_command(
1234
1230
  setup.review_session_id,
@@ -1240,45 +1236,47 @@ class GitHubCommands(SharedHelpers):
1240
1236
  try:
1241
1237
  await command_task
1242
1238
  except Exception as exc:
1243
- timeout_task.cancel()
1244
- with suppress(asyncio.CancelledError):
1245
- await timeout_task
1239
+ if timeout_task is not None:
1240
+ timeout_task.cancel()
1241
+ with suppress(asyncio.CancelledError):
1242
+ await timeout_task
1246
1243
  output_task.cancel()
1247
1244
  with suppress(asyncio.CancelledError):
1248
1245
  await output_task
1249
1246
  raise exc
1250
- done, _pending = await asyncio.wait(
1251
- {output_task, timeout_task},
1252
- return_when=asyncio.FIRST_COMPLETED,
1253
- )
1254
- if timeout_task in done:
1255
- runtime.interrupt_requested = True
1256
- await _abort_opencode()
1257
- output_task.cancel()
1258
- with suppress(asyncio.CancelledError):
1259
- await output_task
1247
+ if timeout_task is not None:
1248
+ done, _pending = await asyncio.wait(
1249
+ {output_task, timeout_task},
1250
+ return_when=asyncio.FIRST_COMPLETED,
1251
+ )
1252
+ if timeout_task in done:
1253
+ runtime.interrupt_requested = True
1254
+ await _abort_opencode()
1255
+ output_task.cancel()
1256
+ with suppress(asyncio.CancelledError):
1257
+ await output_task
1258
+ timeout_task.cancel()
1259
+ with suppress(asyncio.CancelledError):
1260
+ await timeout_task
1261
+ turn_context.turn_elapsed_seconds = (
1262
+ time.monotonic() - turn_started_at
1263
+ if turn_started_at is not None
1264
+ else None
1265
+ )
1266
+ failure_message = "OpenCode review timed out."
1267
+ response_sent = await self._deliver_turn_response(
1268
+ chat_id=message.chat_id,
1269
+ thread_id=message.thread_id,
1270
+ reply_to=message.message_id,
1271
+ placeholder_id=placeholder_id,
1272
+ response=failure_message,
1273
+ )
1274
+ if response_sent:
1275
+ await self._delete_message(message.chat_id, placeholder_id)
1276
+ return turn_context, None
1260
1277
  timeout_task.cancel()
1261
1278
  with suppress(asyncio.CancelledError):
1262
1279
  await timeout_task
1263
- turn_context.turn_elapsed_seconds = (
1264
- time.monotonic() - turn_started_at
1265
- if turn_started_at is not None
1266
- else None
1267
- )
1268
- failure_message = "OpenCode review timed out."
1269
- response_sent = await self._deliver_turn_response(
1270
- chat_id=message.chat_id,
1271
- thread_id=message.thread_id,
1272
- reply_to=message.message_id,
1273
- placeholder_id=placeholder_id,
1274
- response=failure_message,
1275
- )
1276
- if response_sent:
1277
- await self._delete_message(message.chat_id, placeholder_id)
1278
- return turn_context, None
1279
- timeout_task.cancel()
1280
- with suppress(asyncio.CancelledError):
1281
- await timeout_task
1282
1280
  output_result = await output_task
1283
1281
  elapsed = (
1284
1282
  time.monotonic() - turn_started_at
@@ -1563,520 +1561,6 @@ class GitHubCommands(SharedHelpers):
1563
1561
  delivery=delivery,
1564
1562
  )
1565
1563
 
1566
- def _resolve_pr_flow_repo_id(self, record: "TelegramTopicRecord") -> Optional[str]:
1567
- if record.repo_id:
1568
- return record.repo_id
1569
- if not self._hub_root or not self._manifest_path or not record.workspace_path:
1570
- return None
1571
- try:
1572
- manifest = load_manifest(self._manifest_path, self._hub_root)
1573
- except Exception:
1574
- return None
1575
- try:
1576
- workspace_path = canonicalize_path(Path(record.workspace_path))
1577
- except Exception:
1578
- return None
1579
- for repo in manifest.repos:
1580
- repo_path = canonicalize_path(self._hub_root / repo.path)
1581
- if repo_path == workspace_path:
1582
- return repo.id
1583
- return None
1584
-
1585
- def _pr_flow_api_base(
1586
- self, record: "TelegramTopicRecord"
1587
- ) -> tuple[Optional[str], dict[str, str]]:
1588
- headers: dict[str, str] = {}
1589
- if self._hub_root is not None:
1590
- try:
1591
- hub_config = load_hub_config(self._hub_root)
1592
- except Exception:
1593
- return None, headers
1594
- host = hub_config.server_host
1595
- port = hub_config.server_port
1596
- base_path = hub_config.server_base_path
1597
- auth_env = hub_config.server_auth_token_env
1598
- repo_id = self._resolve_pr_flow_repo_id(record)
1599
- if not repo_id:
1600
- return None, headers
1601
- repo_prefix = f"/repos/{repo_id}"
1602
- else:
1603
- if not record.workspace_path:
1604
- return None, headers
1605
- try:
1606
- repo_config = load_repo_config(
1607
- Path(record.workspace_path), hub_path=None
1608
- )
1609
- except Exception:
1610
- return None, headers
1611
- host = repo_config.server_host
1612
- port = repo_config.server_port
1613
- base_path = repo_config.server_base_path
1614
- auth_env = repo_config.server_auth_token_env
1615
- repo_prefix = ""
1616
- if isinstance(auth_env, str) and auth_env:
1617
- token = getenv(auth_env)
1618
- if token:
1619
- headers["Authorization"] = f"Bearer {token}"
1620
- if not host:
1621
- return None, headers
1622
- if host.startswith("http://") or host.startswith("https://"):
1623
- base = host.rstrip("/")
1624
- else:
1625
- base = f"http://{host}:{int(port)}"
1626
- base_path = (base_path or "").strip("/")
1627
- if base_path:
1628
- base = f"{base}/{base_path}"
1629
- return f"{base}{repo_prefix}", headers
1630
-
1631
- async def _pr_flow_request(
1632
- self,
1633
- record: "TelegramTopicRecord",
1634
- *,
1635
- method: str,
1636
- path: str,
1637
- payload: Optional[dict[str, Any]] = None,
1638
- ) -> dict[str, Any]:
1639
- base, headers = self._pr_flow_api_base(record)
1640
- if not base:
1641
- raise RuntimeError(
1642
- "PR flow cannot start: repo server base URL could not be resolved for this chat/topic."
1643
- )
1644
- url = f"{base}{path}"
1645
- async with httpx.AsyncClient(timeout=30.0) as client:
1646
- res = await client.request(method, url, json=payload, headers=headers)
1647
- res.raise_for_status()
1648
- data = res.json()
1649
- if isinstance(data, dict):
1650
- return data
1651
- return {"status": "ok", "flow": data}
1652
-
1653
- def _parse_pr_flags(self, argv: list[str]) -> tuple[Optional[str], dict[str, Any]]:
1654
- ref: Optional[str] = None
1655
- flags: dict[str, Any] = {}
1656
- idx = 0
1657
- while idx < len(argv):
1658
- token = argv[idx]
1659
- if token.startswith("--"):
1660
- if token == "--draft":
1661
- flags["draft"] = True
1662
- idx += 1
1663
- continue
1664
- if token == "--ready":
1665
- flags["draft"] = False
1666
- idx += 1
1667
- continue
1668
- if token == "--base" and idx + 1 < len(argv):
1669
- flags["base_branch"] = argv[idx + 1]
1670
- idx += 2
1671
- continue
1672
- if token == "--until" and idx + 1 < len(argv):
1673
- until = argv[idx + 1].strip().lower()
1674
- if until in ("minor", "minor_only"):
1675
- flags["stop_condition"] = "minor_only"
1676
- elif until in ("clean", "no_issues"):
1677
- flags["stop_condition"] = "no_issues"
1678
- idx += 2
1679
- continue
1680
- if token in ("--max-cycles", "--max_cycles") and idx + 1 < len(argv):
1681
- try:
1682
- flags["max_cycles"] = int(argv[idx + 1])
1683
- except ValueError:
1684
- pass
1685
- idx += 2
1686
- continue
1687
- if token in ("--max-runs", "--max_runs") and idx + 1 < len(argv):
1688
- try:
1689
- flags["max_implementation_runs"] = int(argv[idx + 1])
1690
- except ValueError:
1691
- pass
1692
- idx += 2
1693
- continue
1694
- if token in ("--timeout", "--timeout-seconds") and idx + 1 < len(argv):
1695
- try:
1696
- flags["max_wallclock_seconds"] = int(argv[idx + 1])
1697
- except ValueError:
1698
- pass
1699
- idx += 2
1700
- continue
1701
- idx += 1
1702
- continue
1703
- if ref is None:
1704
- ref = token
1705
- idx += 1
1706
- return ref, flags
1707
-
1708
- def _format_pr_flow_status(self, flow: dict[str, Any]) -> str:
1709
- status = flow.get("status") or "unknown"
1710
- step = flow.get("step") or "unknown"
1711
- cycle = flow.get("cycle") or 0
1712
- pr_url = flow.get("pr_url") or ""
1713
- lines = [f"PR flow: {status} (step: {step}, cycle: {cycle})"]
1714
- if pr_url:
1715
- lines.append(f"PR: {pr_url}")
1716
- return "\n".join(lines)
1717
-
1718
- async def _handle_github_issue_url(
1719
- self,
1720
- message: TelegramMessage,
1721
- key: str,
1722
- slug: str,
1723
- number: int,
1724
- ) -> None:
1725
- if key is None:
1726
- return
1727
-
1728
- record = await self._router.get_topic(key)
1729
- if record is None or not record.workspace_path:
1730
- await self._send_message(
1731
- message.chat_id,
1732
- self._with_conversation_id(
1733
- "Topic not bound. Use /bind <repo_id> or /bind <path>.",
1734
- chat_id=message.chat_id,
1735
- thread_id=message.thread_id,
1736
- ),
1737
- thread_id=message.thread_id,
1738
- reply_to=message.message_id,
1739
- )
1740
- return
1741
-
1742
- try:
1743
- from pathlib import Path
1744
-
1745
- service = GitHubService(Path(record.workspace_path), self._raw_config)
1746
- issue_ref = f"{slug}#{number}"
1747
- service.validate_issue_same_repo(issue_ref)
1748
- except GitHubError as exc:
1749
- await self._send_message(
1750
- message.chat_id,
1751
- str(exc),
1752
- thread_id=message.thread_id,
1753
- reply_to=message.message_id,
1754
- )
1755
- return
1756
-
1757
- await self._offer_pr_flow_start(message, record, slug, number)
1758
-
1759
- async def _offer_pr_flow_start(
1760
- self,
1761
- message: TelegramMessage,
1762
- record: "TelegramTopicRecord",
1763
- slug: str,
1764
- number: int,
1765
- ) -> None:
1766
- from ...adapter import (
1767
- InlineButton,
1768
- build_inline_keyboard,
1769
- encode_cancel_callback,
1770
- encode_pr_flow_start_callback,
1771
- )
1772
-
1773
- keyboard = build_inline_keyboard(
1774
- [
1775
- [
1776
- InlineButton(
1777
- f"Create PR for #{number}",
1778
- encode_pr_flow_start_callback(slug, number),
1779
- ),
1780
- InlineButton(
1781
- "Cancel",
1782
- encode_cancel_callback("pr_flow_offer"),
1783
- ),
1784
- ]
1785
- ]
1786
- )
1787
- await self._send_message(
1788
- message.chat_id,
1789
- f"Detected GitHub issue: {slug}#{number}\nStart PR flow to create a PR?",
1790
- thread_id=message.thread_id,
1791
- reply_to=message.message_id,
1792
- reply_markup=keyboard,
1793
- )
1794
-
1795
- async def _handle_pr_flow_start_callback(
1796
- self,
1797
- key: str,
1798
- callback: TelegramCallbackQuery,
1799
- parsed: PrFlowStartCallback,
1800
- ) -> None:
1801
- from ...adapter import TelegramMessage
1802
-
1803
- await self._answer_callback(callback)
1804
- record = await self._router.get_topic(key)
1805
- if record is None or not record.workspace_path:
1806
- return
1807
-
1808
- issue_ref = f"{parsed.slug}#{parsed.number}"
1809
- payload = {"mode": "issue", "issue": issue_ref}
1810
- payload["source"] = "telegram"
1811
- source_meta: dict[str, Any] = {}
1812
- if callback.chat_id is not None:
1813
- source_meta["chat_id"] = callback.chat_id
1814
- if callback.thread_id is not None:
1815
- source_meta["thread_id"] = callback.thread_id
1816
- if source_meta:
1817
- payload["source_meta"] = source_meta
1818
-
1819
- message = TelegramMessage(
1820
- update_id=callback.update_id,
1821
- message_id=callback.message_id or 0,
1822
- chat_id=callback.chat_id or 0,
1823
- thread_id=callback.thread_id,
1824
- from_user_id=callback.from_user_id,
1825
- text="",
1826
- date=None,
1827
- is_topic_message=False,
1828
- )
1829
-
1830
- try:
1831
- data = await self._pr_flow_request(
1832
- record,
1833
- method="POST",
1834
- path="/api/github/pr_flow/start",
1835
- payload=payload,
1836
- )
1837
- flow = data.get("flow") if isinstance(data, dict) else data
1838
- except Exception as exc:
1839
- detail = _format_httpx_exception(exc) or str(exc)
1840
- await self._send_message(
1841
- message.chat_id,
1842
- f"PR flow error: {detail}",
1843
- thread_id=message.thread_id,
1844
- reply_to=callback.message_id,
1845
- )
1846
- return
1847
- await self._send_message(
1848
- message.chat_id,
1849
- self._format_pr_flow_status(flow),
1850
- thread_id=message.thread_id,
1851
- reply_to=callback.message_id,
1852
- )
1853
-
1854
- async def _handle_pr(
1855
- self, message: TelegramMessage, args: str, runtime: Any
1856
- ) -> None:
1857
- record = await self._require_bound_record(message)
1858
- if not record:
1859
- return
1860
- argv = self._parse_command_args(args)
1861
- if not argv:
1862
- await self._send_message(
1863
- message.chat_id,
1864
- "Usage: /pr start <issueRef> | /pr fix <prRef> | /pr status | /pr stop | /pr resume | /pr collect",
1865
- thread_id=message.thread_id,
1866
- reply_to=message.message_id,
1867
- )
1868
- return
1869
- command = argv[0].lower()
1870
- if command == "status":
1871
- try:
1872
- data = await self._pr_flow_request(
1873
- record, method="GET", path="/api/github/pr_flow/status"
1874
- )
1875
- flow = data.get("flow") if isinstance(data, dict) else data
1876
- except Exception as exc:
1877
- detail = _format_httpx_exception(exc) or str(exc)
1878
- await self._send_message(
1879
- message.chat_id,
1880
- f"PR flow error: {detail}",
1881
- thread_id=message.thread_id,
1882
- reply_to=message.message_id,
1883
- )
1884
- return
1885
- await self._send_message(
1886
- message.chat_id,
1887
- self._format_pr_flow_status(flow),
1888
- thread_id=message.thread_id,
1889
- reply_to=message.message_id,
1890
- )
1891
- return
1892
- if command == "stop":
1893
- try:
1894
- data = await self._pr_flow_request(
1895
- record, method="POST", path="/api/github/pr_flow/stop", payload={}
1896
- )
1897
- flow = data.get("flow") if isinstance(data, dict) else data
1898
- except Exception as exc:
1899
- detail = _format_httpx_exception(exc) or str(exc)
1900
- await self._send_message(
1901
- message.chat_id,
1902
- f"PR flow error: {detail}",
1903
- thread_id=message.thread_id,
1904
- reply_to=message.message_id,
1905
- )
1906
- return
1907
- await self._send_message(
1908
- message.chat_id,
1909
- self._format_pr_flow_status(flow),
1910
- thread_id=message.thread_id,
1911
- reply_to=message.message_id,
1912
- )
1913
- return
1914
- if command == "resume":
1915
- try:
1916
- data = await self._pr_flow_request(
1917
- record, method="POST", path="/api/github/pr_flow/resume", payload={}
1918
- )
1919
- flow = data.get("flow") if isinstance(data, dict) else data
1920
- except Exception as exc:
1921
- detail = _format_httpx_exception(exc) or str(exc)
1922
- await self._send_message(
1923
- message.chat_id,
1924
- f"PR flow error: {detail}",
1925
- thread_id=message.thread_id,
1926
- reply_to=message.message_id,
1927
- )
1928
- return
1929
- await self._send_message(
1930
- message.chat_id,
1931
- self._format_pr_flow_status(flow),
1932
- thread_id=message.thread_id,
1933
- reply_to=message.message_id,
1934
- )
1935
- return
1936
- if command == "collect":
1937
- try:
1938
- data = await self._pr_flow_request(
1939
- record,
1940
- method="POST",
1941
- path="/api/github/pr_flow/collect",
1942
- payload={},
1943
- )
1944
- flow = data.get("flow") if isinstance(data, dict) else data
1945
- except Exception as exc:
1946
- detail = _format_httpx_exception(exc) or str(exc)
1947
- await self._send_message(
1948
- message.chat_id,
1949
- f"PR flow error: {detail}",
1950
- thread_id=message.thread_id,
1951
- reply_to=message.message_id,
1952
- )
1953
- return
1954
- await self._send_message(
1955
- message.chat_id,
1956
- self._format_pr_flow_status(flow),
1957
- thread_id=message.thread_id,
1958
- reply_to=message.message_id,
1959
- )
1960
- return
1961
- if command in ("start", "implement"):
1962
- ref, flags = self._parse_pr_flags(argv[1:])
1963
- if not ref:
1964
- gh = GitHubService(Path(record.workspace_path))
1965
- issues = await asyncio.to_thread(gh.list_open_issues, limit=5)
1966
- if issues:
1967
- lines = ["Open issues:"]
1968
- for issue in issues:
1969
- num = issue.get("number")
1970
- title = issue.get("title") or ""
1971
- lines.append(f"- #{num} {title}".strip())
1972
- lines.append("Use /pr start <issueRef> to begin.")
1973
- await self._send_message(
1974
- message.chat_id,
1975
- "\n".join(lines),
1976
- thread_id=message.thread_id,
1977
- reply_to=message.message_id,
1978
- )
1979
- return
1980
- await self._send_message(
1981
- message.chat_id,
1982
- "Usage: /pr start <issueRef>",
1983
- thread_id=message.thread_id,
1984
- reply_to=message.message_id,
1985
- )
1986
- return
1987
- payload = {"mode": "issue", "issue": ref, **flags}
1988
- payload["source"] = "telegram"
1989
- payload["source_meta"] = {
1990
- "chat_id": message.chat_id,
1991
- "thread_id": message.thread_id,
1992
- }
1993
- try:
1994
- data = await self._pr_flow_request(
1995
- record,
1996
- method="POST",
1997
- path="/api/github/pr_flow/start",
1998
- payload=payload,
1999
- )
2000
- flow = data.get("flow") if isinstance(data, dict) else data
2001
- except Exception as exc:
2002
- detail = _format_httpx_exception(exc) or str(exc)
2003
- await self._send_message(
2004
- message.chat_id,
2005
- f"PR flow error: {detail}",
2006
- thread_id=message.thread_id,
2007
- reply_to=message.message_id,
2008
- )
2009
- return
2010
- await self._send_message(
2011
- message.chat_id,
2012
- self._format_pr_flow_status(flow),
2013
- thread_id=message.thread_id,
2014
- reply_to=message.message_id,
2015
- )
2016
- return
2017
- if command in ("fix", "pr"):
2018
- ref, flags = self._parse_pr_flags(argv[1:])
2019
- if not ref:
2020
- gh = GitHubService(Path(record.workspace_path))
2021
- prs = await asyncio.to_thread(gh.list_open_prs, limit=5)
2022
- if prs:
2023
- lines = ["Open PRs:"]
2024
- for pr in prs:
2025
- num = pr.get("number")
2026
- title = pr.get("title") or ""
2027
- lines.append(f"- #{num} {title}".strip())
2028
- lines.append("Use /pr fix <prRef> to begin.")
2029
- await self._send_message(
2030
- message.chat_id,
2031
- "\n".join(lines),
2032
- thread_id=message.thread_id,
2033
- reply_to=message.message_id,
2034
- )
2035
- return
2036
- await self._send_message(
2037
- message.chat_id,
2038
- "Usage: /pr fix <prRef>",
2039
- thread_id=message.thread_id,
2040
- reply_to=message.message_id,
2041
- )
2042
- return
2043
- payload = {"mode": "pr", "pr": ref, **flags}
2044
- payload["source"] = "telegram"
2045
- payload["source_meta"] = {
2046
- "chat_id": message.chat_id,
2047
- "thread_id": message.thread_id,
2048
- }
2049
- try:
2050
- data = await self._pr_flow_request(
2051
- record,
2052
- method="POST",
2053
- path="/api/github/pr_flow/start",
2054
- payload=payload,
2055
- )
2056
- flow = data.get("flow") if isinstance(data, dict) else data
2057
- except Exception as exc:
2058
- detail = _format_httpx_exception(exc) or str(exc)
2059
- await self._send_message(
2060
- message.chat_id,
2061
- f"PR flow error: {detail}",
2062
- thread_id=message.thread_id,
2063
- reply_to=message.message_id,
2064
- )
2065
- return
2066
- await self._send_message(
2067
- message.chat_id,
2068
- self._format_pr_flow_status(flow),
2069
- thread_id=message.thread_id,
2070
- reply_to=message.message_id,
2071
- )
2072
- return
2073
- await self._send_message(
2074
- message.chat_id,
2075
- "Unknown /pr command. Use /pr start|fix|status|stop|resume|collect.",
2076
- thread_id=message.thread_id,
2077
- reply_to=message.message_id,
2078
- )
2079
-
2080
1564
  async def _prompt_review_commit_picker(
2081
1565
  self,
2082
1566
  message: TelegramMessage,
@@ -2202,28 +1686,3 @@ def _extract_opencode_error_detail(payload: Any) -> Optional[str]:
2202
1686
  if isinstance(value, str) and value:
2203
1687
  return value
2204
1688
  return None
2205
-
2206
-
2207
- def _format_httpx_exception(exc: Exception) -> Optional[str]:
2208
- """Format httpx exceptions for user-friendly error messages."""
2209
- if isinstance(exc, httpx.HTTPStatusError):
2210
- try:
2211
- payload = exc.response.json()
2212
- except Exception:
2213
- payload = None
2214
- if isinstance(payload, dict):
2215
- detail = (
2216
- payload.get("detail") or payload.get("message") or payload.get("error")
2217
- )
2218
- if isinstance(detail, str) and detail:
2219
- return detail
2220
- response_text = exc.response.text.strip()
2221
- if response_text:
2222
- return response_text
2223
- return f"Request failed (HTTP {exc.response.status_code})."
2224
- if isinstance(exc, httpx.RequestError):
2225
- detail = str(exc).strip()
2226
- if detail:
2227
- return detail
2228
- return "Request failed."
2229
- return None