open-code-review-toolkit 0.4.5__tar.gz → 0.4.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/PKG-INFO +12 -5
  2. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/README.md +11 -4
  3. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/_version.py +2 -2
  4. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/mcp.py +1 -1
  5. open_code_review_toolkit-0.4.7/src/ocr_toolkit/posting/approval.py +125 -0
  6. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/formatting.py +46 -43
  7. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/gitlab.py +25 -1
  8. open_code_review_toolkit-0.4.7/src/ocr_toolkit/posting/gitlab_approval.py +230 -0
  9. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/markers.py +8 -0
  10. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/settings.py +45 -10
  11. open_code_review_toolkit-0.4.7/src/ocr_toolkit/posting/suggestions.py +212 -0
  12. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/workflow.py +222 -94
  13. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/preflight.py +1 -1
  14. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/.gitignore +0 -0
  15. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/LICENSE +0 -0
  16. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/pyproject.toml +0 -0
  17. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/__init__.py +0 -0
  18. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/cli.py +0 -0
  19. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/common/__init__.py +0 -0
  20. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/common/git.py +0 -0
  21. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/common/language.py +0 -0
  22. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/common/markdown.py +0 -0
  23. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/common/redaction.py +0 -0
  24. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/config_writer.py +0 -0
  25. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/configure.py +0 -0
  26. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/__init__.py +0 -0
  27. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/__main__.py +0 -0
  28. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/ansible.py +0 -0
  29. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/ansible_requirements.py +0 -0
  30. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/artifacts.py +0 -0
  31. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/categorize.py +0 -0
  32. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/collect.py +0 -0
  33. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/collectors.py +0 -0
  34. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/composer_manifests.py +0 -0
  35. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/coverage.py +0 -0
  36. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/go_manifests.py +0 -0
  37. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/infrastructure.py +0 -0
  38. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/invocation.py +0 -0
  39. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/javascript_manifests.py +0 -0
  40. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/manifest_model.py +0 -0
  41. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/model.py +0 -0
  42. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/project.py +0 -0
  43. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/python_manifests.py +0 -0
  44. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/repository.py +0 -0
  45. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/evidence/store.py +0 -0
  46. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/mcp_config.py +0 -0
  47. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/ocr_result.py +0 -0
  48. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/__init__.py +0 -0
  49. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/__main__.py +0 -0
  50. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/comments.py +0 -0
  51. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/payloads.py +0 -0
  52. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/result.py +0 -0
  53. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/posting/snapshot.py +0 -0
  54. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/providers/__init__.py +0 -0
  55. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/providers/gitlab.py +0 -0
  56. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/py.typed +0 -0
  57. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/result_contract.py +0 -0
  58. {open_code_review_toolkit-0.4.5 → open_code_review_toolkit-0.4.7}/src/ocr_toolkit/review_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-code-review-toolkit
3
- Version: 0.4.5
3
+ Version: 0.4.7
4
4
  Summary: Unofficial GitLab CI integration layer for Open Code Review
5
5
  Project-URL: Homepage, https://github.com/xeonvs/open-code-review-toolkit
6
6
  Project-URL: Repository, https://github.com/xeonvs/open-code-review-toolkit
@@ -243,7 +243,7 @@ ocr --version
243
243
  ocr-ci --help
244
244
  ```
245
245
 
246
- The current compatibility target is OCR `1.8.8`. CI should pin the release and verify its published checksum before execution.
246
+ The current compatibility target is OCR `1.9.1`. CI should pin the release and verify its published checksum before execution.
247
247
  The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
248
248
  Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`.
249
249
 
@@ -255,11 +255,18 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of
255
255
 
256
256
  Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram.
257
257
 
258
+ After every current review note publishes, the GitLab adapter can add a
259
+ conservative approval bound to the exact reviewed source SHA. This write is
260
+ enabled by default; set `OCR_AUTO_APPROVE=false` before upgrading when the bot
261
+ must remain comment-only. GitLab approval rules and protected-branch policy
262
+ remain authoritative. The toolkit only adds an eligible approval; it never
263
+ removes an existing approval when a later review is ineligible or disabled.
264
+
258
265
  Project-wide accepted tradeoffs can be recorded separately in `.opencodereview/accepted-decisions.md`; the evidence collector supplies target-ref decisions to OCR and never lets a source change self-authorize its own review. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) for the entry format, inline marker convention, security boundary, and limitations.
259
266
 
260
- ## Project development
267
+ ## Project architecture
261
268
 
262
- The project is evolving from bounded background generation toward a shared Repository Evidence Engine: one deterministic evidence model will support both a compact OCR bootstrap and a built-in read-only MCP server. Development is ordered by outcomes and dependencies rather than speculative dates.
269
+ The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed evidence through the mandatory built-in read-only MCP server. Reviewed external stdio or native HTTPS MCP servers compose alongside it without replacing the built-in evidence boundary.
263
270
 
264
271
  - [Toolkit strategy](docs/engineering/toolkit_strategy.md) - durable product boundaries, architecture, invariants, and non-goals.
265
272
  - [Roadmap](ROADMAP.md) - milestone status, dependencies, outcomes, and completion signals.
@@ -283,7 +290,7 @@ See the fully synthetic [`examples/gitlab/ocr-review.gitlab-ci.yml`](examples/gi
283
290
 
284
291
  ## Configuration and safety
285
292
 
286
- Configuration is environment-only in v0.1. The [configuration reference](docs/configuration.md) documents supported `OCR_*`, `CI_*`, `GITLAB_*`, and MCP inputs. Posting requires `GITLAB_API_TOKEN`; job tokens and legacy aliases are deliberately unsupported.
293
+ Configuration is environment-driven. The [configuration reference](docs/configuration.md) documents supported `OCR_*`, `CI_*`, `GITLAB_*`, and MCP inputs. Posting requires `GITLAB_API_TOKEN`; job tokens and legacy aliases are deliberately unsupported.
287
294
 
288
295
  Repository content, OCR output, and provider responses are untrusted inputs. The toolkit applies bounded reads and writes, secret redaction, Unicode normalization, Markdown/quick-action neutralization, fingerprinted comments, ownership boundaries for human replies, and rollback controls. Review the [security and trust model](docs/security.md) before enabling write access.
289
296
 
@@ -17,7 +17,7 @@ ocr --version
17
17
  ocr-ci --help
18
18
  ```
19
19
 
20
- The current compatibility target is OCR `1.8.8`. CI should pin the release and verify its published checksum before execution.
20
+ The current compatibility target is OCR `1.9.1`. CI should pin the release and verify its published checksum before execution.
21
21
  The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
22
22
  Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`.
23
23
 
@@ -29,11 +29,18 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of
29
29
 
30
30
  Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram.
31
31
 
32
+ After every current review note publishes, the GitLab adapter can add a
33
+ conservative approval bound to the exact reviewed source SHA. This write is
34
+ enabled by default; set `OCR_AUTO_APPROVE=false` before upgrading when the bot
35
+ must remain comment-only. GitLab approval rules and protected-branch policy
36
+ remain authoritative. The toolkit only adds an eligible approval; it never
37
+ removes an existing approval when a later review is ineligible or disabled.
38
+
32
39
  Project-wide accepted tradeoffs can be recorded separately in `.opencodereview/accepted-decisions.md`; the evidence collector supplies target-ref decisions to OCR and never lets a source change self-authorize its own review. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) for the entry format, inline marker convention, security boundary, and limitations.
33
40
 
34
- ## Project development
41
+ ## Project architecture
35
42
 
36
- The project is evolving from bounded background generation toward a shared Repository Evidence Engine: one deterministic evidence model will support both a compact OCR bootstrap and a built-in read-only MCP server. Development is ordered by outcomes and dependencies rather than speculative dates.
43
+ The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed evidence through the mandatory built-in read-only MCP server. Reviewed external stdio or native HTTPS MCP servers compose alongside it without replacing the built-in evidence boundary.
37
44
 
38
45
  - [Toolkit strategy](docs/engineering/toolkit_strategy.md) - durable product boundaries, architecture, invariants, and non-goals.
39
46
  - [Roadmap](ROADMAP.md) - milestone status, dependencies, outcomes, and completion signals.
@@ -57,7 +64,7 @@ See the fully synthetic [`examples/gitlab/ocr-review.gitlab-ci.yml`](examples/gi
57
64
 
58
65
  ## Configuration and safety
59
66
 
60
- Configuration is environment-only in v0.1. The [configuration reference](docs/configuration.md) documents supported `OCR_*`, `CI_*`, `GITLAB_*`, and MCP inputs. Posting requires `GITLAB_API_TOKEN`; job tokens and legacy aliases are deliberately unsupported.
67
+ Configuration is environment-driven. The [configuration reference](docs/configuration.md) documents supported `OCR_*`, `CI_*`, `GITLAB_*`, and MCP inputs. Posting requires `GITLAB_API_TOKEN`; job tokens and legacy aliases are deliberately unsupported.
61
68
 
62
69
  Repository content, OCR output, and provider responses are untrusted inputs. The toolkit applies bounded reads and writes, secret redaction, Unicode normalization, Markdown/quick-action neutralization, fingerprinted comments, ownership boundaries for human replies, and rollback controls. Review the [security and trust model](docs/security.md) before enabling write access.
63
70
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.5'
22
- __version_tuple__ = version_tuple = (0, 4, 5)
21
+ __version__ = version = '0.4.7'
22
+ __version_tuple__ = version_tuple = (0, 4, 7)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -106,7 +106,7 @@ def _optional_filter(arguments: dict[str, object], name: str) -> str | None:
106
106
 
107
107
 
108
108
  def _encode_cursor(offset: int, query: _Query) -> str:
109
- """Encode an opaque, tamper-evident pagination position."""
109
+ """Encode an opaque, query-bound self-validating pagination position."""
110
110
 
111
111
  state = f"{offset}:{query.key()}"
112
112
  checksum = hashlib.sha256(f"ocr-evidence-v1:{state}".encode()).hexdigest()[:16]
@@ -0,0 +1,125 @@
1
+ """Conservative policy and typed outcomes for GitLab automatic approval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from ocr_toolkit.posting.settings import BooleanSetting
10
+ from ocr_toolkit.result_contract import ReviewOutcome
11
+
12
+ ALLOWED_CATEGORIES = frozenset({"style", "documentation", "maintainability"})
13
+ MAX_APPROVABLE_FINDINGS = 3
14
+
15
+
16
+ class ApprovalStatus(str, Enum):
17
+ """Closed public states for one automatic-approval transaction."""
18
+
19
+ APPROVED = "approved"
20
+ NOT_ELIGIBLE = "not eligible"
21
+ DISABLED = "disabled"
22
+ SKIPPED = "skipped"
23
+ FAILED = "failed"
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ApprovalResult:
28
+ """Bounded status rendered in the review summary and runner log."""
29
+
30
+ status: ApprovalStatus
31
+ reason: str
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ApprovalEligibility:
36
+ """Policy conclusion before provider state is consulted."""
37
+
38
+ eligible: bool
39
+ result: ApprovalResult
40
+
41
+
42
+ def evaluate_approval_policy(
43
+ setting: BooleanSetting,
44
+ outcome: ReviewOutcome,
45
+ comments: list[dict[str, Any]],
46
+ warnings: list[Any],
47
+ omitted_count: int,
48
+ ) -> ApprovalEligibility:
49
+ """Evaluate the fixed v0.4.7 policy from authoritative OCR data."""
50
+
51
+ if not setting.enabled:
52
+ reason = (
53
+ "configuration was invalid and failed closed"
54
+ if not setting.valid
55
+ else "disabled by OCR_AUTO_APPROVE"
56
+ )
57
+ return ApprovalEligibility(
58
+ False,
59
+ ApprovalResult(ApprovalStatus.DISABLED, reason),
60
+ )
61
+ if not outcome.manifest_present:
62
+ reason = "the OCR result has no authoritative coverage manifest"
63
+ elif outcome.kind != "clean" or outcome.budget_exceeded:
64
+ reason = "the OCR review did not complete cleanly"
65
+ elif outcome.failed_count or outcome.waived_count:
66
+ reason = "coverage contained failed or waived items"
67
+ elif warnings:
68
+ reason = "the OCR review reported warnings"
69
+ elif omitted_count:
70
+ reason = "one or more findings were omitted from publication"
71
+ elif len(comments) > MAX_APPROVABLE_FINDINGS:
72
+ reason = f"the review reported more than {MAX_APPROVABLE_FINDINGS} findings"
73
+ else:
74
+ reason = ""
75
+
76
+ if reason:
77
+ return ApprovalEligibility(
78
+ False,
79
+ ApprovalResult(ApprovalStatus.NOT_ELIGIBLE, reason),
80
+ )
81
+
82
+ for comment in comments:
83
+ severity = comment.get("severity")
84
+ category = comment.get("category")
85
+ if severity != "low":
86
+ return ApprovalEligibility(
87
+ False,
88
+ ApprovalResult(
89
+ ApprovalStatus.NOT_ELIGIBLE,
90
+ "a finding had a blocking or malformed severity",
91
+ ),
92
+ )
93
+ if not isinstance(category, str) or category not in ALLOWED_CATEGORIES:
94
+ return ApprovalEligibility(
95
+ False,
96
+ ApprovalResult(
97
+ ApprovalStatus.NOT_ELIGIBLE,
98
+ "a finding had a blocking or malformed category",
99
+ ),
100
+ )
101
+
102
+ return ApprovalEligibility(
103
+ True,
104
+ ApprovalResult(
105
+ ApprovalStatus.SKIPPED,
106
+ "awaiting post-publication SHA verification",
107
+ ),
108
+ )
109
+
110
+
111
+ def approval_summary_line(result: ApprovalResult) -> str:
112
+ """Render exactly one bounded automatic-approval state line."""
113
+
114
+ return f"- Automatic approval: `{result.status.value}` — {result.reason}."
115
+
116
+
117
+ def provisional_approval_result(eligibility: ApprovalEligibility) -> ApprovalResult:
118
+ """Return a fail-closed state safe to publish before provider readback."""
119
+
120
+ if not eligibility.eligible:
121
+ return eligibility.result
122
+ return ApprovalResult(
123
+ ApprovalStatus.FAILED,
124
+ "automatic approval has not yet been confirmed",
125
+ )
@@ -17,6 +17,7 @@ from ocr_toolkit.common.markdown import (
17
17
  )
18
18
  from ocr_toolkit.common.redaction import redact_sensitive
19
19
  from ocr_toolkit.ocr_result import TOOLKIT_RESULT_SCHEMA_VERSION
20
+ from ocr_toolkit.posting.approval import ApprovalResult, approval_summary_line
20
21
  from ocr_toolkit.posting.comments import (
21
22
  clean_text,
22
23
  code_text,
@@ -34,14 +35,13 @@ from ocr_toolkit.posting.settings import (
34
35
  MAX_REVIEWER_GUIDE_LABEL_CHARS,
35
36
  MAX_REVIEWER_GUIDE_LOCATION_CHARS,
36
37
  MAX_REVIEWER_GUIDE_TEXT_CHARS,
37
- MAX_SUGGESTION_CODE_CHARS,
38
- MAX_SUGGESTION_SPAN_LINES,
39
38
  MAX_TOOL_CALL_NAME_CHARS,
40
39
  MAX_TOOL_CALL_SUMMARY_TOOLS,
41
40
  SUGGESTION_HEADER,
42
41
  post_emoji,
43
42
  post_mode,
44
43
  )
44
+ from ocr_toolkit.posting.suggestions import SuggestionDecision, SuggestionState
45
45
 
46
46
  OCR_FINDING_CATEGORIES = {
47
47
  "bug",
@@ -85,22 +85,6 @@ CATEGORY_EMOJI = {
85
85
  }
86
86
 
87
87
 
88
- def suggestion_range_suffix(comment: dict[str, Any]) -> str:
89
- """Return a GitLab suggestion range suffix."""
90
-
91
- end_line = line_number(comment.get("end_line") or comment.get("line"))
92
- start_line = line_number(comment.get("start_line") or comment.get("line") or end_line)
93
-
94
- if start_line <= 0 or end_line <= 0 or start_line > end_line:
95
- return ""
96
-
97
- span = end_line - start_line
98
- if span > MAX_SUGGESTION_SPAN_LINES:
99
- return ""
100
-
101
- return f"-0+{span}"
102
-
103
-
104
88
  def inline_code(value: str) -> str:
105
89
  """Return a Markdown inline-code representation safe for backticks."""
106
90
 
@@ -136,34 +120,33 @@ def format_finding_tags(comment: dict[str, Any], *, emoji: bool | None = None) -
136
120
  return " · ".join(tags)
137
121
 
138
122
 
139
- def format_suggestion_block(comment: dict[str, Any]) -> str:
140
- """Return a GitLab suggestion block if OCR supplied replacement code."""
123
+ def format_suggestion_block(decision: SuggestionDecision) -> str:
124
+ """Render one previously validated GitLab suggestion decision."""
141
125
 
142
- suggestion = code_text(comment.get("suggestion_code"))
143
- if not suggestion.strip() or comment.get("_ocr_suggestion_noop") is True:
144
- return ""
145
-
146
- if "```" in suggestion:
126
+ if decision.state in {SuggestionState.ABSENT, SuggestionState.NO_OP}:
147
127
  return ""
128
+ if decision.state is SuggestionState.OMITTED:
129
+ return f"\n\nSuggestion block was omitted because {decision.omission_message}."
130
+ return (
131
+ f"\n\n{SUGGESTION_HEADER}\n```suggestion:{decision.range_suffix}\n"
132
+ f"{decision.replacement}\n```"
133
+ )
148
134
 
149
- if any(line.lstrip().startswith("/") for line in suggestion.splitlines()):
150
- return ""
151
135
 
152
- if len(suggestion) > MAX_SUGGESTION_CODE_CHARS:
153
- return (
154
- "\n\nSuggestion block was omitted because the generated replacement "
155
- "was too large to publish safely."
156
- )
136
+ def format_suggestion_omission(decision: SuggestionDecision) -> str:
137
+ """Render a bounded explanation for a withheld actionable suggestion."""
157
138
 
158
- range_suffix = suggestion_range_suffix(comment)
159
- if not range_suffix:
139
+ if decision.state is not SuggestionState.OMITTED:
160
140
  return ""
161
-
162
- return f"\n\n{SUGGESTION_HEADER}\n```suggestion:{range_suffix}\n{suggestion}\n```"
141
+ return f"\n\nSuggestion block was omitted because {decision.omission_message}."
163
142
 
164
143
 
165
144
  def format_inline_comment(
166
- comment: dict[str, Any], include_suggestion: bool = True, *, emoji: bool | None = None
145
+ comment: dict[str, Any],
146
+ include_suggestion: bool = True,
147
+ *,
148
+ suggestion_decision: SuggestionDecision | None = None,
149
+ emoji: bool | None = None,
167
150
  ) -> str:
168
151
  """Format one OCR comment as Markdown for an inline GitLab discussion."""
169
152
 
@@ -173,12 +156,19 @@ def format_inline_comment(
173
156
  tags = format_finding_tags(comment, emoji=emoji)
174
157
  body = f"{tags}\n\n{content}" if tags else content
175
158
  if include_suggestion:
176
- body += format_suggestion_block(comment)
159
+ body += format_suggestion_block(
160
+ suggestion_decision or SuggestionDecision(SuggestionState.ABSENT)
161
+ )
177
162
 
178
163
  return body
179
164
 
180
165
 
181
- def format_fallback_comment(comment: dict[str, Any], *, emoji: bool | None = None) -> str:
166
+ def format_fallback_comment(
167
+ comment: dict[str, Any],
168
+ *,
169
+ suggestion_decision: SuggestionDecision | None = None,
170
+ emoji: bool | None = None,
171
+ ) -> str:
182
172
  """Format an OCR comment for a fallback non-inline MR note."""
183
173
 
184
174
  path = clean_text(comment.get("path")) or "unknown"
@@ -202,10 +192,13 @@ def format_fallback_comment(comment: dict[str, Any], *, emoji: bool | None = Non
202
192
  f"{format_inline_comment(comment, include_suggestion=False, emoji=emoji)}"
203
193
  )
204
194
 
195
+ decision = suggestion_decision or SuggestionDecision(SuggestionState.ABSENT)
196
+ body += format_suggestion_omission(decision)
197
+
205
198
  existing = code_text(comment.get("existing_code"))
206
199
  suggestion = code_text(comment.get("suggestion_code"))
207
200
 
208
- if existing.strip() and suggestion.strip() and comment.get("_ocr_suggestion_noop") is not True:
201
+ if existing.strip() and suggestion.strip() and decision.state is not SuggestionState.NO_OP:
209
202
  body += "\n\n<details><summary>Suggested change details</summary>\n\n"
210
203
  body += "**Before:**\n"
211
204
  body += markdown_code_block(
@@ -228,16 +221,23 @@ def format_fallback_comment(comment: dict[str, Any], *, emoji: bool | None = Non
228
221
 
229
222
 
230
223
  def format_fallback_comment_chunks(
231
- comments: Sequence[dict[str, Any]], *, emoji: bool | None = None
224
+ comments: Sequence[tuple[dict[str, Any], SuggestionDecision]],
225
+ *,
226
+ emoji: bool | None = None,
232
227
  ) -> list[str]:
233
228
  """Split fallback comments into safe chunks before publishing MR notes."""
234
229
 
235
230
  chunks: list[str] = []
236
231
  current = ""
237
232
 
238
- for comment in comments:
233
+ for comment, suggestion_decision in comments:
239
234
  item = truncate_note_body(
240
- format_fallback_comment(comment, emoji=emoji), max_chars=FALLBACK_NOTE_CHUNK_BUDGET
235
+ format_fallback_comment(
236
+ comment,
237
+ suggestion_decision=suggestion_decision,
238
+ emoji=emoji,
239
+ ),
240
+ max_chars=FALLBACK_NOTE_CHUNK_BUDGET,
241
241
  )
242
242
  separator = "\n\n---\n\n" if current else ""
243
243
 
@@ -776,6 +776,7 @@ def summarize_result(
776
776
  coverage_diagnostics: CoverageDiagnostics | None = None,
777
777
  warnings: Sequence[Any] = (),
778
778
  suppressed_count: int = 0,
779
+ approval_result: ApprovalResult | None = None,
779
780
  emoji: bool | None = None,
780
781
  ) -> str:
781
782
  """Build one decision-first summary for every validated OCR outcome."""
@@ -892,6 +893,8 @@ def summarize_result(
892
893
  technical.append(
893
894
  f"- Posting: {inline_count} inline, {fallback_count} fallback, {omitted_count} omitted"
894
895
  )
896
+ if approval_result is not None:
897
+ technical.append(approval_summary_line(approval_result))
895
898
  if suppressed_count:
896
899
  technical.append(f"- Reviewer suppression: {suppressed_count}")
897
900
  if coverage_summary:
@@ -58,6 +58,7 @@ class GitLabWriteResult:
58
58
 
59
59
  status: str
60
60
  response: Any | None = None
61
+ http_status: int | None = None
61
62
 
62
63
  @property
63
64
  def posted(self) -> bool:
@@ -282,7 +283,7 @@ def api_write_url_detailed(
282
283
  print(f"GitLab API error {exc.code} for {method} {url}: {safe_body}", file=sys.stderr)
283
284
  if _is_invalid_position_error(exc.code, raw_body):
284
285
  return GitLabWriteResult("invalid_position")
285
- return GitLabWriteResult("write_failed")
286
+ return GitLabWriteResult("write_failed", http_status=exc.code)
286
287
  except GitLabResponseTooLarge as exc:
287
288
  print(f"GitLab API response too large for {method} {url}: {exc}", file=sys.stderr)
288
289
  return GitLabWriteResult("write_failed")
@@ -515,6 +516,29 @@ def delete_plain_note(config: GitLabConfig, note_id: int) -> bool:
515
516
  return response is not None
516
517
 
517
518
 
519
+ def update_plain_note(config: GitLabConfig, note_id: int, body: str) -> GitLabWriteResult:
520
+ """Update one known toolkit-owned summary without retrying the write."""
521
+
522
+ return api_write_url_detailed(
523
+ url=f"{config.api_base}/notes/{note_id}",
524
+ api_token=config.api_token,
525
+ auth_header=config.auth_header,
526
+ data={"body": build_marked_note_body(body)},
527
+ method="PUT",
528
+ )
529
+
530
+
531
+ def approve_merge_request(config: GitLabConfig, sha: str) -> GitLabWriteResult:
532
+ """Approve exactly one merge-request head without retrying the write."""
533
+
534
+ return api_write_url_detailed(
535
+ url=f"{config.api_base}/approve",
536
+ api_token=config.api_token,
537
+ auth_header=config.auth_header,
538
+ data={"sha": sha},
539
+ )
540
+
541
+
518
542
  def delete_discussion_note(config: GitLabConfig, discussion_id: str, note_id: int) -> bool:
519
543
  """Delete a note inside a merge request discussion thread."""
520
544