devvy 0.1.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.
- cli/__init__.py +1 -0
- cli/__main__.py +9 -0
- cli/ado_client.py +212 -0
- cli/commands/__init__.py +1 -0
- cli/commands/init.py +107 -0
- cli/commands/logs.py +36 -0
- cli/commands/ps.py +72 -0
- cli/commands/resume.py +105 -0
- cli/commands/run.py +135 -0
- cli/commands/status.py +35 -0
- cli/config.py +69 -0
- cli/db.py +138 -0
- cli/fsm.py +87 -0
- cli/local_runner/__init__.py +85 -0
- cli/local_runner/credentials.py +101 -0
- cli/local_runner/docker_primitives.py +142 -0
- cli/local_runner/repo_detection.py +182 -0
- cli/local_runner/validation.py +203 -0
- cli/local_runner/workspace.py +295 -0
- cli/main.py +22 -0
- cli/models.py +83 -0
- cli/orchestrator.py +530 -0
- cli/prompts.py +42 -0
- cli/ui/__init__.py +55 -0
- cli/ui/picker.py +179 -0
- cli/ui/rendering.py +350 -0
- devvy-0.1.0.dist-info/METADATA +260 -0
- devvy-0.1.0.dist-info/RECORD +31 -0
- devvy-0.1.0.dist-info/WHEEL +4 -0
- devvy-0.1.0.dist-info/entry_points.txt +2 -0
- devvy-0.1.0.dist-info/licenses/LICENSE +21 -0
cli/models.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""ORM models for the CLI's local SQLite database.
|
|
2
|
+
|
|
3
|
+
These are intentionally separate from the server-side app/models/ so the CLI
|
|
4
|
+
package has no dependency on app/. The schema is a simplified subset —
|
|
5
|
+
no MSSQL-specific types, no alembic migrations needed (tables are created
|
|
6
|
+
automatically via create_tables() on startup).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
|
15
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
16
|
+
|
|
17
|
+
from cli.db import Base
|
|
18
|
+
from cli.fsm import TicketState
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Ticket(Base):
|
|
22
|
+
"""A coding task submitted to the local agent."""
|
|
23
|
+
|
|
24
|
+
__tablename__ = "tickets"
|
|
25
|
+
|
|
26
|
+
id: Mapped[str] = mapped_column(
|
|
27
|
+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
28
|
+
)
|
|
29
|
+
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
30
|
+
description: Mapped[str] = mapped_column(Text, nullable=False)
|
|
31
|
+
repo_url: Mapped[str] = mapped_column(String(1000), nullable=False)
|
|
32
|
+
branch_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
33
|
+
state: Mapped[str] = mapped_column(
|
|
34
|
+
String(50), nullable=False, default=TicketState.RECEIVED.value
|
|
35
|
+
)
|
|
36
|
+
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
37
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
38
|
+
DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
|
39
|
+
)
|
|
40
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
41
|
+
DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GraphContext(Base):
|
|
46
|
+
"""Persists orchestrator state so it survives between runs."""
|
|
47
|
+
|
|
48
|
+
__tablename__ = "graph_contexts"
|
|
49
|
+
__table_args__ = (
|
|
50
|
+
UniqueConstraint("ticket_id", name="uq_graph_contexts_ticket_id"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
id: Mapped[str] = mapped_column(
|
|
54
|
+
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
55
|
+
)
|
|
56
|
+
ticket_id: Mapped[str] = mapped_column(
|
|
57
|
+
String(36), ForeignKey("tickets.id"), nullable=False, index=True
|
|
58
|
+
)
|
|
59
|
+
current_state: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
60
|
+
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
61
|
+
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
62
|
+
pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
63
|
+
plan_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
64
|
+
opencode_session_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
65
|
+
logs: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
66
|
+
workspace_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
|
67
|
+
# Comma-separated thread IDs that have already been responded to, so the
|
|
68
|
+
# agent does not re-address comments it has already handled.
|
|
69
|
+
seen_thread_ids: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
70
|
+
default_branch: Mapped[str] = mapped_column(
|
|
71
|
+
String(255), nullable=False, default="main"
|
|
72
|
+
)
|
|
73
|
+
# JSON-serialised RepoContainerConfig — set during PREPARE_ENV and used
|
|
74
|
+
# by VALIDATE to build and run the repo's own container for checks.
|
|
75
|
+
repo_container_config_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
76
|
+
# PID of the background process running the orchestrator for this ticket.
|
|
77
|
+
# Set to os.getpid() at startup; cleared to NULL on terminal state.
|
|
78
|
+
# Used by `devvy status` and `devvy ps` to determine if a ticket is actively
|
|
79
|
+
# being processed or has crashed mid-run.
|
|
80
|
+
worker_pid: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
81
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
82
|
+
DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
|
83
|
+
)
|
cli/orchestrator.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Local orchestrator — drives a ticket through its full lifecycle without a server.
|
|
2
|
+
|
|
3
|
+
All code execution happens inside an ephemeral Docker container (devvy-worker:latest).
|
|
4
|
+
The orchestrator itself only manages state, persistence, and ADO API calls.
|
|
5
|
+
|
|
6
|
+
Flow:
|
|
7
|
+
RECEIVED → PREPARE_ENV (clone repo, start container)
|
|
8
|
+
→ PLAN (opencode: explore + plan, no code changes)
|
|
9
|
+
→ IMPLEMENT (opencode: implement the plan, same session)
|
|
10
|
+
→ VALIDATE (pytest / ruff / mypy inside container)
|
|
11
|
+
→ CREATE_PR (git push + ADO REST API)
|
|
12
|
+
→ WAIT_FOR_REVIEW (poll ADO every 30s)
|
|
13
|
+
→ RESPOND_TO_REVIEW (opencode: address comments)
|
|
14
|
+
→ MERGED / FAILED (stop + remove container)
|
|
15
|
+
|
|
16
|
+
The FSM logic is copied here to avoid importing from app/ (CLI wheel has no
|
|
17
|
+
dependency on the server package).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
|
|
28
|
+
from cli import ado_client, local_runner
|
|
29
|
+
from cli.config import Config
|
|
30
|
+
from cli.db import SessionManager
|
|
31
|
+
from cli.fsm import (
|
|
32
|
+
VALID_TRANSITIONS,
|
|
33
|
+
InvalidTransitionError,
|
|
34
|
+
TicketState,
|
|
35
|
+
transition as _transition,
|
|
36
|
+
)
|
|
37
|
+
from cli.models import GraphContext, Ticket
|
|
38
|
+
from cli.prompts import parse_replies
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_PR_POLL_INTERVAL = 30 # seconds between ADO status polls
|
|
42
|
+
_MAX_VALIDATE_RETRIES = 3
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Orchestrator:
|
|
46
|
+
"""Drives a single ticket through its lifecycle inside a Docker container."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, ticket: Ticket, context: GraphContext, config: Config) -> None:
|
|
49
|
+
self._ticket = ticket
|
|
50
|
+
self._ctx = context
|
|
51
|
+
self._cfg = config
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Public API
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
async def run(self, status_callback: Callable[[str], None] | None = None) -> None:
|
|
58
|
+
"""Run the FSM from the current state to a terminal state."""
|
|
59
|
+
self._ctx.worker_pid = os.getpid()
|
|
60
|
+
await self._persist()
|
|
61
|
+
try:
|
|
62
|
+
while not TicketState(self._ctx.current_state).is_terminal:
|
|
63
|
+
await self._dispatch(
|
|
64
|
+
TicketState(self._ctx.current_state), status_callback
|
|
65
|
+
)
|
|
66
|
+
except asyncio.CancelledError:
|
|
67
|
+
raise
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
await self._fail(str(exc))
|
|
70
|
+
finally:
|
|
71
|
+
# Clear the PID so status/ps can distinguish "done" from "crashed".
|
|
72
|
+
self._ctx.worker_pid = None
|
|
73
|
+
await self._persist()
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Dispatch
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# Handler map is a class-level constant — it never varies per call or
|
|
80
|
+
# per instance, so there is no reason to rebuild it on every _dispatch().
|
|
81
|
+
_HANDLERS: dict[TicketState, str] = {
|
|
82
|
+
TicketState.RECEIVED: "_handle_received",
|
|
83
|
+
TicketState.PREPARE_ENV: "_handle_prepare_env",
|
|
84
|
+
TicketState.PLAN: "_handle_plan",
|
|
85
|
+
TicketState.IMPLEMENT: "_handle_implement",
|
|
86
|
+
TicketState.VALIDATE: "_handle_validate",
|
|
87
|
+
TicketState.CREATE_PR: "_handle_create_pr",
|
|
88
|
+
TicketState.WAIT_FOR_REVIEW: "_handle_wait_for_review",
|
|
89
|
+
TicketState.RESPOND_TO_REVIEW: "_handle_respond_to_review",
|
|
90
|
+
TicketState.MERGED: "_handle_merged",
|
|
91
|
+
TicketState.FAILED: "_handle_failed",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async def _dispatch(
|
|
95
|
+
self, state: TicketState, cb: Callable[[str], None] | None
|
|
96
|
+
) -> None:
|
|
97
|
+
handler_name = self._HANDLERS[state]
|
|
98
|
+
if state == TicketState.WAIT_FOR_REVIEW:
|
|
99
|
+
# _poll_for_review calls cb itself on state transitions, so we
|
|
100
|
+
# pass cb through rather than calling it once after the handler.
|
|
101
|
+
await self._poll_for_review(cb)
|
|
102
|
+
else:
|
|
103
|
+
await getattr(self, handler_name)()
|
|
104
|
+
if cb:
|
|
105
|
+
cb(self._ctx.current_state)
|
|
106
|
+
|
|
107
|
+
async def _handle_wait_for_review(self) -> None:
|
|
108
|
+
"""Placeholder — actual work is done by _poll_for_review via _dispatch."""
|
|
109
|
+
pass # _dispatch routes WAIT_FOR_REVIEW directly to _poll_for_review
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# State handlers
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async def _handle_received(self) -> None:
|
|
116
|
+
if not self._ticket.repo_url:
|
|
117
|
+
await self._fail("Ticket has no repo_url")
|
|
118
|
+
return
|
|
119
|
+
await self._set_state(TicketState.PREPARE_ENV)
|
|
120
|
+
|
|
121
|
+
async def _handle_prepare_env(self) -> None:
|
|
122
|
+
setup = await local_runner.prepare_workspace(
|
|
123
|
+
ticket_id=self._ticket.id,
|
|
124
|
+
repo_url=self._ticket.repo_url,
|
|
125
|
+
ado_pat=self._cfg.ado_pat,
|
|
126
|
+
env_file=self._cfg.env_file,
|
|
127
|
+
)
|
|
128
|
+
self._ticket.branch_name = setup.branch_name
|
|
129
|
+
self._ctx.workspace_path = str(setup.workspace)
|
|
130
|
+
self._ctx.container_id = setup.container_id
|
|
131
|
+
self._ctx.default_branch = setup.default_branch
|
|
132
|
+
self._ctx.repo_container_config_json = setup.repo_cfg.to_json()
|
|
133
|
+
self._append_log(
|
|
134
|
+
f"Workspace: {setup.workspace}\nBranch: {setup.branch_name}\n"
|
|
135
|
+
f"Default branch: {setup.default_branch}\nContainer: {setup.container_id[:12]}"
|
|
136
|
+
)
|
|
137
|
+
await self._persist()
|
|
138
|
+
await self._set_state(TicketState.PLAN)
|
|
139
|
+
|
|
140
|
+
async def _handle_plan(self) -> None:
|
|
141
|
+
"""Ask opencode to explore the repo and produce a plan. No code changes yet."""
|
|
142
|
+
container_id = self._require_container()
|
|
143
|
+
prompt = (
|
|
144
|
+
f"You are an autonomous coding agent.\n\n"
|
|
145
|
+
f"Ticket title: {self._ticket.title}\n\n"
|
|
146
|
+
f"Ticket description:\n{self._ticket.description}\n\n"
|
|
147
|
+
f"Explore the repository at {local_runner.CONTAINER_WORKSPACE}, understand "
|
|
148
|
+
f"the existing codebase, and produce a detailed implementation plan. "
|
|
149
|
+
f"List the files you will create or modify and the exact changes needed. "
|
|
150
|
+
f"Do NOT make any code changes yet — planning only."
|
|
151
|
+
)
|
|
152
|
+
plan_text, session_id = await local_runner.run_opencode(
|
|
153
|
+
container_id=container_id,
|
|
154
|
+
prompt=prompt,
|
|
155
|
+
model=self._cfg.opencode_model,
|
|
156
|
+
)
|
|
157
|
+
self._ctx.plan_json = plan_text
|
|
158
|
+
self._ctx.opencode_session_id = session_id
|
|
159
|
+
self._append_log(f"Plan (session {session_id}):\n{plan_text}")
|
|
160
|
+
await self._persist()
|
|
161
|
+
await self._set_state(TicketState.IMPLEMENT)
|
|
162
|
+
|
|
163
|
+
async def _handle_implement(self) -> None:
|
|
164
|
+
"""Continue the opencode session and implement the plan."""
|
|
165
|
+
container_id = self._require_container()
|
|
166
|
+
prompt = (
|
|
167
|
+
"Now implement the plan you just described. "
|
|
168
|
+
"Make all necessary code changes, create new files where required, "
|
|
169
|
+
"and ensure the implementation is complete and correct.\n\n"
|
|
170
|
+
"IMPORTANT: After making your changes, verify them by running the "
|
|
171
|
+
"repo's validation scripts inside the workspace in this order:\n\n"
|
|
172
|
+
f" 1. Format: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/format.sh\n"
|
|
173
|
+
f" 2. Code checks: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/code-checks.sh\n"
|
|
174
|
+
f" 3. Tests: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/run-tests.sh\n\n"
|
|
175
|
+
"Fix any failures before finishing. "
|
|
176
|
+
"You MAY create, update, or delete tests to keep the suite consistent with your changes."
|
|
177
|
+
)
|
|
178
|
+
output, session_id = await local_runner.run_opencode(
|
|
179
|
+
container_id=container_id,
|
|
180
|
+
prompt=prompt,
|
|
181
|
+
model=self._cfg.opencode_model,
|
|
182
|
+
session_id=self._ctx.opencode_session_id or None,
|
|
183
|
+
)
|
|
184
|
+
self._ctx.opencode_session_id = session_id
|
|
185
|
+
self._append_log(f"Implementation:\n{output}")
|
|
186
|
+
await local_runner.commit_if_changed(
|
|
187
|
+
container_id, f"agent: implement ticket {self._ticket.id[:8]}"
|
|
188
|
+
)
|
|
189
|
+
await self._set_state(TicketState.VALIDATE)
|
|
190
|
+
|
|
191
|
+
async def _handle_validate(self) -> None:
|
|
192
|
+
container_id = self._require_container()
|
|
193
|
+
if not self._ctx.repo_container_config_json:
|
|
194
|
+
await self._fail("No repo container config — PREPARE_ENV may have failed")
|
|
195
|
+
return
|
|
196
|
+
repo_cfg = local_runner.RepoContainerConfig.from_json(
|
|
197
|
+
self._ctx.repo_container_config_json
|
|
198
|
+
)
|
|
199
|
+
result = await local_runner.run_validation(repo_cfg)
|
|
200
|
+
self._append_log(f"Validation:\n{result.output}")
|
|
201
|
+
|
|
202
|
+
if result.passed:
|
|
203
|
+
self._ctx.retry_count = 0
|
|
204
|
+
if self._ctx.pr_number:
|
|
205
|
+
# PR already exists (post-review validation) — push the updated
|
|
206
|
+
# branch so the PR reflects the new commits, then go back to polling.
|
|
207
|
+
branch = self._branch_name
|
|
208
|
+
await local_runner.push_branch(container_id, branch)
|
|
209
|
+
await self._set_state(TicketState.WAIT_FOR_REVIEW)
|
|
210
|
+
else:
|
|
211
|
+
await self._set_state(TicketState.CREATE_PR)
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
self._ctx.retry_count += 1
|
|
215
|
+
if self._ctx.retry_count > _MAX_VALIDATE_RETRIES:
|
|
216
|
+
await self._fail(f"Validation failed after {_MAX_VALIDATE_RETRIES} retries")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
fix_prompt = (
|
|
220
|
+
f"The validation checks failed. Fix ALL of the following issues:\n\n"
|
|
221
|
+
f"{result.output}\n\n"
|
|
222
|
+
f"Once fixed, verify by re-running the repo's validation scripts in order:\n\n"
|
|
223
|
+
f" 1. Format: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/format.sh\n"
|
|
224
|
+
f" 2. Code checks: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/code-checks.sh\n"
|
|
225
|
+
f" 3. Tests: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/run-tests.sh\n\n"
|
|
226
|
+
f"Fix everything — do not stop after fixing just one category. "
|
|
227
|
+
f"All scripts must exit 0 before you finish."
|
|
228
|
+
)
|
|
229
|
+
fix_output, session_id = await local_runner.run_opencode(
|
|
230
|
+
container_id=container_id,
|
|
231
|
+
prompt=fix_prompt,
|
|
232
|
+
model=self._cfg.opencode_model,
|
|
233
|
+
session_id=self._ctx.opencode_session_id or None,
|
|
234
|
+
)
|
|
235
|
+
self._ctx.opencode_session_id = session_id
|
|
236
|
+
self._append_log(f"Self-fix:\n{fix_output}")
|
|
237
|
+
await local_runner.commit_if_changed(
|
|
238
|
+
container_id,
|
|
239
|
+
f"agent: fix validation failures (attempt {self._ctx.retry_count})",
|
|
240
|
+
)
|
|
241
|
+
await self._persist()
|
|
242
|
+
# Stay in VALIDATE — the while loop re-dispatches
|
|
243
|
+
|
|
244
|
+
async def _generate_pr_description(self, template: str) -> str:
|
|
245
|
+
"""
|
|
246
|
+
Ask opencode (continuing the existing session) to fill in *template*
|
|
247
|
+
based on the changes it just made.
|
|
248
|
+
|
|
249
|
+
Returns the filled-in description text. Falls back to the raw plan
|
|
250
|
+
text if opencode produces no output.
|
|
251
|
+
"""
|
|
252
|
+
container_id = self._require_container()
|
|
253
|
+
prompt = (
|
|
254
|
+
"Fill in the following pull request template based on the changes "
|
|
255
|
+
"you just made to this repository. Return ONLY the filled-in "
|
|
256
|
+
"template text — no preamble, no extra commentary.\n\n"
|
|
257
|
+
f"Template:\n{template}"
|
|
258
|
+
)
|
|
259
|
+
description, session_id = await local_runner.run_opencode(
|
|
260
|
+
container_id=container_id,
|
|
261
|
+
prompt=prompt,
|
|
262
|
+
model=self._cfg.opencode_model,
|
|
263
|
+
session_id=self._ctx.opencode_session_id or None,
|
|
264
|
+
)
|
|
265
|
+
self._ctx.opencode_session_id = session_id
|
|
266
|
+
return description.strip() or (
|
|
267
|
+
self._ctx.plan_json or self._ticket.description or ""
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
async def _handle_create_pr(self) -> None:
|
|
271
|
+
container_id = self._require_container()
|
|
272
|
+
branch = self._branch_name
|
|
273
|
+
await local_runner.push_branch(container_id, branch)
|
|
274
|
+
|
|
275
|
+
template = local_runner.read_pr_template(self._ticket.id)
|
|
276
|
+
if template:
|
|
277
|
+
description = await self._generate_pr_description(template)
|
|
278
|
+
self._append_log(f"PR description generated from template:\n{description}")
|
|
279
|
+
else:
|
|
280
|
+
description = self._ctx.plan_json or self._ticket.description or ""
|
|
281
|
+
|
|
282
|
+
pr_number, pr_url = await ado_client.create_pr(
|
|
283
|
+
repo_url=self._ticket.repo_url,
|
|
284
|
+
branch=branch,
|
|
285
|
+
title=f"[Agent] {self._ticket.title}",
|
|
286
|
+
description=description,
|
|
287
|
+
ado_org_url=self._cfg.ado_org_url,
|
|
288
|
+
ado_project=self._cfg.ado_project,
|
|
289
|
+
ado_pat=self._cfg.ado_pat,
|
|
290
|
+
target_branch=self._ctx.default_branch or "main",
|
|
291
|
+
)
|
|
292
|
+
self._ctx.pr_number = pr_number
|
|
293
|
+
self._append_log(f"PR #{pr_number} created: {pr_url}")
|
|
294
|
+
await self._set_state(TicketState.WAIT_FOR_REVIEW)
|
|
295
|
+
|
|
296
|
+
async def _poll_for_review(self, cb: Callable[[str], None] | None) -> None:
|
|
297
|
+
"""Poll ADO every 30s until the PR is merged, abandoned, or has new comments."""
|
|
298
|
+
pr_number = self._ctx.pr_number
|
|
299
|
+
if not pr_number:
|
|
300
|
+
await self._fail("No PR number in WAIT_FOR_REVIEW")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
self._append_log("Waiting for PR review (polling ADO every 30s)...")
|
|
304
|
+
await self._persist()
|
|
305
|
+
|
|
306
|
+
while True:
|
|
307
|
+
await asyncio.sleep(_PR_POLL_INTERVAL)
|
|
308
|
+
try:
|
|
309
|
+
status = await ado_client.get_pr_status(
|
|
310
|
+
self._ticket.repo_url,
|
|
311
|
+
pr_number,
|
|
312
|
+
self._cfg.ado_org_url,
|
|
313
|
+
self._cfg.ado_project,
|
|
314
|
+
self._cfg.ado_pat,
|
|
315
|
+
)
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
self._append_log(f"ADO poll error: {exc}")
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
if status == "completed":
|
|
321
|
+
await self._set_state(TicketState.MERGED)
|
|
322
|
+
if cb:
|
|
323
|
+
cb(self._ctx.current_state)
|
|
324
|
+
return
|
|
325
|
+
elif status == "abandoned":
|
|
326
|
+
await self._fail("PR was abandoned")
|
|
327
|
+
return
|
|
328
|
+
else:
|
|
329
|
+
try:
|
|
330
|
+
comments = await ado_client.get_pr_comments(
|
|
331
|
+
self._ticket.repo_url,
|
|
332
|
+
pr_number,
|
|
333
|
+
self._cfg.ado_org_url,
|
|
334
|
+
self._cfg.ado_project,
|
|
335
|
+
self._cfg.ado_pat,
|
|
336
|
+
)
|
|
337
|
+
except Exception:
|
|
338
|
+
comments = []
|
|
339
|
+
|
|
340
|
+
seen_map = self._seen_thread_map()
|
|
341
|
+
new_threads = [
|
|
342
|
+
t
|
|
343
|
+
for t in comments
|
|
344
|
+
if max(c["comment_id"] for c in t["comments"])
|
|
345
|
+
> seen_map.get(t["thread_id"], 0)
|
|
346
|
+
]
|
|
347
|
+
if new_threads:
|
|
348
|
+
await self._set_state(TicketState.RESPOND_TO_REVIEW)
|
|
349
|
+
if cb:
|
|
350
|
+
cb(self._ctx.current_state)
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
async def _handle_respond_to_review(self) -> None:
|
|
354
|
+
pr_number = self._ctx.pr_number
|
|
355
|
+
if not pr_number:
|
|
356
|
+
await self._fail("No PR number in RESPOND_TO_REVIEW")
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
container_id = self._require_container()
|
|
360
|
+
threads = await ado_client.get_pr_comments(
|
|
361
|
+
self._ticket.repo_url,
|
|
362
|
+
pr_number,
|
|
363
|
+
self._cfg.ado_org_url,
|
|
364
|
+
self._cfg.ado_project,
|
|
365
|
+
self._cfg.ado_pat,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
seen_map = self._seen_thread_map()
|
|
369
|
+
new_threads = [
|
|
370
|
+
t
|
|
371
|
+
for t in threads
|
|
372
|
+
if max(c["comment_id"] for c in t["comments"])
|
|
373
|
+
> seen_map.get(t["thread_id"], 0)
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
if not new_threads:
|
|
377
|
+
await self._set_state(TicketState.WAIT_FOR_REVIEW)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# Build a full thread history block for each new thread so OpenCode
|
|
381
|
+
# has full context (prior conversation + new reply).
|
|
382
|
+
thread_blocks = []
|
|
383
|
+
for t in new_threads:
|
|
384
|
+
header = f"thread_id={t['thread_id']}, file={t['file_path'] or 'general'}:"
|
|
385
|
+
lines = [header]
|
|
386
|
+
for c in t["comments"]:
|
|
387
|
+
lines.append(f" [{c['author']}] {c['body']}")
|
|
388
|
+
thread_blocks.append("\n".join(lines))
|
|
389
|
+
thread_section = "\n\n".join(thread_blocks)
|
|
390
|
+
|
|
391
|
+
prompt = (
|
|
392
|
+
"You have the following PR review threads to address. Each thread "
|
|
393
|
+
"shows the full conversation history — the last message in each "
|
|
394
|
+
"thread is the newest comment requiring your attention.\n\n"
|
|
395
|
+
f"{thread_section}\n\n"
|
|
396
|
+
"For each thread:\n"
|
|
397
|
+
"1. If you agree it requires a code change, make the change.\n"
|
|
398
|
+
"2. If you disagree, do NOT make the change — instead explain "
|
|
399
|
+
" your reasoning clearly and respectfully in your reply.\n"
|
|
400
|
+
"3. You MUST write a reply for every thread, whether you made a "
|
|
401
|
+
" change or not (e.g. 'Good catch, fixed.' or 'I disagree "
|
|
402
|
+
" because ...').\n\n"
|
|
403
|
+
"At the end of your response, output your replies in EXACTLY "
|
|
404
|
+
"this format (no extra blank lines between entries):\n\n"
|
|
405
|
+
"REPLIES:\n"
|
|
406
|
+
"thread_id=<id>: <your reply text>\n"
|
|
407
|
+
"thread_id=<id>: <your reply text>\n"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
output, session_id = await local_runner.run_opencode(
|
|
411
|
+
container_id=container_id,
|
|
412
|
+
prompt=prompt,
|
|
413
|
+
model=self._cfg.opencode_model,
|
|
414
|
+
session_id=self._ctx.opencode_session_id or None,
|
|
415
|
+
)
|
|
416
|
+
self._ctx.opencode_session_id = session_id
|
|
417
|
+
self._append_log(f"Review response:\n{output}")
|
|
418
|
+
|
|
419
|
+
# Post a reply to each thread in ADO, then advance the high-water mark
|
|
420
|
+
# to include devvy's own reply comment ID so we don't re-process it.
|
|
421
|
+
replies = parse_replies(output)
|
|
422
|
+
for t in new_threads:
|
|
423
|
+
tid = t["thread_id"]
|
|
424
|
+
# High-water mark: max comment ID seen in this thread so far.
|
|
425
|
+
max_seen = max(c["comment_id"] for c in t["comments"])
|
|
426
|
+
message = replies.get(tid, "Reviewed and addressed.")
|
|
427
|
+
try:
|
|
428
|
+
new_comment_id = await ado_client.post_thread_reply(
|
|
429
|
+
repo_url=self._ticket.repo_url,
|
|
430
|
+
pr_number=pr_number,
|
|
431
|
+
thread_id=tid,
|
|
432
|
+
message=message,
|
|
433
|
+
ado_org_url=self._cfg.ado_org_url,
|
|
434
|
+
ado_project=self._cfg.ado_project,
|
|
435
|
+
ado_pat=self._cfg.ado_pat,
|
|
436
|
+
)
|
|
437
|
+
# Advance mark to include devvy's own reply so it isn't re-processed.
|
|
438
|
+
max_seen = max(max_seen, new_comment_id)
|
|
439
|
+
self._append_log(f"Posted reply to thread {tid}: {message}")
|
|
440
|
+
except Exception as exc:
|
|
441
|
+
self._append_log(f"Failed to post reply to thread {tid}: {exc}")
|
|
442
|
+
self._mark_comments_seen(tid, max_seen)
|
|
443
|
+
|
|
444
|
+
# Commit any code changes OpenCode made.
|
|
445
|
+
committed = await local_runner.commit_if_changed(
|
|
446
|
+
container_id, "agent: address PR review comments"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
self._ctx.retry_count = 0
|
|
450
|
+
await self._persist()
|
|
451
|
+
|
|
452
|
+
if committed:
|
|
453
|
+
# Code changed — run validation before re-entering the review loop.
|
|
454
|
+
await self._set_state(TicketState.VALIDATE)
|
|
455
|
+
else:
|
|
456
|
+
# Pure discussion (replies only, no code changes) — skip CI and
|
|
457
|
+
# go straight back to waiting for the next reviewer action.
|
|
458
|
+
await self._set_state(TicketState.WAIT_FOR_REVIEW)
|
|
459
|
+
|
|
460
|
+
async def _handle_merged(self) -> None:
|
|
461
|
+
await local_runner.cleanup_workspace(self._ticket.id, self._ctx.container_id)
|
|
462
|
+
self._append_log("Ticket complete — PR merged.")
|
|
463
|
+
await self._persist()
|
|
464
|
+
|
|
465
|
+
async def _handle_failed(self) -> None:
|
|
466
|
+
await local_runner.cleanup_workspace(self._ticket.id, self._ctx.container_id)
|
|
467
|
+
await self._persist()
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
# Helpers
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
async def _set_state(self, target: TicketState) -> None:
|
|
474
|
+
current = TicketState(self._ctx.current_state)
|
|
475
|
+
new_state = _transition(current, target)
|
|
476
|
+
self._ctx.current_state = new_state.value
|
|
477
|
+
self._ticket.state = new_state.value
|
|
478
|
+
self._ticket.updated_at = datetime.now(UTC)
|
|
479
|
+
await self._persist()
|
|
480
|
+
|
|
481
|
+
async def _fail(self, reason: str) -> None:
|
|
482
|
+
self._ticket.error_message = reason
|
|
483
|
+
try:
|
|
484
|
+
await self._set_state(TicketState.FAILED)
|
|
485
|
+
except Exception:
|
|
486
|
+
self._ctx.current_state = TicketState.FAILED.value
|
|
487
|
+
self._ticket.state = TicketState.FAILED.value
|
|
488
|
+
await self._persist()
|
|
489
|
+
await self._handle_failed()
|
|
490
|
+
|
|
491
|
+
async def _persist(self) -> None:
|
|
492
|
+
self._ctx.updated_at = datetime.now(UTC)
|
|
493
|
+
async with SessionManager.session() as session:
|
|
494
|
+
session.add(self._ticket)
|
|
495
|
+
session.add(self._ctx)
|
|
496
|
+
|
|
497
|
+
def _require_container(self) -> str:
|
|
498
|
+
if not self._ctx.container_id:
|
|
499
|
+
raise RuntimeError("No container_id — PREPARE_ENV may have failed")
|
|
500
|
+
return self._ctx.container_id
|
|
501
|
+
|
|
502
|
+
@property
|
|
503
|
+
def _branch_name(self) -> str:
|
|
504
|
+
return self._ticket.branch_name or f"agent/{self._ticket.id[:8]}"
|
|
505
|
+
|
|
506
|
+
def _seen_thread_map(self) -> dict[str, int]:
|
|
507
|
+
"""Return a mapping of thread_id → max comment_id seen.
|
|
508
|
+
|
|
509
|
+
Stored as a JSON object in ``seen_thread_ids``, e.g.::
|
|
510
|
+
|
|
511
|
+
{"64": 3, "65": 1, "66": 2}
|
|
512
|
+
"""
|
|
513
|
+
raw = self._ctx.seen_thread_ids or ""
|
|
514
|
+
if not raw:
|
|
515
|
+
return {}
|
|
516
|
+
try:
|
|
517
|
+
return json.loads(raw)
|
|
518
|
+
except json.JSONDecodeError:
|
|
519
|
+
# Graceful fallback for any stale data from a previous format.
|
|
520
|
+
return {}
|
|
521
|
+
|
|
522
|
+
def _mark_comments_seen(self, thread_id: str, max_comment_id: int) -> None:
|
|
523
|
+
"""Record that we have seen up to *max_comment_id* in *thread_id*."""
|
|
524
|
+
seen_map = self._seen_thread_map()
|
|
525
|
+
seen_map[thread_id] = max(seen_map.get(thread_id, 0), max_comment_id)
|
|
526
|
+
self._ctx.seen_thread_ids = json.dumps(seen_map)
|
|
527
|
+
|
|
528
|
+
def _append_log(self, message: str) -> None:
|
|
529
|
+
timestamp = datetime.now(UTC).isoformat()
|
|
530
|
+
self._ctx.logs = f"{self._ctx.logs}\n[{timestamp}] {message}".strip()
|
cli/prompts.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Prompt parsing helpers for the devvy CLI.
|
|
2
|
+
|
|
3
|
+
Contains pure-Python parsers for structured output produced by opencode,
|
|
4
|
+
kept separate from ADO API calls (ado_client.py) and orchestration logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_replies(output: str) -> dict[str, str]:
|
|
11
|
+
"""Extract per-thread replies from OpenCode's structured output.
|
|
12
|
+
|
|
13
|
+
Looks for a block of the form::
|
|
14
|
+
|
|
15
|
+
REPLIES:
|
|
16
|
+
thread_id=abc123: Great suggestion, fixed.
|
|
17
|
+
thread_id=def456: I disagree because X.
|
|
18
|
+
|
|
19
|
+
Returns a dict mapping thread_id → reply text. Lines that don't match
|
|
20
|
+
the expected format are silently skipped.
|
|
21
|
+
"""
|
|
22
|
+
replies: dict[str, str] = {}
|
|
23
|
+
in_block = False
|
|
24
|
+
for line in output.splitlines():
|
|
25
|
+
stripped = line.strip()
|
|
26
|
+
if stripped == "REPLIES:":
|
|
27
|
+
in_block = True
|
|
28
|
+
continue
|
|
29
|
+
if in_block:
|
|
30
|
+
# Stop at the next blank line or a line that looks like a new section
|
|
31
|
+
if not stripped:
|
|
32
|
+
break
|
|
33
|
+
if stripped.startswith("thread_id="):
|
|
34
|
+
# thread_id=<id>: <message>
|
|
35
|
+
rest = stripped[len("thread_id=") :]
|
|
36
|
+
if ":" in rest:
|
|
37
|
+
tid, _, msg = rest.partition(":")
|
|
38
|
+
tid = tid.strip()
|
|
39
|
+
msg = msg.strip()
|
|
40
|
+
if tid and msg:
|
|
41
|
+
replies[tid] = msg
|
|
42
|
+
return replies
|