@topy-ai/maggie 0.7.13 → 0.7.15

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.
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """Track project scenario-based browser QA from test through retest.
3
+
4
+ The command stores secret-free run state in ``.maggie/qa-runs``. Browser
5
+ interaction remains with the selected browser skill; this CLI records the
6
+ scenario, evidence references, fix event and final release decision.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from urllib.parse import urlsplit, urlunsplit
20
+
21
+
22
+ DEFAULT_SCENARIOS = Path(".maggie") / "scenario-manifest.json"
23
+ VALID_STATUSES = {"pending", "pass", "fail", "blocked", "inconclusive"}
24
+ VALID_PHASES = {"test", "fix", "retest"}
25
+ SECRET_RE = re.compile(
26
+ r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|authorization|cookie)\s*[=:]\s*)[^\s,;]+"
27
+ )
28
+ EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
29
+ QUERY_SECRET_RE = re.compile(r"(?i)([?&](?:token|key|secret|password|code|id_token|access_token)=)[^&\s]+")
30
+
31
+
32
+ def now() -> str:
33
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
34
+
35
+
36
+ def safe_text(value: object, limit: int = 1200) -> str:
37
+ text = str(value or "").strip()
38
+ text = SECRET_RE.sub(r"\1[REDACTED]", text)
39
+ text = QUERY_SECRET_RE.sub(r"\1[REDACTED]", text)
40
+ text = EMAIL_RE.sub("[REDACTED_EMAIL]", text)
41
+ return text[:limit]
42
+
43
+
44
+ def safe_base_url(value: object) -> str:
45
+ """Keep the origin only so reports do not retain private paths or queries."""
46
+ text = safe_text(value, 500)
47
+ try:
48
+ parsed = urlsplit(text)
49
+ if parsed.scheme and parsed.hostname:
50
+ netloc = parsed.hostname
51
+ if parsed.port:
52
+ netloc = f"{netloc}:{parsed.port}"
53
+ return urlunsplit((parsed.scheme, netloc, "", "", ""))
54
+ except ValueError:
55
+ pass
56
+ return text
57
+
58
+
59
+ def project_root(value: str | os.PathLike[str]) -> Path:
60
+ return Path(value).expanduser().resolve()
61
+
62
+
63
+ def load_json(path: Path) -> dict:
64
+ try:
65
+ value = json.loads(path.read_text(encoding="utf-8"))
66
+ except (OSError, json.JSONDecodeError) as error:
67
+ raise ValueError(f"could not read JSON {path}: {error}") from error
68
+ if not isinstance(value, dict):
69
+ raise ValueError(f"JSON root must be an object: {path}")
70
+ return value
71
+
72
+
73
+ def scenario_records(path: Path) -> list[dict]:
74
+ manifest = load_json(path)
75
+ scenarios = manifest.get("scenarios")
76
+ if not isinstance(scenarios, list) or not scenarios:
77
+ raise ValueError("scenario manifest must contain a non-empty scenarios array")
78
+ result = []
79
+ seen: set[str] = set()
80
+ for item in scenarios:
81
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
82
+ raise ValueError("each scenario must be an object with an id")
83
+ scenario_id = item["id"]
84
+ if scenario_id in seen:
85
+ raise ValueError(f"duplicate scenario id: {scenario_id}")
86
+ seen.add(scenario_id)
87
+ result.append(item)
88
+ return result
89
+
90
+
91
+ def run_directory(project: Path) -> Path:
92
+ directory = project / ".maggie" / "qa-runs"
93
+ directory.mkdir(parents=True, exist_ok=True)
94
+ return directory
95
+
96
+
97
+ def normal_run_id(value: str | None) -> str:
98
+ if value:
99
+ value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-")
100
+ if not value:
101
+ raise ValueError("run id cannot be empty")
102
+ return value
103
+ return "qa-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
104
+
105
+
106
+ def run_path(project: Path, value: str) -> Path:
107
+ candidate = Path(value).expanduser()
108
+ if candidate.suffix == ".json" or candidate.parent != Path("."):
109
+ path = candidate if candidate.is_absolute() else project / candidate
110
+ else:
111
+ path = run_directory(project) / f"{normal_run_id(value)}.json"
112
+ path = path.resolve()
113
+ if not path.is_file():
114
+ raise ValueError(f"QA run does not exist: {path}")
115
+ return path
116
+
117
+
118
+ def write_json(path: Path, value: dict) -> None:
119
+ path.parent.mkdir(parents=True, exist_ok=True)
120
+ temporary = path.with_suffix(path.suffix + ".tmp")
121
+ temporary.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
122
+ temporary.replace(path)
123
+
124
+
125
+ def calculate(run: dict) -> dict:
126
+ counts = {status: 0 for status in VALID_STATUSES}
127
+ lifecycle_counts = {"ready": 0, "fix_pending": 0}
128
+ for item in run.get("scenarios", {}).values():
129
+ status = item.get("status", "pending")
130
+ counts[status] = counts.get(status, 0) + 1
131
+ lifecycle_counts["fix_pending" if item.get("lifecycle") == "fix_pending" else "ready"] += 1
132
+ if counts["fail"]:
133
+ gate = "fail"
134
+ elif counts["pending"] or counts["blocked"] or counts["inconclusive"]:
135
+ gate = "blocked"
136
+ else:
137
+ gate = "pass"
138
+ return {"gate": gate, "counts": counts, "lifecycle": lifecycle_counts, "total": sum(counts.values())}
139
+
140
+
141
+ def start(args: argparse.Namespace) -> int:
142
+ project = project_root(args.project)
143
+ manifest_path = project_root(args.scenario_file) if Path(args.scenario_file).is_absolute() else project / args.scenario_file
144
+ records = scenario_records(manifest_path)
145
+ run_id = normal_run_id(args.run_id)
146
+ path = run_directory(project) / f"{run_id}.json"
147
+ if path.exists():
148
+ raise ValueError(f"QA run already exists: {path}")
149
+ scenarios = {
150
+ item["id"]: {
151
+ "id": item["id"],
152
+ "group": item.get("group", ""),
153
+ "title": item.get("title", ""),
154
+ "priority": item.get("priority", "P2"),
155
+ "routes": item.get("routes", []),
156
+ "auth": item.get("auth", ""),
157
+ "browser": bool(item.get("browser", True)),
158
+ "status": "pending",
159
+ "lifecycle": "ready",
160
+ "history": [],
161
+ }
162
+ for item in records
163
+ }
164
+ try:
165
+ manifest_label = str(manifest_path.relative_to(project))
166
+ except ValueError:
167
+ # Do not persist an absolute path outside the project. The basename is
168
+ # enough to identify the source without leaking a local filesystem
169
+ # layout into a shareable run artifact.
170
+ manifest_label = manifest_path.name
171
+ run = {
172
+ "schemaVersion": "maggie.qa-run.v1",
173
+ "runId": run_id,
174
+ "createdAt": now(),
175
+ "updatedAt": now(),
176
+ "project": hashlib.sha256(str(project).encode()).hexdigest()[:16],
177
+ "environment": safe_text(args.environment),
178
+ "baseUrl": safe_base_url(args.base_url),
179
+ "browser": safe_text(args.browser),
180
+ "commit": safe_text(args.commit),
181
+ "release": safe_text(args.release),
182
+ "scenarioManifest": manifest_label,
183
+ "scenarios": scenarios,
184
+ }
185
+ run["summary"] = calculate(run)
186
+ write_json(path, run)
187
+ print(json.dumps({"runId": run_id, "path": str(path), "summary": run["summary"]}, ensure_ascii=False, indent=2))
188
+ return 0
189
+
190
+
191
+ def evidence(value: str, project: Path) -> dict:
192
+ clean = safe_text(value, 500)
193
+ path = Path(value).expanduser()
194
+ if path.is_file():
195
+ resolved = path.resolve()
196
+ try:
197
+ display = str(resolved.relative_to(project))
198
+ except ValueError:
199
+ display = resolved.name
200
+ return {"path": display, "sha256": hashlib.sha256(resolved.read_bytes()).hexdigest()}
201
+ return {"reference": clean}
202
+
203
+
204
+ def record(args: argparse.Namespace) -> int:
205
+ project = project_root(args.project)
206
+ path = run_path(project, args.run)
207
+ run = load_json(path)
208
+ scenarios = run.get("scenarios", {})
209
+ item = scenarios.get(args.scenario)
210
+ if not isinstance(item, dict):
211
+ raise ValueError(f"scenario is not in this run: {args.scenario}")
212
+ phase = args.phase
213
+ if phase not in VALID_PHASES:
214
+ raise ValueError(f"phase must be one of: {', '.join(sorted(VALID_PHASES))}")
215
+ if phase in {"test", "retest"} and args.status not in VALID_STATUSES - {"pending"}:
216
+ raise ValueError("test and retest require status pass, fail, blocked or inconclusive")
217
+ if phase == "fix" and not args.resolution:
218
+ raise ValueError("fix requires --resolution")
219
+ if phase == "fix" and item.get("status") != "fail":
220
+ raise ValueError("fix can only follow a recorded fail; use retest after a blocked or inconclusive run")
221
+ if phase == "retest" and not any(event.get("phase") == "test" for event in item.get("history", [])):
222
+ raise ValueError("retest requires an earlier test event")
223
+ if phase == "retest" and item.get("status") == "fail" and not any(event.get("phase") == "fix" for event in item.get("history", [])):
224
+ raise ValueError("a failed scenario must have a recorded fix before retest")
225
+ if phase == "test" and args.status == "fail" and not (args.expected and args.actual and args.error_fingerprint):
226
+ raise ValueError("a failed test requires --expected, --actual and --error-fingerprint")
227
+ event = {
228
+ "at": now(),
229
+ "phase": phase,
230
+ "status": args.status if phase != "fix" else "fix_pending",
231
+ "summary": safe_text(args.summary),
232
+ "expected": safe_text(args.expected),
233
+ "actual": safe_text(args.actual),
234
+ "errorFingerprint": safe_text(args.error_fingerprint, 300),
235
+ "issue": safe_text(args.issue, 500),
236
+ "resolution": safe_text(args.resolution),
237
+ "validation": safe_text(args.validation),
238
+ "evidence": [evidence(value, project) for value in args.evidence],
239
+ }
240
+ item.setdefault("history", []).append(event)
241
+ if phase == "fix":
242
+ item["lifecycle"] = "fix_pending"
243
+ else:
244
+ item["status"] = args.status
245
+ item["lifecycle"] = "ready"
246
+ run["updatedAt"] = now()
247
+ run["summary"] = calculate(run)
248
+ write_json(path, run)
249
+ print(json.dumps({"runId": run.get("runId"), "scenario": args.scenario, "event": event, "summary": run["summary"]}, ensure_ascii=False, indent=2))
250
+ return 0
251
+
252
+
253
+ def markdown(run: dict) -> str:
254
+ summary = run.get("summary") or calculate(run)
255
+ lines = [
256
+ f"# Maggie QA run `{run.get('runId', '')}`",
257
+ "",
258
+ "<!-- Generated by maggie_qa_workflow.py. Evidence is metadata-only. -->",
259
+ "",
260
+ f"- Environment: `{run.get('environment') or 'not provided'}`",
261
+ f"- Base URL: `{run.get('baseUrl') or 'not provided'}`",
262
+ f"- Browser: `{run.get('browser') or 'not provided'}`",
263
+ f"- Commit/release: `{run.get('commit') or 'not provided'}` / `{run.get('release') or 'not provided'}`",
264
+ f"- Gate: **{summary.get('gate', 'blocked').upper()}**",
265
+ "",
266
+ "## Scenario summary",
267
+ "",
268
+ "| ID | Priority | Scenario | Status | Lifecycle | Last result |",
269
+ "|---|---|---|---|---|---|",
270
+ ]
271
+ for scenario in run.get("scenarios", {}).values():
272
+ history = scenario.get("history", [])
273
+ last = history[-1] if history else {}
274
+ result = safe_text(last.get("summary") or last.get("actual") or "Not tested", 180).replace("|", "\\|")
275
+ lines.append(f"| {scenario.get('id')} | {scenario.get('priority')} | {scenario.get('title')} | **{scenario.get('status')}** | {scenario.get('lifecycle')} | {result} |")
276
+ lines += ["", "## Fix and retest history", ""]
277
+ for scenario in run.get("scenarios", {}).values():
278
+ for event in scenario.get("history", []):
279
+ lines.append(f"### {scenario.get('id')} — {event.get('phase')} ({event.get('at')})")
280
+ lines.append("")
281
+ if event.get("summary"): lines.append(f"- Summary: {event['summary']}")
282
+ if event.get("expected"): lines.append(f"- Expected: {event['expected']}")
283
+ if event.get("actual"): lines.append(f"- Actual: {event['actual']}")
284
+ if event.get("errorFingerprint"): lines.append(f"- Error fingerprint: `{event['errorFingerprint']}`")
285
+ if event.get("resolution"): lines.append(f"- Resolution: {event['resolution']}")
286
+ if event.get("validation"): lines.append(f"- Validation: {event['validation']}")
287
+ references = event.get("evidence", [])
288
+ if references: lines.append(f"- Evidence: {', '.join(str(value.get('path') or value.get('reference')) for value in references)}")
289
+ lines.append("")
290
+ return "\n".join(lines).rstrip() + "\n"
291
+
292
+
293
+ def summary(args: argparse.Namespace) -> int:
294
+ project = project_root(args.project)
295
+ run = load_json(run_path(project, args.run))
296
+ result = run.get("summary") or calculate(run)
297
+ if args.format == "markdown":
298
+ print(markdown(run))
299
+ else:
300
+ print(json.dumps({"runId": run.get("runId"), **result}, ensure_ascii=False, indent=2))
301
+ return 0 if result.get("gate") == "pass" else 1
302
+
303
+
304
+ def export_run(args: argparse.Namespace) -> int:
305
+ project = project_root(args.project)
306
+ run = load_json(run_path(project, args.run))
307
+ output = Path(args.output).expanduser()
308
+ if not output.is_absolute(): output = project / output
309
+ output.parent.mkdir(parents=True, exist_ok=True)
310
+ output.write_text(markdown(run), encoding="utf-8")
311
+ print(json.dumps({"runId": run.get("runId"), "output": str(output.resolve()), "gate": run.get("summary", {}).get("gate")}, ensure_ascii=False, indent=2))
312
+ return 0
313
+
314
+
315
+ def parser() -> argparse.ArgumentParser:
316
+ root = argparse.ArgumentParser(description=__doc__)
317
+ root.add_argument("--project", default=".")
318
+ commands = root.add_subparsers(dest="command", required=True)
319
+
320
+ start_parser = commands.add_parser("start")
321
+ start_parser.add_argument("--project", default=argparse.SUPPRESS)
322
+ start_parser.add_argument("--scenario-file", default=str(DEFAULT_SCENARIOS))
323
+ start_parser.add_argument("--run-id")
324
+ start_parser.add_argument("--environment", required=True)
325
+ start_parser.add_argument("--base-url", required=True)
326
+ start_parser.add_argument("--browser", default="chrome")
327
+ start_parser.add_argument("--commit", default="")
328
+ start_parser.add_argument("--release", default="")
329
+
330
+ record_parser = commands.add_parser("record")
331
+ record_parser.add_argument("--project", default=argparse.SUPPRESS)
332
+ record_parser.add_argument("--run", required=True)
333
+ record_parser.add_argument("--scenario", required=True)
334
+ record_parser.add_argument("--phase", choices=sorted(VALID_PHASES), required=True)
335
+ record_parser.add_argument("--status", choices=sorted(VALID_STATUSES - {"pending"}))
336
+ record_parser.add_argument("--summary", default="")
337
+ record_parser.add_argument("--expected", default="")
338
+ record_parser.add_argument("--actual", default="")
339
+ record_parser.add_argument("--error-fingerprint", default="")
340
+ record_parser.add_argument("--issue", default="")
341
+ record_parser.add_argument("--resolution", default="")
342
+ record_parser.add_argument("--validation", default="")
343
+ record_parser.add_argument("--evidence", action="append", default=[])
344
+
345
+ for name in ("summary", "export"):
346
+ command = commands.add_parser(name)
347
+ command.add_argument("--project", default=argparse.SUPPRESS)
348
+ command.add_argument("--run", required=True)
349
+ if name == "summary": command.add_argument("--format", choices=("json", "markdown"), default="json")
350
+ else: command.add_argument("--output", required=True)
351
+ return root
352
+
353
+
354
+ def main() -> int:
355
+ args = parser().parse_args()
356
+ try:
357
+ if args.command == "start": return start(args)
358
+ if args.command == "record": return record(args)
359
+ if args.command == "summary": return summary(args)
360
+ return export_run(args)
361
+ except (OSError, ValueError, json.JSONDecodeError) as error:
362
+ print(f"QA workflow error: {safe_text(error)}", file=sys.stderr)
363
+ return 2
364
+
365
+
366
+ if __name__ == "__main__":
367
+ raise SystemExit(main())
@@ -0,0 +1,97 @@
1
+ """Provider-neutral request/response contract checks for HTTP APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+
9
+ SCHEMA = "maggie-api-contract.v1"
10
+ METHODS = {"get", "post", "put", "patch", "delete", "head", "options", "trace"}
11
+ BODY_METHODS = {"post", "put", "patch"}
12
+
13
+
14
+ def _has_schema(value: object) -> bool:
15
+ if not isinstance(value, Mapping):
16
+ return False
17
+ if "$ref" in value:
18
+ return bool(str(value["$ref"]).strip())
19
+ schema = value.get("schema")
20
+ if isinstance(schema, Mapping):
21
+ return _has_schema(schema) or bool(schema)
22
+ content = value.get("content")
23
+ if isinstance(content, Mapping):
24
+ return any(_has_schema(media) for media in content.values())
25
+ return False
26
+
27
+
28
+ def _success_responses(responses: Mapping[str, Any]) -> list[tuple[str, Mapping[str, Any]]]:
29
+ result = []
30
+ for code, response in responses.items():
31
+ label = str(code).upper()
32
+ if label.startswith("2") or label == "2XX":
33
+ result.append((str(code), response if isinstance(response, Mapping) else {}))
34
+ return result
35
+
36
+
37
+ def validate_api_contract(spec: object) -> dict[str, Any]:
38
+ """Validate declared body and success response shapes without guessing fields.
39
+
40
+ Empty request/response bodies are valid only when they are explicit (or a
41
+ 204 response). This keeps a consumer from inventing fields for an
42
+ under-specified endpoint while allowing intentional command-style APIs.
43
+ """
44
+ errors: list[dict[str, str]] = []
45
+ operations: list[dict[str, Any]] = []
46
+ paths = spec.get("paths") if isinstance(spec, Mapping) else None
47
+ if not isinstance(paths, Mapping):
48
+ return {"schemaVersion": SCHEMA, "passed": False, "operations": [],
49
+ "errors": [{"code": "paths-missing", "message": "spec.paths must be an object"}]}
50
+ for path, path_item in sorted(paths.items(), key=lambda item: str(item[0])):
51
+ if not isinstance(path_item, Mapping):
52
+ errors.append({"path": str(path), "code": "path-invalid", "message": "path item must be an object"})
53
+ continue
54
+ for method, operation in sorted(path_item.items(), key=lambda item: str(item[0])):
55
+ verb = str(method).casefold()
56
+ if verb not in METHODS:
57
+ continue
58
+ operation_id = str((operation or {}).get("operationId") or f"{verb} {path}") if isinstance(operation, Mapping) else f"{verb} {path}"
59
+ record: dict[str, Any] = {"path": str(path), "method": verb.upper(), "operationId": operation_id}
60
+ if not isinstance(operation, Mapping):
61
+ errors.append({"path": str(path), "method": verb.upper(), "code": "operation-invalid", "message": "operation must be an object"})
62
+ continue
63
+ if verb in BODY_METHODS:
64
+ body = operation.get("requestBody")
65
+ if body is None and not operation.get("x-maggie-empty-request-body"):
66
+ errors.append({"path": str(path), "method": verb.upper(), "code": "request-body-undeclared", "message": "declare requestBody or x-maggie-empty-request-body"})
67
+ record["requestBody"] = "missing"
68
+ elif operation.get("x-maggie-empty-request-body"):
69
+ record["requestBody"] = "explicit-empty"
70
+ elif not _has_schema(body):
71
+ errors.append({"path": str(path), "method": verb.upper(), "code": "request-body-schema-missing", "message": "requestBody must declare content.schema or $ref"})
72
+ record["requestBody"] = "schema-missing"
73
+ else:
74
+ record["requestBody"] = "declared"
75
+ else:
76
+ record["requestBody"] = "not-applicable"
77
+ responses = operation.get("responses")
78
+ if not isinstance(responses, Mapping):
79
+ errors.append({"path": str(path), "method": verb.upper(), "code": "responses-missing", "message": "declare responses with a 2xx success response"})
80
+ record["successResponse"] = "missing"
81
+ else:
82
+ successes = _success_responses(responses)
83
+ if not successes:
84
+ errors.append({"path": str(path), "method": verb.upper(), "code": "success-response-missing", "message": "declare at least one 2xx response"})
85
+ record["successResponse"] = "missing"
86
+ else:
87
+ code, response = successes[0]
88
+ if code == "204" or response.get("x-maggie-empty-response"):
89
+ record["successResponse"] = f"{code}:explicit-empty"
90
+ elif not _has_schema(response):
91
+ errors.append({"path": str(path), "method": verb.upper(), "code": "success-response-schema-missing", "message": f"response {code} must declare content.schema, $ref, or x-maggie-empty-response"})
92
+ record["successResponse"] = f"{code}:schema-missing"
93
+ else:
94
+ record["successResponse"] = f"{code}:declared"
95
+ operations.append(record)
96
+ return {"schemaVersion": SCHEMA, "passed": not errors, "operations": operations, "errors": errors,
97
+ "operationCount": len(operations), "errorCount": len(errors)}
@@ -18,6 +18,8 @@ DEFAULTS = {
18
18
  "postsPerPage": 9,
19
19
  "topicLimit": 12,
20
20
  "defaultPostStatus": "draft",
21
+ "requireReview": True,
22
+ "autoPublishEnabled": False,
21
23
  "autoPullEnabled": False,
22
24
  "pullIntervalMinutes": 120,
23
25
  "maxPerRun": 1,
@@ -142,6 +144,12 @@ class BlogStore:
142
144
  if not content_id or not title: raise ValueError("each post requires contentId/id and title")
143
145
  old = by_content.get(content_id)
144
146
  raw_slug = str(item.get("slug") or title)
147
+ initial_status = settings.get("defaultPostStatus", "draft")
148
+ # A provider must not turn an import into an implicit publication.
149
+ # Older projects may still carry defaultPostStatus=published; the
150
+ # review gate makes that configuration visible and safe here.
151
+ if settings.get("requireReview", True) and initial_status != "draft":
152
+ initial_status = "draft"
145
153
  post = {
146
154
  "schemaVersion": "maggie-blog-post.v1",
147
155
  "id": old["id"] if old else f"post:{content_id}",
@@ -156,7 +164,7 @@ class BlogStore:
156
164
  "topics": [{"slug": slugify(str(topic)), "label": str(topic)} for topic in item.get("topics", item.get("keywords", []))],
157
165
  # Provider input can suggest a status, but cannot bypass the
158
166
  # local approval gate. Existing published state is preserved.
159
- "status": old.get("status", settings["defaultPostStatus"]) if old else settings["defaultPostStatus"],
167
+ "status": old.get("status", initial_status) if old else initial_status,
160
168
  "canonicalUrl": item.get("canonicalUrl"),
161
169
  "source": {"provider": provider, "revision": str(item.get("revision") or checksum(item))},
162
170
  "publishedAt": old.get("publishedAt") if old else item.get("publishedAt"),
@@ -183,6 +191,8 @@ class BlogStore:
183
191
 
184
192
  def validate(self) -> dict:
185
193
  settings = self.settings(); posts = self.posts(); errors = []
194
+ gate = self.review_gate(settings)
195
+ errors.extend(f"review gate: {error}" for error in gate["errors"])
186
196
  ids = [p.get("contentId") for p in posts]; slugs = [p.get("slug") for p in posts]
187
197
  if len(ids) != len(set(ids)): errors.append("duplicate contentId")
188
198
  if len(slugs) != len(set(slugs)): errors.append("duplicate slug")
@@ -192,11 +202,42 @@ class BlogStore:
192
202
  if post.get("status") == "published" and not post.get("publishedAt"): errors.append(f"{post.get('id')}: publishedAt required")
193
203
  return {"valid": not errors, "posts": len(posts), "published": sum(p.get("status") == "published" for p in posts), "settings": settings, "errors": errors}
194
204
 
205
+ @staticmethod
206
+ def review_gate(settings: dict) -> dict:
207
+ """Report whether generated content is forced through human review."""
208
+ errors: list[str] = []
209
+ require_review = bool(settings.get("requireReview", True))
210
+ auto_publish = bool(settings.get("autoPublishEnabled", False))
211
+ default_status = str(settings.get("defaultPostStatus", "draft"))
212
+ if auto_publish:
213
+ errors.append("autoPublishEnabled must be false for provider-neutral draft-first publishing")
214
+ if not require_review:
215
+ errors.append("requireReview must be true")
216
+ if default_status == "published":
217
+ errors.append("defaultPostStatus cannot be published")
218
+ return {"schemaVersion": "maggie-blog-review-gate.v1", "passed": not errors,
219
+ "requireReview": require_review, "autoPublishEnabled": auto_publish,
220
+ "defaultPostStatus": default_status, "errors": errors,
221
+ "mutation": False}
222
+
223
+ def approve(self, slug: str, actor: str, reason: str) -> dict:
224
+ if not actor or not reason:
225
+ raise ValueError("actor and reason are required")
226
+ posts = self.posts(); found = next((p for p in posts if p.get("slug") == slug), None)
227
+ if not found: raise ValueError(f"post not found: {slug}")
228
+ if found["status"] not in {"draft", "review"}:
229
+ raise ValueError(f"post cannot approve from {found['status']}")
230
+ found["status"] = "approved"; found["updatedAt"] = now()
231
+ found["lastTransition"] = {"actor": actor, "reason": reason, "at": now()}
232
+ self._backup(); self._write(self.posts_path, posts); return found
233
+
195
234
  def publish(self, slug: str, actor: str, reason: str) -> dict:
196
235
  if not actor or not reason: raise ValueError("actor and reason are required")
197
236
  posts = self.posts(); found = next((p for p in posts if p.get("slug") == slug), None)
198
237
  if not found: raise ValueError(f"post not found: {slug}")
199
238
  if found["status"] not in {"draft", "review", "approved"}: raise ValueError(f"post cannot publish from {found['status']}")
239
+ if self.settings().get("requireReview", True) and found["status"] != "approved":
240
+ raise ValueError("post requires explicit approval before publish")
200
241
  found["status"] = "published"; found["publishedAt"] = found.get("publishedAt") or now(); found["updatedAt"] = now(); found["lastTransition"] = {"actor": actor, "reason": reason, "at": now()}
201
242
  self._backup(); self._write(self.posts_path, posts); return found
202
243