omneval-devloop 0.0.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.
- devloop/__init__.py +27 -0
- devloop/cluster.py +79 -0
- devloop/dev_loop.py +395 -0
- devloop/dev_loop_logic.py +66 -0
- devloop/github_ops.py +167 -0
- devloop/k8s_jobs.py +367 -0
- devloop/projects.py +121 -0
- devloop/schedules.py +82 -0
- devloop/shared.py +244 -0
- devloop/summarization.py +69 -0
- devloop/summarize_activities.py +130 -0
- devloop/webhook.py +105 -0
- devloop/worker.py +124 -0
- devloop/workflows.py +25 -0
- omneval_devloop-0.0.1.dist-info/METADATA +11 -0
- omneval_devloop-0.0.1.dist-info/RECORD +18 -0
- omneval_devloop-0.0.1.dist-info/WHEEL +4 -0
- omneval_devloop-0.0.1.dist-info/licenses/LICENSE +201 -0
devloop/schedules.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Temporal Schedules for the Dev Loop nightly sweep and weekly summary.
|
|
2
|
+
|
|
3
|
+
* Nightly (03:00): start a Dev Loop per enrolled project. The Plan phase no-ops
|
|
4
|
+
cleanly when a project has no open agent-ready issues (issue #20).
|
|
5
|
+
* Weekly (Mon 08:00): start a Summarization workflow per project (issue #24).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from temporalio.client import (
|
|
13
|
+
Client,
|
|
14
|
+
Schedule,
|
|
15
|
+
ScheduleActionStartWorkflow,
|
|
16
|
+
ScheduleAlreadyRunningError,
|
|
17
|
+
ScheduleSpec,
|
|
18
|
+
ScheduleCalendarSpec,
|
|
19
|
+
ScheduleRange,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .projects import ProjectConfig
|
|
23
|
+
from .shared import ORCHESTRATION_QUEUE
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _ensure(client: Client, schedule_id: str, schedule: Schedule) -> None:
|
|
29
|
+
try:
|
|
30
|
+
await client.create_schedule(schedule_id, schedule)
|
|
31
|
+
log.info("created schedule %s", schedule_id)
|
|
32
|
+
except ScheduleAlreadyRunningError:
|
|
33
|
+
log.info("schedule %s already exists", schedule_id)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def ensure_schedules(client: Client, projects: list[ProjectConfig]) -> None:
|
|
37
|
+
from .dev_loop import DevLoopInput
|
|
38
|
+
from .summarization import SummarizeInput
|
|
39
|
+
|
|
40
|
+
for p in projects:
|
|
41
|
+
await _ensure(
|
|
42
|
+
client,
|
|
43
|
+
f"devloop-nightly-{p.id}",
|
|
44
|
+
Schedule(
|
|
45
|
+
action=ScheduleActionStartWorkflow(
|
|
46
|
+
"DevLoopWorkflow",
|
|
47
|
+
DevLoopInput(project_id=p.id, agent_label=p.agent_label),
|
|
48
|
+
id=f"devloop-nightly-{p.id}",
|
|
49
|
+
task_queue=ORCHESTRATION_QUEUE,
|
|
50
|
+
),
|
|
51
|
+
spec=ScheduleSpec(
|
|
52
|
+
calendars=[
|
|
53
|
+
ScheduleCalendarSpec(
|
|
54
|
+
hour=[ScheduleRange(3)],
|
|
55
|
+
minute=[ScheduleRange(0)],
|
|
56
|
+
)
|
|
57
|
+
]
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
await _ensure(
|
|
62
|
+
client,
|
|
63
|
+
f"summarize-weekly-{p.id}",
|
|
64
|
+
Schedule(
|
|
65
|
+
action=ScheduleActionStartWorkflow(
|
|
66
|
+
"SummarizationWorkflow",
|
|
67
|
+
SummarizeInput(project_id=p.id, trigger="weekly"),
|
|
68
|
+
id=f"summarize-weekly-{p.id}",
|
|
69
|
+
task_queue=ORCHESTRATION_QUEUE,
|
|
70
|
+
),
|
|
71
|
+
spec=ScheduleSpec(
|
|
72
|
+
calendars=[
|
|
73
|
+
ScheduleCalendarSpec(
|
|
74
|
+
# Monday = 1 in Temporal's day-of-week range
|
|
75
|
+
day_of_week=[ScheduleRange(1)],
|
|
76
|
+
hour=[ScheduleRange(8)],
|
|
77
|
+
minute=[ScheduleRange(0)],
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
)
|
devloop/shared.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Sandbox-safe data structures shared between workflows and activities.
|
|
2
|
+
|
|
3
|
+
This module is imported by both Temporal workflow definitions (which run in
|
|
4
|
+
the deterministic sandbox) and activity code, so it must only import from the
|
|
5
|
+
standard library — no I/O, no threading, no network clients.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
# Task queues — override via env vars to match helm chart values.
|
|
16
|
+
# MESSAGING_TASK_QUEUE is the queue name for whichever messaging platform bot is
|
|
17
|
+
# deployed (discord-bot, slack-bot, etc.); set it in helm values alongside the
|
|
18
|
+
# bot's own TASK_QUEUE so both sides agree on the queue name.
|
|
19
|
+
ORCHESTRATION_QUEUE = os.getenv("ORCHESTRATION_QUEUE", "devloop-orchestration")
|
|
20
|
+
MESSAGING_QUEUE = os.getenv("MESSAGING_TASK_QUEUE", "discord-bot")
|
|
21
|
+
|
|
22
|
+
# Discord channel logical names (resolved to IDs inside the bot)
|
|
23
|
+
CHANNEL_APPROVALS = "approvals"
|
|
24
|
+
CHANNEL_ALERTS = "alerts"
|
|
25
|
+
CHANNEL_CHANGELOG = "changelog"
|
|
26
|
+
|
|
27
|
+
# Agent Job output ConfigMap contract: the keys the worker and the Agent
|
|
28
|
+
# Execution Job exchange through the Job's output ConfigMap. Defined here so both
|
|
29
|
+
# the devloop-temporal-worker and devloop-agent-base images reference one source.
|
|
30
|
+
KEY_RESULT = "result" # the JSON-encoded AgentJobResult payload
|
|
31
|
+
KEY_HUMAN_ANSWER = "human_answer" # a human's mid-run reply patched back in
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Phase(str, Enum):
|
|
35
|
+
PLAN = "plan"
|
|
36
|
+
EXECUTE = "execute"
|
|
37
|
+
REVIEW = "review"
|
|
38
|
+
MERGE = "merge"
|
|
39
|
+
DIAGNOSIS = "diagnosis"
|
|
40
|
+
REMEDIATION = "remediation"
|
|
41
|
+
SUMMARIZE = "summarize"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class JobStatus(str, Enum):
|
|
45
|
+
COMPLETE = "complete"
|
|
46
|
+
FAILED = "failed"
|
|
47
|
+
AWAITING_HUMAN = "awaiting_human"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Discord activity I/O (mirror of images/discord-bot/activities.py dataclasses)
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class SendMessageInput:
|
|
57
|
+
workflow_id: str
|
|
58
|
+
message: str
|
|
59
|
+
channel: str = CHANNEL_APPROVALS
|
|
60
|
+
thread_name: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class SendMessageOutput:
|
|
65
|
+
thread_id: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SendNotificationInput:
|
|
70
|
+
workflow_id: str
|
|
71
|
+
message: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ArchiveThreadInput:
|
|
76
|
+
workflow_id: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# GitHub activity I/O
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class InlineComment:
|
|
86
|
+
file: str
|
|
87
|
+
line: int
|
|
88
|
+
body: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class PostCommentsInput:
|
|
93
|
+
"""Reviewer findings posted to a PR: a PR-level ``summary`` plus optional
|
|
94
|
+
line-anchored ``inline_comments``. Built by the workflow from the review
|
|
95
|
+
Agent Execution Job's ``review`` payload, consumed by the ``post_pr_comments``
|
|
96
|
+
activity."""
|
|
97
|
+
|
|
98
|
+
project_id: str
|
|
99
|
+
pr_number: int
|
|
100
|
+
summary: str
|
|
101
|
+
inline_comments: list[InlineComment] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Execution model
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class TaskSpec:
|
|
111
|
+
"""The instruction payload handed to an Agent Execution Job.
|
|
112
|
+
|
|
113
|
+
Serialized into the Job's ``TASK_SPEC`` env var by the worker and rebuilt by
|
|
114
|
+
the agent entrypoint — both via the methods below, so the field set has one
|
|
115
|
+
owner."""
|
|
116
|
+
|
|
117
|
+
phase: str
|
|
118
|
+
project_id: str
|
|
119
|
+
issue_number: int = 0
|
|
120
|
+
title: str = ""
|
|
121
|
+
body: str = ""
|
|
122
|
+
branch: str = ""
|
|
123
|
+
instructions: str = ""
|
|
124
|
+
# phase-specific extras (review rubric, merge branch list, alert payload …)
|
|
125
|
+
extra: dict = field(default_factory=dict)
|
|
126
|
+
|
|
127
|
+
def to_env_value(self) -> str:
|
|
128
|
+
"""Render the ``TASK_SPEC`` env value the Agent Execution Job reads."""
|
|
129
|
+
return json.dumps(asdict(self))
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_env(cls, raw: str) -> "TaskSpec":
|
|
133
|
+
"""Rebuild a TaskSpec from the ``TASK_SPEC`` env value (agent side)."""
|
|
134
|
+
d = json.loads(raw or "{}")
|
|
135
|
+
return cls(
|
|
136
|
+
phase=d.get("phase", "execute"),
|
|
137
|
+
project_id=d.get("project_id", ""),
|
|
138
|
+
issue_number=int(d.get("issue_number", 0) or 0),
|
|
139
|
+
title=d.get("title", ""),
|
|
140
|
+
body=d.get("body", ""),
|
|
141
|
+
branch=d.get("branch", ""),
|
|
142
|
+
instructions=d.get("instructions", ""),
|
|
143
|
+
extra=d.get("extra", {}) or {},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class AgentJobResult:
|
|
149
|
+
"""The result an Agent Execution Job writes to its output ConfigMap.
|
|
150
|
+
|
|
151
|
+
The agent serializes one of these with :meth:`to_payload`; the worker rebuilds
|
|
152
|
+
it with :meth:`from_payload`. ``job_name`` is assigned by the reader (the
|
|
153
|
+
worker knows which Job it polled) and is not part of the wire payload."""
|
|
154
|
+
|
|
155
|
+
status: str = JobStatus.FAILED.value
|
|
156
|
+
job_name: str = ""
|
|
157
|
+
issue_number: int = 0
|
|
158
|
+
branch: str = ""
|
|
159
|
+
pr_url: str = ""
|
|
160
|
+
# number of commits the agent produced (execute/review phases)
|
|
161
|
+
commits: int = 0
|
|
162
|
+
tests_passed: bool = False
|
|
163
|
+
# mid-run question (status == awaiting_human)
|
|
164
|
+
question: str = ""
|
|
165
|
+
# plan phase output (codebase-grounded plan from the planner Agent Job)
|
|
166
|
+
plan: dict | None = None
|
|
167
|
+
# review phase output
|
|
168
|
+
review: dict | None = None
|
|
169
|
+
# diagnosis phase output
|
|
170
|
+
diagnosis: dict | None = None
|
|
171
|
+
# merge / summarize output
|
|
172
|
+
summary: str = ""
|
|
173
|
+
merged_issues: list[int] = field(default_factory=list)
|
|
174
|
+
merge_commit: str = ""
|
|
175
|
+
error: str = ""
|
|
176
|
+
|
|
177
|
+
def to_payload(self) -> dict:
|
|
178
|
+
"""Render the dict the agent stores under ``KEY_RESULT`` (drops the
|
|
179
|
+
reader-assigned ``job_name``)."""
|
|
180
|
+
d = asdict(self)
|
|
181
|
+
d.pop("job_name", None)
|
|
182
|
+
return d
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_payload(cls, payload: dict, job_name: str) -> "AgentJobResult":
|
|
186
|
+
"""Rebuild an AgentJobResult from a Job's output payload (worker side)."""
|
|
187
|
+
return cls(
|
|
188
|
+
status=payload.get("status", JobStatus.FAILED.value),
|
|
189
|
+
job_name=job_name,
|
|
190
|
+
issue_number=int(payload.get("issue_number", 0) or 0),
|
|
191
|
+
branch=payload.get("branch", ""),
|
|
192
|
+
pr_url=payload.get("pr_url", ""),
|
|
193
|
+
commits=int(payload.get("commits", 0) or 0),
|
|
194
|
+
tests_passed=bool(payload.get("tests_passed", False)),
|
|
195
|
+
question=payload.get("question", ""),
|
|
196
|
+
plan=payload.get("plan"),
|
|
197
|
+
review=payload.get("review"),
|
|
198
|
+
diagnosis=payload.get("diagnosis"),
|
|
199
|
+
summary=payload.get("summary", ""),
|
|
200
|
+
merged_issues=list(payload.get("merged_issues", []) or []),
|
|
201
|
+
merge_commit=payload.get("merge_commit", ""),
|
|
202
|
+
error=payload.get("error", ""),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class DispatchInput:
|
|
208
|
+
project_id: str
|
|
209
|
+
issue_number: int
|
|
210
|
+
task_spec: TaskSpec
|
|
211
|
+
# test override: poll interval / job ttl (seconds)
|
|
212
|
+
poll_interval_seconds: float = 5.0
|
|
213
|
+
retention_seconds: float = 300.0
|
|
214
|
+
# For jobs not backed by a registry project (e.g. custom consumer workflows):
|
|
215
|
+
# override the image / omneval ingest secret / repo without a registry entry.
|
|
216
|
+
image_override: str = ""
|
|
217
|
+
omneval_secret_override: str = ""
|
|
218
|
+
github_url_override: str = ""
|
|
219
|
+
# GitHub token Secret name; empty means the job needs no GitHub access
|
|
220
|
+
github_token_secret_override: str = ""
|
|
221
|
+
# ServiceAccount the Job pod runs as; empty falls back to the default SA
|
|
222
|
+
service_account_override: str = ""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class OpenAgentPRsInput:
|
|
227
|
+
"""Input for the activity that lists issue numbers with an open agent PR."""
|
|
228
|
+
|
|
229
|
+
project_id: str
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class AnswerInput:
|
|
234
|
+
job_name: str
|
|
235
|
+
answer: str
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class AwaitInput:
|
|
240
|
+
"""Resume polling a parked Job. Only the job name and poll cadence are needed
|
|
241
|
+
— the poll reads neither project nor task spec."""
|
|
242
|
+
|
|
243
|
+
job_name: str
|
|
244
|
+
poll_interval_seconds: float = 5.0
|
devloop/summarization.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Summarization workflow (issue #24).
|
|
2
|
+
|
|
3
|
+
Runs after a successful Merge (as a Dev Loop child workflow) and on a weekly
|
|
4
|
+
Temporal Schedule. Reads the changes since the last summarized commit, asks the
|
|
5
|
+
LLM for a plain-English digest, and posts it to ``#changelog``.
|
|
6
|
+
|
|
7
|
+
Sandbox-safe: only stdlib + shared imports here. The I/O (GitHub compare, LLM
|
|
8
|
+
call, dedup state) lives in ``summarize_activities`` and is referenced by name.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
|
|
16
|
+
from temporalio import workflow
|
|
17
|
+
from temporalio.common import RetryPolicy
|
|
18
|
+
|
|
19
|
+
from .shared import CHANNEL_CHANGELOG, MESSAGING_QUEUE, SendMessageInput
|
|
20
|
+
|
|
21
|
+
_RETRY = RetryPolicy(maximum_attempts=3)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SummarizeInput:
|
|
26
|
+
project_id: str
|
|
27
|
+
trigger: str = "post-merge" # post-merge | weekly
|
|
28
|
+
head_sha: str = ""
|
|
29
|
+
closed_issues: list[int] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SummarizeResult:
|
|
34
|
+
skipped: bool = False
|
|
35
|
+
summary: str = ""
|
|
36
|
+
head_sha: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@workflow.defn
|
|
40
|
+
class SummarizationWorkflow:
|
|
41
|
+
@workflow.run
|
|
42
|
+
async def run(self, inp: SummarizeInput) -> SummarizeResult:
|
|
43
|
+
result: SummarizeResult = await workflow.execute_activity(
|
|
44
|
+
"summarize_changes",
|
|
45
|
+
inp,
|
|
46
|
+
result_type=SummarizeResult,
|
|
47
|
+
start_to_close_timeout=timedelta(minutes=10),
|
|
48
|
+
retry_policy=_RETRY,
|
|
49
|
+
)
|
|
50
|
+
if result.skipped:
|
|
51
|
+
workflow.logger.info(
|
|
52
|
+
"summary skipped (no new changes) for %s", inp.project_id
|
|
53
|
+
)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
title = f"{inp.project_id} — {inp.trigger} digest"
|
|
57
|
+
await workflow.execute_activity(
|
|
58
|
+
"send_message",
|
|
59
|
+
SendMessageInput(
|
|
60
|
+
workflow_id=workflow.info().workflow_id,
|
|
61
|
+
message=result.summary,
|
|
62
|
+
channel=CHANNEL_CHANGELOG,
|
|
63
|
+
thread_name=title,
|
|
64
|
+
),
|
|
65
|
+
task_queue=MESSAGING_QUEUE,
|
|
66
|
+
start_to_close_timeout=timedelta(seconds=60),
|
|
67
|
+
retry_policy=_RETRY,
|
|
68
|
+
)
|
|
69
|
+
return result
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""I/O activities for the Summarization workflow (issue #24).
|
|
2
|
+
|
|
3
|
+
* dedup state (last-summarized commit SHA per project) is kept in a ConfigMap.
|
|
4
|
+
* the change set is read from the GitHub compare API (no clone needed).
|
|
5
|
+
* the digest is produced by a single-turn LLM call against the homelab model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from temporalio import activity
|
|
15
|
+
|
|
16
|
+
from . import cluster
|
|
17
|
+
from .github_ops import _client # reuse the authed httpx client
|
|
18
|
+
from .projects import get_project, parse_github_repo
|
|
19
|
+
from .summarization import SummarizeInput, SummarizeResult
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
STATE_CONFIGMAP = os.getenv("SUMMARY_STATE_CONFIGMAP", "dev-loop-summary-state")
|
|
24
|
+
OPENAI_BASE_URL = os.getenv("AGENT_OPENAI_BASE_URL", "http://192.168.68.104/v1")
|
|
25
|
+
SUMMARY_MODEL = os.getenv("SUMMARY_MODEL", "qwen3-27b")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --------------------------------------------------------------------------- #
|
|
29
|
+
# Pure helpers
|
|
30
|
+
# --------------------------------------------------------------------------- #
|
|
31
|
+
def should_summarize(last_sha: str, head_sha: str, closed_issues: list[int]) -> bool:
|
|
32
|
+
"""Skip only when nothing new has landed since the last summary."""
|
|
33
|
+
if closed_issues:
|
|
34
|
+
return True
|
|
35
|
+
if not head_sha:
|
|
36
|
+
return False
|
|
37
|
+
return head_sha != last_sha
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_prompt(commits: list[str], issues: list[dict]) -> str:
|
|
41
|
+
commit_block = "\n".join(f"- {c}" for c in commits) or "- (no new commits)"
|
|
42
|
+
issue_block = (
|
|
43
|
+
"\n".join(f"- #{i['number']} {i['title']}" for i in issues) or "- (none)"
|
|
44
|
+
)
|
|
45
|
+
return (
|
|
46
|
+
"You are writing a changelog entry for a homelab Kubernetes repo. "
|
|
47
|
+
"Given the commit messages and resolved issues below, write a short "
|
|
48
|
+
"plain-English paragraph explaining WHAT changed and WHY, followed by a "
|
|
49
|
+
"bullet list of the resolved issues by title. Do NOT include raw diff "
|
|
50
|
+
"lines or git hashes.\n\n"
|
|
51
|
+
f"Commit messages:\n{commit_block}\n\nResolved issues:\n{issue_block}\n"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------- #
|
|
56
|
+
# Dedup state — last-summarized SHA per project, kept in a ConfigMap
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
def get_last_sha(project_id: str) -> str:
|
|
59
|
+
data = cluster.read_configmap_data(STATE_CONFIGMAP) or {}
|
|
60
|
+
return json.loads(data.get("last-sha", "{}")).get(project_id, "")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def set_last_sha(project_id: str, sha: str) -> None:
|
|
64
|
+
data = cluster.read_configmap_data(STATE_CONFIGMAP) or {}
|
|
65
|
+
mapping = json.loads(data.get("last-sha", "{}"))
|
|
66
|
+
mapping[project_id] = sha
|
|
67
|
+
cluster.patch_configmap_data(STATE_CONFIGMAP, {"last-sha": json.dumps(mapping)})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------- #
|
|
71
|
+
# GitHub + LLM
|
|
72
|
+
# --------------------------------------------------------------------------- #
|
|
73
|
+
def _fetch_changes(
|
|
74
|
+
repo: str, base: str, head: str, closed: list[int]
|
|
75
|
+
) -> tuple[list[str], list[dict], str]:
|
|
76
|
+
commits: list[str] = []
|
|
77
|
+
issues: list[dict] = []
|
|
78
|
+
resolved_head = head
|
|
79
|
+
with _client() as c:
|
|
80
|
+
if not resolved_head:
|
|
81
|
+
r = c.get(f"/repos/{repo}/commits", params={"per_page": 1})
|
|
82
|
+
r.raise_for_status()
|
|
83
|
+
resolved_head = r.json()[0]["sha"]
|
|
84
|
+
if base and base != resolved_head:
|
|
85
|
+
r = c.get(f"/repos/{repo}/compare/{base}...{resolved_head}")
|
|
86
|
+
if r.status_code == 200:
|
|
87
|
+
commits = [
|
|
88
|
+
cm["commit"]["message"].splitlines()[0]
|
|
89
|
+
for cm in r.json().get("commits", [])
|
|
90
|
+
]
|
|
91
|
+
for n in closed:
|
|
92
|
+
r = c.get(f"/repos/{repo}/issues/{n}")
|
|
93
|
+
if r.status_code == 200:
|
|
94
|
+
j = r.json()
|
|
95
|
+
issues.append({"number": j["number"], "title": j["title"]})
|
|
96
|
+
return commits, issues, resolved_head
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _llm_summary(prompt: str) -> str:
|
|
100
|
+
import httpx
|
|
101
|
+
|
|
102
|
+
resp = httpx.post(
|
|
103
|
+
f"{OPENAI_BASE_URL}/chat/completions",
|
|
104
|
+
json={
|
|
105
|
+
"model": SUMMARY_MODEL,
|
|
106
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
107
|
+
"temperature": 0.3,
|
|
108
|
+
},
|
|
109
|
+
timeout=120.0,
|
|
110
|
+
)
|
|
111
|
+
resp.raise_for_status()
|
|
112
|
+
return resp.json()["choices"][0]["message"]["content"].strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@activity.defn
|
|
116
|
+
async def summarize_changes(inp: SummarizeInput) -> SummarizeResult:
|
|
117
|
+
cfg = get_project(inp.project_id)
|
|
118
|
+
repo = parse_github_repo(cfg.github_url)
|
|
119
|
+
last_sha = get_last_sha(inp.project_id)
|
|
120
|
+
|
|
121
|
+
commits, issues, head = _fetch_changes(
|
|
122
|
+
repo, last_sha, inp.head_sha, inp.closed_issues
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not should_summarize(last_sha, head, inp.closed_issues):
|
|
126
|
+
return SummarizeResult(skipped=True, head_sha=head)
|
|
127
|
+
|
|
128
|
+
summary = _llm_summary(build_prompt(commits, issues))
|
|
129
|
+
set_last_sha(inp.project_id, head)
|
|
130
|
+
return SummarizeResult(skipped=False, summary=summary, head_sha=head)
|
devloop/webhook.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Webhook receiver for the Orchestration Worker (issues #20, #25, #31).
|
|
2
|
+
|
|
3
|
+
A FastAPI app served alongside the Temporal worker:
|
|
4
|
+
|
|
5
|
+
* ``POST /webhook/github`` — GitHub ``issues`` events; an ``agent-ready`` label
|
|
6
|
+
on an issue starts a Dev Loop workflow for the matching enrolled project.
|
|
7
|
+
HMAC-SHA256 signature verification is enforced when ``GITHUB_WEBHOOK_SECRET``
|
|
8
|
+
is set (GitHub sends the ``X-Hub-Signature-256`` header).
|
|
9
|
+
* ``POST /alertmanager/webhook`` — AlertManager alerts; each starts an Alert
|
|
10
|
+
Response workflow.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI, Request, Response
|
|
22
|
+
from temporalio.client import Client
|
|
23
|
+
from temporalio.common import WorkflowIDConflictPolicy
|
|
24
|
+
|
|
25
|
+
from .projects import ProjectConfig, parse_github_repo
|
|
26
|
+
from .shared import ORCHESTRATION_QUEUE
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Module-level constant so tests can monkeypatch ``webhook.GITHUB_WEBHOOK_SECRET``.
|
|
31
|
+
GITHUB_WEBHOOK_SECRET: str = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _verify_github_signature(body: bytes, signature: str) -> bool:
|
|
35
|
+
"""Return True iff the ``X-Hub-Signature-256`` value matches the body HMAC.
|
|
36
|
+
|
|
37
|
+
The HMAC is computed over the exact raw request bytes — not re-serialised
|
|
38
|
+
JSON — using ``GITHUB_WEBHOOK_SECRET`` as the key. Comparison is done with
|
|
39
|
+
``hmac.compare_digest`` to resist timing attacks.
|
|
40
|
+
"""
|
|
41
|
+
expected = hmac.new(
|
|
42
|
+
GITHUB_WEBHOOK_SECRET.encode(), body, hashlib.sha256
|
|
43
|
+
).hexdigest()
|
|
44
|
+
# GitHub sends "sha256=<hex>" — strip the prefix before comparing.
|
|
45
|
+
received = signature.removeprefix("sha256=")
|
|
46
|
+
return hmac.compare_digest(expected, received)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_app(client: Client, projects: list[ProjectConfig]) -> FastAPI:
|
|
50
|
+
app = FastAPI(title="orchestration-worker-webhooks")
|
|
51
|
+
by_repo = {parse_github_repo(p.github_url): p for p in projects}
|
|
52
|
+
|
|
53
|
+
@app.get("/healthz")
|
|
54
|
+
async def healthz():
|
|
55
|
+
return {"ok": True}
|
|
56
|
+
|
|
57
|
+
@app.post("/webhook/github")
|
|
58
|
+
async def github_webhook(request: Request):
|
|
59
|
+
# Read raw bytes first so HMAC is computed over the exact wire body.
|
|
60
|
+
body = await request.body()
|
|
61
|
+
|
|
62
|
+
if GITHUB_WEBHOOK_SECRET:
|
|
63
|
+
sig = request.headers.get("X-Hub-Signature-256", "")
|
|
64
|
+
if not sig or not _verify_github_signature(body, sig):
|
|
65
|
+
log.warning("GitHub webhook: invalid or missing signature")
|
|
66
|
+
return Response(
|
|
67
|
+
content='{"detail":"invalid signature"}',
|
|
68
|
+
status_code=401,
|
|
69
|
+
media_type="application/json",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
payload = json.loads(body)
|
|
73
|
+
event = request.headers.get("X-GitHub-Event", "")
|
|
74
|
+
if event != "issues" or payload.get("action") != "labeled":
|
|
75
|
+
return {"ignored": f"event={event} action={payload.get('action')}"}
|
|
76
|
+
|
|
77
|
+
label = (payload.get("label") or {}).get("name", "")
|
|
78
|
+
repo = (payload.get("repository") or {}).get("full_name", "")
|
|
79
|
+
project = by_repo.get(repo)
|
|
80
|
+
if project is None or label != project.agent_label:
|
|
81
|
+
return {"ignored": f"repo={repo} label={label}"}
|
|
82
|
+
|
|
83
|
+
issue_number = (payload.get("issue") or {}).get("number")
|
|
84
|
+
wf_id = f"devloop-{project.id}"
|
|
85
|
+
await client.start_workflow(
|
|
86
|
+
"DevLoopWorkflow",
|
|
87
|
+
_dev_loop_input(project.id, project.agent_label),
|
|
88
|
+
id=wf_id,
|
|
89
|
+
task_queue=ORCHESTRATION_QUEUE,
|
|
90
|
+
id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
|
|
91
|
+
)
|
|
92
|
+
log.info(
|
|
93
|
+
"triggered Dev Loop %s for %s (issue #%s)", wf_id, project.id, issue_number
|
|
94
|
+
)
|
|
95
|
+
return {"workflow_id": wf_id, "project": project.id, "issue": issue_number}
|
|
96
|
+
|
|
97
|
+
return app
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Inputs are built lazily to avoid importing the workflow modules (and their
|
|
101
|
+
# passthrough deps) at module import time in the webhook process path.
|
|
102
|
+
def _dev_loop_input(project_id: str, agent_label: str):
|
|
103
|
+
from .dev_loop import DevLoopInput
|
|
104
|
+
|
|
105
|
+
return DevLoopInput(project_id=project_id, agent_label=agent_label)
|