patchrail 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.
- patchrail/__init__.py +7 -0
- patchrail/__main__.py +7 -0
- patchrail/ci/__init__.py +7 -0
- patchrail/ci/classify.py +888 -0
- patchrail/cli.py +8566 -0
- patchrail/funded_issues/__init__.py +138 -0
- patchrail/funded_issues/algora_board.py +240 -0
- patchrail/funded_issues/blocklist.py +112 -0
- patchrail/funded_issues/discovery.py +4091 -0
- patchrail/funded_issues/importers.py +316 -0
- patchrail/funded_issues/source_noise.py +349 -0
- patchrail/funded_issues/store.py +459 -0
- patchrail/queue/__init__.py +75 -0
- patchrail/queue/server.py +273 -0
- patchrail/queue/status.py +756 -0
- patchrail/queue/store.py +600 -0
- patchrail/reviewer_quick_check.py +650 -0
- patchrail/schemas/__init__.py +1 -0
- patchrail/schemas/application-dossier.v1.schema.json +305 -0
- patchrail/schemas/ci-benchmark.v1.schema.json +174 -0
- patchrail/schemas/ci-fixture-check.v1.schema.json +122 -0
- patchrail/schemas/ci-pilot-metrics.v1.schema.json +164 -0
- patchrail/schemas/ci-pilot-summary.v1.schema.json +146 -0
- patchrail/schemas/ci-result.v1.schema.json +133 -0
- patchrail/schemas/funded-issues-client-report.v1.schema.json +524 -0
- patchrail/schemas/funded-issues-recheck-queue.v1.schema.json +333 -0
- patchrail/schemas/funded-issues-recheck-summary.v1.schema.json +136 -0
- patchrail/schemas/funded-issues-report.v1.schema.json +836 -0
- patchrail/schemas/funded-issues-shortlist.v1.schema.json +953 -0
- patchrail/schemas/funded-issues-store-status.v1.schema.json +96 -0
- patchrail/schemas/funded-issues-store.v1.schema.json +117 -0
- patchrail/schemas/queue-audit-event.v1.schema.json +44 -0
- patchrail/schemas/queue-audit-summary.v1.schema.json +169 -0
- patchrail/schemas/queue-gate-report.v1.schema.json +158 -0
- patchrail/schemas/queue-policy-resolution.v1.schema.json +188 -0
- patchrail/schemas/queue-policy-scan.v1.schema.json +175 -0
- patchrail/schemas/queue-proposal.v1.schema.json +61 -0
- patchrail/schemas/queue-review.v1.schema.json +218 -0
- patchrail/schemas/queue-status.v1.schema.json +179 -0
- patchrail/schemas/queue-work-item.v1.schema.json +64 -0
- patchrail/schemas/reviewer-quick-check-artifacts.v1.schema.json +104 -0
- patchrail/web_metrics.py +649 -0
- patchrail-0.1.0.dist-info/METADATA +279 -0
- patchrail-0.1.0.dist-info/RECORD +47 -0
- patchrail-0.1.0.dist-info/WHEEL +4 -0
- patchrail-0.1.0.dist-info/entry_points.txt +2 -0
- patchrail-0.1.0.dist-info/licenses/LICENSE +202 -0
patchrail/queue/store.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
SCHEMA_VERSION = "patchrail.queue.v1"
|
|
12
|
+
DEFAULT_QUEUE_PATH = Path(".patchrail") / "queue.sqlite"
|
|
13
|
+
|
|
14
|
+
VALID_APPROVAL_STATES = {"pending", "approved", "rejected"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class QueueItem:
|
|
19
|
+
id: str
|
|
20
|
+
kind: str
|
|
21
|
+
title: str
|
|
22
|
+
source: str
|
|
23
|
+
status: str
|
|
24
|
+
approval_state: str
|
|
25
|
+
write_actions_allowed: bool
|
|
26
|
+
created_at: str
|
|
27
|
+
updated_at: str
|
|
28
|
+
payload: dict[str, Any]
|
|
29
|
+
decision_note: str | None = None
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"id": self.id,
|
|
34
|
+
"kind": self.kind,
|
|
35
|
+
"title": self.title,
|
|
36
|
+
"source": self.source,
|
|
37
|
+
"status": self.status,
|
|
38
|
+
"approval_state": self.approval_state,
|
|
39
|
+
"write_actions_allowed": self.write_actions_allowed,
|
|
40
|
+
"created_at": self.created_at,
|
|
41
|
+
"updated_at": self.updated_at,
|
|
42
|
+
"payload": self.payload,
|
|
43
|
+
"decision_note": self.decision_note,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class AuditEvent:
|
|
49
|
+
id: int
|
|
50
|
+
ts: str
|
|
51
|
+
event_type: str
|
|
52
|
+
work_item_id: str | None
|
|
53
|
+
payload: dict[str, Any]
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"id": self.id,
|
|
58
|
+
"ts": self.ts,
|
|
59
|
+
"event_type": self.event_type,
|
|
60
|
+
"work_item_id": self.work_item_id,
|
|
61
|
+
"payload": self.payload,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ProposalRecord:
|
|
67
|
+
id: str
|
|
68
|
+
work_item_id: str
|
|
69
|
+
title: str
|
|
70
|
+
summary: str
|
|
71
|
+
patch_plan: str
|
|
72
|
+
risk_level: str
|
|
73
|
+
approval_state: str
|
|
74
|
+
created_at: str
|
|
75
|
+
updated_at: str
|
|
76
|
+
decision_note: str | None = None
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"id": self.id,
|
|
81
|
+
"work_item_id": self.work_item_id,
|
|
82
|
+
"title": self.title,
|
|
83
|
+
"summary": self.summary,
|
|
84
|
+
"patch_plan": self.patch_plan,
|
|
85
|
+
"risk_level": self.risk_level,
|
|
86
|
+
"approval_state": self.approval_state,
|
|
87
|
+
"created_at": self.created_at,
|
|
88
|
+
"updated_at": self.updated_at,
|
|
89
|
+
"decision_note": self.decision_note,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _now() -> str:
|
|
94
|
+
return datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
98
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
conn = sqlite3.connect(db_path)
|
|
100
|
+
conn.row_factory = sqlite3.Row
|
|
101
|
+
return conn
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def init_queue(db_path: Path = DEFAULT_QUEUE_PATH) -> dict[str, Any]:
|
|
105
|
+
with _connect(db_path) as conn:
|
|
106
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
107
|
+
conn.execute(
|
|
108
|
+
"""
|
|
109
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
110
|
+
key TEXT PRIMARY KEY,
|
|
111
|
+
value TEXT NOT NULL
|
|
112
|
+
)
|
|
113
|
+
"""
|
|
114
|
+
)
|
|
115
|
+
conn.execute(
|
|
116
|
+
"""
|
|
117
|
+
CREATE TABLE IF NOT EXISTS work_items (
|
|
118
|
+
id TEXT PRIMARY KEY,
|
|
119
|
+
kind TEXT NOT NULL,
|
|
120
|
+
title TEXT NOT NULL,
|
|
121
|
+
source TEXT NOT NULL,
|
|
122
|
+
status TEXT NOT NULL,
|
|
123
|
+
approval_state TEXT NOT NULL,
|
|
124
|
+
write_actions_allowed INTEGER NOT NULL DEFAULT 0,
|
|
125
|
+
created_at TEXT NOT NULL,
|
|
126
|
+
updated_at TEXT NOT NULL,
|
|
127
|
+
payload_json TEXT NOT NULL,
|
|
128
|
+
decision_note TEXT
|
|
129
|
+
)
|
|
130
|
+
"""
|
|
131
|
+
)
|
|
132
|
+
conn.execute(
|
|
133
|
+
"""
|
|
134
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
135
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
136
|
+
ts TEXT NOT NULL,
|
|
137
|
+
event_type TEXT NOT NULL,
|
|
138
|
+
work_item_id TEXT,
|
|
139
|
+
payload_json TEXT NOT NULL
|
|
140
|
+
)
|
|
141
|
+
"""
|
|
142
|
+
)
|
|
143
|
+
conn.execute(
|
|
144
|
+
"""
|
|
145
|
+
CREATE TABLE IF NOT EXISTS proposals (
|
|
146
|
+
id TEXT PRIMARY KEY,
|
|
147
|
+
work_item_id TEXT NOT NULL,
|
|
148
|
+
title TEXT NOT NULL,
|
|
149
|
+
summary TEXT NOT NULL,
|
|
150
|
+
patch_plan TEXT NOT NULL,
|
|
151
|
+
risk_level TEXT NOT NULL,
|
|
152
|
+
approval_state TEXT NOT NULL,
|
|
153
|
+
created_at TEXT NOT NULL,
|
|
154
|
+
updated_at TEXT NOT NULL,
|
|
155
|
+
decision_note TEXT,
|
|
156
|
+
FOREIGN KEY(work_item_id) REFERENCES work_items(id)
|
|
157
|
+
)
|
|
158
|
+
"""
|
|
159
|
+
)
|
|
160
|
+
conn.execute(
|
|
161
|
+
"""
|
|
162
|
+
INSERT INTO metadata(key, value)
|
|
163
|
+
VALUES('schema_version', ?)
|
|
164
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
|
165
|
+
""",
|
|
166
|
+
(SCHEMA_VERSION,),
|
|
167
|
+
)
|
|
168
|
+
return {
|
|
169
|
+
"schema_version": SCHEMA_VERSION,
|
|
170
|
+
"db_path": str(db_path),
|
|
171
|
+
"local_first": True,
|
|
172
|
+
"write_actions_allowed_by_default": False,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _row_to_item(row: sqlite3.Row) -> QueueItem:
|
|
177
|
+
return QueueItem(
|
|
178
|
+
id=str(row["id"]),
|
|
179
|
+
kind=str(row["kind"]),
|
|
180
|
+
title=str(row["title"]),
|
|
181
|
+
source=str(row["source"]),
|
|
182
|
+
status=str(row["status"]),
|
|
183
|
+
approval_state=str(row["approval_state"]),
|
|
184
|
+
write_actions_allowed=bool(row["write_actions_allowed"]),
|
|
185
|
+
created_at=str(row["created_at"]),
|
|
186
|
+
updated_at=str(row["updated_at"]),
|
|
187
|
+
payload=json.loads(str(row["payload_json"])),
|
|
188
|
+
decision_note=row["decision_note"],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _row_to_audit_event(row: sqlite3.Row) -> AuditEvent:
|
|
193
|
+
return AuditEvent(
|
|
194
|
+
id=int(row["id"]),
|
|
195
|
+
ts=str(row["ts"]),
|
|
196
|
+
event_type=str(row["event_type"]),
|
|
197
|
+
work_item_id=row["work_item_id"],
|
|
198
|
+
payload=json.loads(str(row["payload_json"])),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _row_to_proposal(row: sqlite3.Row) -> ProposalRecord:
|
|
203
|
+
return ProposalRecord(
|
|
204
|
+
id=str(row["id"]),
|
|
205
|
+
work_item_id=str(row["work_item_id"]),
|
|
206
|
+
title=str(row["title"]),
|
|
207
|
+
summary=str(row["summary"]),
|
|
208
|
+
patch_plan=str(row["patch_plan"]),
|
|
209
|
+
risk_level=str(row["risk_level"]),
|
|
210
|
+
approval_state=str(row["approval_state"]),
|
|
211
|
+
created_at=str(row["created_at"]),
|
|
212
|
+
updated_at=str(row["updated_at"]),
|
|
213
|
+
decision_note=row["decision_note"],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _write_audit_event(
|
|
218
|
+
conn: sqlite3.Connection,
|
|
219
|
+
*,
|
|
220
|
+
event_type: str,
|
|
221
|
+
work_item_id: str | None,
|
|
222
|
+
payload: dict[str, Any],
|
|
223
|
+
) -> None:
|
|
224
|
+
conn.execute(
|
|
225
|
+
"""
|
|
226
|
+
INSERT INTO audit_events(ts, event_type, work_item_id, payload_json)
|
|
227
|
+
VALUES(?, ?, ?, ?)
|
|
228
|
+
""",
|
|
229
|
+
(_now(), event_type, work_item_id, json.dumps(payload, sort_keys=True)),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def add_work_item(
|
|
234
|
+
*,
|
|
235
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
236
|
+
kind: str,
|
|
237
|
+
title: str,
|
|
238
|
+
source: str = "manual",
|
|
239
|
+
payload: dict[str, Any] | None = None,
|
|
240
|
+
) -> QueueItem:
|
|
241
|
+
init_queue(db_path)
|
|
242
|
+
item_id = f"prq_{uuid4().hex[:12]}"
|
|
243
|
+
ts = _now()
|
|
244
|
+
safe_payload = payload or {}
|
|
245
|
+
with _connect(db_path) as conn:
|
|
246
|
+
conn.execute(
|
|
247
|
+
"""
|
|
248
|
+
INSERT INTO work_items(
|
|
249
|
+
id, kind, title, source, status, approval_state,
|
|
250
|
+
write_actions_allowed, created_at, updated_at, payload_json
|
|
251
|
+
)
|
|
252
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
253
|
+
""",
|
|
254
|
+
(
|
|
255
|
+
item_id,
|
|
256
|
+
kind,
|
|
257
|
+
title,
|
|
258
|
+
source,
|
|
259
|
+
"open",
|
|
260
|
+
"pending",
|
|
261
|
+
0,
|
|
262
|
+
ts,
|
|
263
|
+
ts,
|
|
264
|
+
json.dumps(safe_payload, sort_keys=True),
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
_write_audit_event(
|
|
268
|
+
conn,
|
|
269
|
+
event_type="work_item_added",
|
|
270
|
+
work_item_id=item_id,
|
|
271
|
+
payload={"kind": kind, "source": source, "approval_state": "pending"},
|
|
272
|
+
)
|
|
273
|
+
return show_work_item(db_path=db_path, item_id=item_id)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def list_work_items(
|
|
277
|
+
*,
|
|
278
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
279
|
+
status: str | None = None,
|
|
280
|
+
approval_state: str | None = None,
|
|
281
|
+
) -> list[QueueItem]:
|
|
282
|
+
init_queue(db_path)
|
|
283
|
+
clauses: list[str] = []
|
|
284
|
+
values: list[str] = []
|
|
285
|
+
if status:
|
|
286
|
+
clauses.append("status = ?")
|
|
287
|
+
values.append(status)
|
|
288
|
+
if approval_state:
|
|
289
|
+
if approval_state not in VALID_APPROVAL_STATES:
|
|
290
|
+
raise ValueError(f"unknown approval state: {approval_state}")
|
|
291
|
+
clauses.append("approval_state = ?")
|
|
292
|
+
values.append(approval_state)
|
|
293
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
294
|
+
with _connect(db_path) as conn:
|
|
295
|
+
rows = conn.execute(
|
|
296
|
+
f"SELECT * FROM work_items {where} ORDER BY created_at DESC, id DESC",
|
|
297
|
+
values,
|
|
298
|
+
).fetchall()
|
|
299
|
+
return [_row_to_item(row) for row in rows]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def show_work_item(*, db_path: Path = DEFAULT_QUEUE_PATH, item_id: str) -> QueueItem:
|
|
303
|
+
init_queue(db_path)
|
|
304
|
+
with _connect(db_path) as conn:
|
|
305
|
+
row = conn.execute("SELECT * FROM work_items WHERE id = ?", (item_id,)).fetchone()
|
|
306
|
+
if row is None:
|
|
307
|
+
raise KeyError(item_id)
|
|
308
|
+
return _row_to_item(row)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _set_approval_state(
|
|
312
|
+
*,
|
|
313
|
+
db_path: Path,
|
|
314
|
+
item_id: str,
|
|
315
|
+
approval_state: str,
|
|
316
|
+
decision_note: str | None,
|
|
317
|
+
) -> QueueItem:
|
|
318
|
+
if approval_state not in VALID_APPROVAL_STATES:
|
|
319
|
+
raise ValueError(f"unknown approval state: {approval_state}")
|
|
320
|
+
init_queue(db_path)
|
|
321
|
+
ts = _now()
|
|
322
|
+
status = "open" if approval_state == "approved" else approval_state
|
|
323
|
+
with _connect(db_path) as conn:
|
|
324
|
+
current = conn.execute("SELECT id FROM work_items WHERE id = ?", (item_id,)).fetchone()
|
|
325
|
+
if current is None:
|
|
326
|
+
raise KeyError(item_id)
|
|
327
|
+
conn.execute(
|
|
328
|
+
"""
|
|
329
|
+
UPDATE work_items
|
|
330
|
+
SET approval_state = ?, status = ?, decision_note = ?, updated_at = ?
|
|
331
|
+
WHERE id = ?
|
|
332
|
+
""",
|
|
333
|
+
(approval_state, status, decision_note, ts, item_id),
|
|
334
|
+
)
|
|
335
|
+
_write_audit_event(
|
|
336
|
+
conn,
|
|
337
|
+
event_type=f"work_item_{approval_state}",
|
|
338
|
+
work_item_id=item_id,
|
|
339
|
+
payload={"decision_note": decision_note},
|
|
340
|
+
)
|
|
341
|
+
return show_work_item(db_path=db_path, item_id=item_id)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def approve_work_item(
|
|
345
|
+
*,
|
|
346
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
347
|
+
item_id: str,
|
|
348
|
+
decision_note: str | None = None,
|
|
349
|
+
) -> QueueItem:
|
|
350
|
+
return _set_approval_state(
|
|
351
|
+
db_path=db_path,
|
|
352
|
+
item_id=item_id,
|
|
353
|
+
approval_state="approved",
|
|
354
|
+
decision_note=decision_note,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def reject_work_item(
|
|
359
|
+
*,
|
|
360
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
361
|
+
item_id: str,
|
|
362
|
+
decision_note: str | None = None,
|
|
363
|
+
) -> QueueItem:
|
|
364
|
+
return _set_approval_state(
|
|
365
|
+
db_path=db_path,
|
|
366
|
+
item_id=item_id,
|
|
367
|
+
approval_state="rejected",
|
|
368
|
+
decision_note=decision_note,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def skip_work_item(
|
|
373
|
+
*,
|
|
374
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
375
|
+
item_id: str,
|
|
376
|
+
decision_note: str,
|
|
377
|
+
) -> QueueItem:
|
|
378
|
+
init_queue(db_path)
|
|
379
|
+
ts = _now()
|
|
380
|
+
with _connect(db_path) as conn:
|
|
381
|
+
current = conn.execute("SELECT id FROM work_items WHERE id = ?", (item_id,)).fetchone()
|
|
382
|
+
if current is None:
|
|
383
|
+
raise KeyError(item_id)
|
|
384
|
+
conn.execute(
|
|
385
|
+
"""
|
|
386
|
+
UPDATE work_items
|
|
387
|
+
SET approval_state = ?, status = ?, decision_note = ?, updated_at = ?
|
|
388
|
+
WHERE id = ?
|
|
389
|
+
""",
|
|
390
|
+
("rejected", "skipped", decision_note, ts, item_id),
|
|
391
|
+
)
|
|
392
|
+
_write_audit_event(
|
|
393
|
+
conn,
|
|
394
|
+
event_type="work_item_skipped",
|
|
395
|
+
work_item_id=item_id,
|
|
396
|
+
payload={
|
|
397
|
+
"approval_state": "rejected",
|
|
398
|
+
"status": "skipped",
|
|
399
|
+
"decision_note": decision_note,
|
|
400
|
+
},
|
|
401
|
+
)
|
|
402
|
+
return show_work_item(db_path=db_path, item_id=item_id)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def add_proposal(
|
|
406
|
+
*,
|
|
407
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
408
|
+
work_item_id: str,
|
|
409
|
+
title: str,
|
|
410
|
+
summary: str,
|
|
411
|
+
patch_plan: str,
|
|
412
|
+
risk_level: str = "medium",
|
|
413
|
+
) -> ProposalRecord:
|
|
414
|
+
init_queue(db_path)
|
|
415
|
+
show_work_item(db_path=db_path, item_id=work_item_id)
|
|
416
|
+
proposal_id = f"prp_{uuid4().hex[:12]}"
|
|
417
|
+
ts = _now()
|
|
418
|
+
with _connect(db_path) as conn:
|
|
419
|
+
conn.execute(
|
|
420
|
+
"""
|
|
421
|
+
INSERT INTO proposals(
|
|
422
|
+
id, work_item_id, title, summary, patch_plan, risk_level,
|
|
423
|
+
approval_state, created_at, updated_at
|
|
424
|
+
)
|
|
425
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
426
|
+
""",
|
|
427
|
+
(
|
|
428
|
+
proposal_id,
|
|
429
|
+
work_item_id,
|
|
430
|
+
title,
|
|
431
|
+
summary,
|
|
432
|
+
patch_plan,
|
|
433
|
+
risk_level,
|
|
434
|
+
"pending",
|
|
435
|
+
ts,
|
|
436
|
+
ts,
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
_write_audit_event(
|
|
440
|
+
conn,
|
|
441
|
+
event_type="proposal_added",
|
|
442
|
+
work_item_id=work_item_id,
|
|
443
|
+
payload={
|
|
444
|
+
"proposal_id": proposal_id,
|
|
445
|
+
"risk_level": risk_level,
|
|
446
|
+
"approval_state": "pending",
|
|
447
|
+
},
|
|
448
|
+
)
|
|
449
|
+
return show_proposal(db_path=db_path, proposal_id=proposal_id)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def list_proposals(
|
|
453
|
+
*,
|
|
454
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
455
|
+
work_item_id: str | None = None,
|
|
456
|
+
approval_state: str | None = None,
|
|
457
|
+
) -> list[ProposalRecord]:
|
|
458
|
+
init_queue(db_path)
|
|
459
|
+
clauses: list[str] = []
|
|
460
|
+
values: list[str] = []
|
|
461
|
+
if work_item_id:
|
|
462
|
+
clauses.append("work_item_id = ?")
|
|
463
|
+
values.append(work_item_id)
|
|
464
|
+
if approval_state:
|
|
465
|
+
if approval_state not in VALID_APPROVAL_STATES:
|
|
466
|
+
raise ValueError(f"unknown approval state: {approval_state}")
|
|
467
|
+
clauses.append("approval_state = ?")
|
|
468
|
+
values.append(approval_state)
|
|
469
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
470
|
+
with _connect(db_path) as conn:
|
|
471
|
+
rows = conn.execute(
|
|
472
|
+
f"SELECT * FROM proposals {where} ORDER BY created_at DESC, id DESC",
|
|
473
|
+
values,
|
|
474
|
+
).fetchall()
|
|
475
|
+
return [_row_to_proposal(row) for row in rows]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def show_proposal(*, db_path: Path = DEFAULT_QUEUE_PATH, proposal_id: str) -> ProposalRecord:
|
|
479
|
+
init_queue(db_path)
|
|
480
|
+
with _connect(db_path) as conn:
|
|
481
|
+
row = conn.execute("SELECT * FROM proposals WHERE id = ?", (proposal_id,)).fetchone()
|
|
482
|
+
if row is None:
|
|
483
|
+
raise KeyError(proposal_id)
|
|
484
|
+
return _row_to_proposal(row)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _set_proposal_approval_state(
|
|
488
|
+
*,
|
|
489
|
+
db_path: Path,
|
|
490
|
+
proposal_id: str,
|
|
491
|
+
approval_state: str,
|
|
492
|
+
decision_note: str | None,
|
|
493
|
+
) -> ProposalRecord:
|
|
494
|
+
if approval_state not in VALID_APPROVAL_STATES:
|
|
495
|
+
raise ValueError(f"unknown approval state: {approval_state}")
|
|
496
|
+
init_queue(db_path)
|
|
497
|
+
ts = _now()
|
|
498
|
+
with _connect(db_path) as conn:
|
|
499
|
+
current = conn.execute(
|
|
500
|
+
"SELECT id, work_item_id FROM proposals WHERE id = ?",
|
|
501
|
+
(proposal_id,),
|
|
502
|
+
).fetchone()
|
|
503
|
+
if current is None:
|
|
504
|
+
raise KeyError(proposal_id)
|
|
505
|
+
conn.execute(
|
|
506
|
+
"""
|
|
507
|
+
UPDATE proposals
|
|
508
|
+
SET approval_state = ?, decision_note = ?, updated_at = ?
|
|
509
|
+
WHERE id = ?
|
|
510
|
+
""",
|
|
511
|
+
(approval_state, decision_note, ts, proposal_id),
|
|
512
|
+
)
|
|
513
|
+
_write_audit_event(
|
|
514
|
+
conn,
|
|
515
|
+
event_type=f"proposal_{approval_state}",
|
|
516
|
+
work_item_id=str(current["work_item_id"]),
|
|
517
|
+
payload={"proposal_id": proposal_id, "decision_note": decision_note},
|
|
518
|
+
)
|
|
519
|
+
return show_proposal(db_path=db_path, proposal_id=proposal_id)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def approve_proposal(
|
|
523
|
+
*,
|
|
524
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
525
|
+
proposal_id: str,
|
|
526
|
+
decision_note: str | None = None,
|
|
527
|
+
) -> ProposalRecord:
|
|
528
|
+
return _set_proposal_approval_state(
|
|
529
|
+
db_path=db_path,
|
|
530
|
+
proposal_id=proposal_id,
|
|
531
|
+
approval_state="approved",
|
|
532
|
+
decision_note=decision_note,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def reject_proposal(
|
|
537
|
+
*,
|
|
538
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
539
|
+
proposal_id: str,
|
|
540
|
+
decision_note: str | None = None,
|
|
541
|
+
) -> ProposalRecord:
|
|
542
|
+
return _set_proposal_approval_state(
|
|
543
|
+
db_path=db_path,
|
|
544
|
+
proposal_id=proposal_id,
|
|
545
|
+
approval_state="rejected",
|
|
546
|
+
decision_note=decision_note,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def export_work_items(*, db_path: Path = DEFAULT_QUEUE_PATH) -> dict[str, Any]:
|
|
551
|
+
work_items = [item.to_dict() for item in list_work_items(db_path=db_path)]
|
|
552
|
+
with _connect(db_path) as conn:
|
|
553
|
+
_write_audit_event(
|
|
554
|
+
conn,
|
|
555
|
+
event_type="work_items_exported",
|
|
556
|
+
work_item_id=None,
|
|
557
|
+
payload={"count": len(work_items), "export": "work_items"},
|
|
558
|
+
)
|
|
559
|
+
return {
|
|
560
|
+
"schema_version": SCHEMA_VERSION,
|
|
561
|
+
"db_path": str(db_path),
|
|
562
|
+
"local_first": True,
|
|
563
|
+
"work_items": work_items,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def list_audit_events(
|
|
568
|
+
*,
|
|
569
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
570
|
+
work_item_id: str | None = None,
|
|
571
|
+
) -> list[AuditEvent]:
|
|
572
|
+
init_queue(db_path)
|
|
573
|
+
clauses: list[str] = []
|
|
574
|
+
values: list[str] = []
|
|
575
|
+
if work_item_id:
|
|
576
|
+
clauses.append("work_item_id = ?")
|
|
577
|
+
values.append(work_item_id)
|
|
578
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
579
|
+
with _connect(db_path) as conn:
|
|
580
|
+
rows = conn.execute(
|
|
581
|
+
f"SELECT * FROM audit_events {where} ORDER BY id ASC",
|
|
582
|
+
values,
|
|
583
|
+
).fetchall()
|
|
584
|
+
return [_row_to_audit_event(row) for row in rows]
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def export_audit_events(
|
|
588
|
+
*,
|
|
589
|
+
db_path: Path = DEFAULT_QUEUE_PATH,
|
|
590
|
+
work_item_id: str | None = None,
|
|
591
|
+
) -> dict[str, Any]:
|
|
592
|
+
return {
|
|
593
|
+
"schema_version": SCHEMA_VERSION,
|
|
594
|
+
"db_path": str(db_path),
|
|
595
|
+
"local_first": True,
|
|
596
|
+
"audit_events": [
|
|
597
|
+
event.to_dict()
|
|
598
|
+
for event in list_audit_events(db_path=db_path, work_item_id=work_item_id)
|
|
599
|
+
],
|
|
600
|
+
}
|