tfscout 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.
tfscout/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """tfscout - AI-assisted Terraform plan review and drift detection."""
2
+
3
+ __version__ = "0.1.0"
tfscout/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from tfscout.cli import main
2
+
3
+ raise SystemExit(main())
tfscout/analyze.py ADDED
@@ -0,0 +1,239 @@
1
+ """Orchestration: plan -> redaction -> rules -> (optional) LLM -> report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import sys
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from tfscout.diff import diff
12
+ from tfscout.llm import LLMClient, LLMError
13
+ from tfscout.plan import Change, Plan
14
+ from tfscout.redact import redact_change
15
+ from tfscout.rules import (
16
+ DRIFT_CLASSES,
17
+ SEVERITIES,
18
+ DriftItem,
19
+ Finding,
20
+ drift_rules,
21
+ review_rules,
22
+ sev_rank,
23
+ )
24
+
25
+ MAX_RESOURCES = 150
26
+ MAX_DIFFS_PER_RESOURCE = 40
27
+ CATEGORIES = {"security", "data-loss", "availability", "cost", "compliance", "other"}
28
+
29
+ REVIEW_SYSTEM = """\
30
+ You are tfscout, a senior platform engineer reviewing a Terraform plan attached to a pull request.
31
+ Input: resource changes (secrets are already redacted as [REDACTED]) and findings produced by deterministic rules.
32
+
33
+ Tasks:
34
+ 1. "summary": 2-5 short markdown bullet lines describing WHAT this change does and its blast radius, in plain English.
35
+ 2. "findings": ONLY risks the rules did not already report: downtime, data loss, security, cost,
36
+ compliance, surprising side-effects (e.g. a renamed resource causing destroy/create, forced replacement
37
+ attributes, changes to shared infrastructure). Never repeat a rule finding. Never invent problems:
38
+ if nothing else is notable return an empty list. Be concrete and reference attribute paths.
39
+
40
+ Respond with a single JSON object and nothing else:
41
+ {"summary": "- ...\\n- ...", "findings": [{"severity": "info|low|medium|high|critical",
42
+ "category": "security|data-loss|availability|cost|compliance|other", "resource": "<resource address>",
43
+ "title": "<short>", "detail": "<why>", "recommendation": "<what to do>"}]}
44
+ """
45
+
46
+ DRIFT_SYSTEM = """\
47
+ You are tfscout, a senior platform engineer analysing Terraform DRIFT: differences between the Terraform state
48
+ and real infrastructure that were made outside Terraform. Secrets are redacted as [REDACTED].
49
+ Each item has a preliminary classification from rules: noise | manual-change | risky.
50
+ If "classification_is_final" is true, the rules proved it from the Terraform state: keep that classification
51
+ and only explain it.
52
+
53
+ For every item return:
54
+ - "classification": noise (volatile/auto-managed values), manual-change (someone changed it by hand,
55
+ probably a hotfix that should be codified), or risky (weakens security, availability or compliance,
56
+ or looks unauthorised).
57
+ - "explanation": 1-2 sentences on what changed and the likely cause.
58
+ - "recommendation": either codify the change in Terraform (show the HCL attribute to set) or revert it by
59
+ running apply, or ignore it via lifecycle.ignore_changes for true noise.
60
+ Also give an overall "summary" (2-4 markdown bullet lines).
61
+
62
+ Respond with a single JSON object and nothing else:
63
+ {"summary": "- ...", "items": [{"resource": "<address>", "classification": "noise|manual-change|risky",
64
+ "explanation": "...", "recommendation": "..."}]}
65
+ """
66
+
67
+
68
+ def _warn(msg: str) -> None:
69
+ print(f"tfscout: warning: {msg}", file=sys.stderr)
70
+
71
+
72
+ def _diff_payload(c: Change) -> list[dict[str, Any]]:
73
+ ds = diff(c.before, c.after, c.after_unknown)
74
+ out = [{"path": d.path, "before": d.before, "after": d.after} for d in ds[:MAX_DIFFS_PER_RESOURCE]]
75
+ if len(ds) > MAX_DIFFS_PER_RESOURCE:
76
+ out.append({"path": f"... {len(ds) - MAX_DIFFS_PER_RESOURCE} more attributes omitted"})
77
+ return out
78
+
79
+
80
+ def _finding_dict(f: Finding) -> dict[str, str]:
81
+ return {"severity": f.severity, "category": f.category, "resource": f.resource, "title": f.title}
82
+
83
+
84
+ # --------------------------------------------------------------------------- review
85
+
86
+
87
+ @dataclass
88
+ class ReviewReport:
89
+ summary: str
90
+ counts: dict[str, int]
91
+ findings: list[Finding]
92
+ changes: list[Change]
93
+ ai_used: bool = False
94
+ ai_error: str = ""
95
+
96
+ @property
97
+ def max_severity(self) -> str:
98
+ return max((f.severity for f in self.findings), key=sev_rank, default="none")
99
+
100
+
101
+ def _count_actions(changes: list[Change]) -> dict[str, int]:
102
+ counts = {"create": 0, "update": 0, "replace": 0, "delete": 0}
103
+ for c in changes:
104
+ counts[c.action] = counts.get(c.action, 0) + 1
105
+ return counts
106
+
107
+
108
+ def _parse_ai_findings(raw: Any, known: set[str]) -> list[Finding]:
109
+ out = []
110
+ for item in raw if isinstance(raw, list) else []:
111
+ if not isinstance(item, dict) or not item.get("title"):
112
+ continue
113
+ sev = str(item.get("severity", "info")).lower()
114
+ cat = str(item.get("category", "other")).lower()
115
+ res = str(item.get("resource", ""))
116
+ out.append(
117
+ Finding(
118
+ severity=sev if sev in SEVERITIES else "info",
119
+ category=cat if cat in CATEGORIES else "other",
120
+ resource=res if res in known else (res or "(plan)"),
121
+ title=str(item["title"])[:200],
122
+ detail=str(item.get("detail", ""))[:1000],
123
+ recommendation=str(item.get("recommendation", ""))[:1000],
124
+ source="ai",
125
+ )
126
+ )
127
+ return out
128
+
129
+
130
+ def run_review(plan: Plan, client: LLMClient | None) -> ReviewReport:
131
+ changes = [redact_change(c) for c in plan.changes]
132
+ findings = review_rules(changes)
133
+ report = ReviewReport(summary="", counts=_count_actions(changes), findings=findings, changes=changes)
134
+ if not changes or client is None:
135
+ return report
136
+
137
+ payload = {
138
+ "terraform_version": plan.terraform_version,
139
+ "counts": report.counts,
140
+ "changes": [
141
+ {
142
+ "address": c.address,
143
+ "type": c.type,
144
+ "action": c.action,
145
+ "replace_paths": c.replace_paths,
146
+ "diff": _diff_payload(c),
147
+ }
148
+ for c in changes[:MAX_RESOURCES]
149
+ ],
150
+ "omitted_changes": max(0, len(changes) - MAX_RESOURCES),
151
+ "rule_findings": [_finding_dict(f) for f in findings],
152
+ }
153
+ try:
154
+ resp = client.complete_json(REVIEW_SYSTEM, json.dumps(payload, default=str))
155
+ except LLMError as e:
156
+ _warn(f"AI analysis skipped: {e}")
157
+ report.ai_error = str(e)
158
+ return report
159
+
160
+ report.ai_used = True
161
+ report.summary = str(resp.get("summary", "")).strip()
162
+ seen = {(f.resource, f.category) for f in findings}
163
+ for f in _parse_ai_findings(resp.get("findings"), {c.address for c in changes}):
164
+ if (f.resource, f.category) not in seen:
165
+ report.findings.append(f)
166
+ seen.add((f.resource, f.category))
167
+ report.findings.sort(key=lambda f: (-sev_rank(f.severity), f.source != "rule", f.resource))
168
+ return report
169
+
170
+
171
+ # --------------------------------------------------------------------------- drift
172
+
173
+
174
+ @dataclass
175
+ class DriftReport:
176
+ items: list[DriftItem] = field(default_factory=list)
177
+ summary: str = ""
178
+ ai_used: bool = False
179
+ ai_error: str = ""
180
+
181
+ @property
182
+ def actionable(self) -> list[DriftItem]:
183
+ return [i for i in self.items if i.classification != "noise"]
184
+
185
+ @property
186
+ def fingerprint(self) -> str:
187
+ """Stable id of the actionable drift, so repeated runs don't spam new issues."""
188
+ key = sorted(
189
+ (i.resource, i.action, sorted((d.path, json.dumps(d.after, default=str)) for d in i.diffs))
190
+ for i in self.actionable
191
+ )
192
+ return hashlib.sha256(json.dumps(key).encode()).hexdigest()[:12] if key else ""
193
+
194
+
195
+ def run_drift(plan: Plan, client: LLMClient | None) -> DriftReport:
196
+ report = DriftReport(items=drift_rules([redact_change(c) for c in plan.drift], plan.prior_resources))
197
+ if not report.items or client is None:
198
+ return report
199
+
200
+ payload = {
201
+ "items": [
202
+ {
203
+ "resource": i.resource,
204
+ "type": i.type,
205
+ "action": i.action,
206
+ "preliminary_classification": i.classification,
207
+ "classification_is_final": i.locked,
208
+ "reason": i.reason,
209
+ "diff": [{"path": d.path, "state": d.before, "real": d.after} for d in i.diffs][
210
+ :MAX_DIFFS_PER_RESOURCE
211
+ ],
212
+ }
213
+ for i in report.items[:MAX_RESOURCES]
214
+ ]
215
+ }
216
+ try:
217
+ resp = client.complete_json(DRIFT_SYSTEM, json.dumps(payload, default=str))
218
+ except LLMError as e:
219
+ _warn(f"AI analysis skipped: {e}")
220
+ report.ai_error = str(e)
221
+ return report
222
+
223
+ report.ai_used = True
224
+ report.summary = str(resp.get("summary", "")).strip()
225
+ by_res = {i.resource: i for i in report.items}
226
+ for ai in resp.get("items") or []:
227
+ if not isinstance(ai, dict) or ai.get("resource") not in by_res:
228
+ continue
229
+ item = by_res[ai["resource"]]
230
+ cls = str(ai.get("classification", "")).lower()
231
+ # Rules are security-first: the model may escalate but never downgrade a risky item,
232
+ # and never overrides a classification proven from state.
233
+ if cls in DRIFT_CLASSES and item.classification != "risky" and not item.locked:
234
+ item.classification = cls
235
+ item.explanation = str(ai.get("explanation", ""))[:1000]
236
+ item.recommendation = str(ai.get("recommendation", ""))[:1500]
237
+ order = {k: i for i, k in enumerate(reversed(DRIFT_CLASSES))}
238
+ report.items.sort(key=lambda i: (order[i.classification], i.resource))
239
+ return report
tfscout/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ """Command line interface: `tfscout review` and `tfscout drift`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from tfscout import __version__
11
+ from tfscout.analyze import run_drift, run_review
12
+ from tfscout.github import GitHub, GitHubError, pr_number_from_event
13
+ from tfscout.llm import PROVIDERS, LLMError, make_client
14
+ from tfscout.plan import PlanError, load_plan
15
+ from tfscout.render import (
16
+ drift_json,
17
+ drift_marker,
18
+ render_drift_md,
19
+ render_review_md,
20
+ review_json,
21
+ review_marker,
22
+ )
23
+ from tfscout.rules import SEVERITIES, sev_rank
24
+
25
+ EXIT_FAIL_ON = 3
26
+ EXIT_DRIFT = 2
27
+
28
+
29
+ def _common(p: argparse.ArgumentParser) -> None:
30
+ p.add_argument("plan", help="plan file: JSON from `terraform show -json`, or a binary plan (-out=...)")
31
+ p.add_argument("--name", default="", help="label for this root module (e.g. 'prod/us-east-1')")
32
+ p.add_argument(
33
+ "--provider",
34
+ default=os.environ.get("TFSCOUT_PROVIDER", "none"),
35
+ choices=PROVIDERS,
36
+ help="LLM provider; 'none' = deterministic rules only (default: $TFSCOUT_PROVIDER or none)",
37
+ )
38
+ p.add_argument("--model", default=os.environ.get("TFSCOUT_MODEL", ""), help="override the model name")
39
+ p.add_argument(
40
+ "--base-url", default=os.environ.get("TFSCOUT_BASE_URL", ""), help="override the OpenAI-compatible endpoint"
41
+ )
42
+ p.add_argument("--api-key", default="", help="API key (prefer env TFSCOUT_API_KEY or provider env var)")
43
+ p.add_argument(
44
+ "--timeout",
45
+ type=float,
46
+ default=float(os.environ.get("TFSCOUT_TIMEOUT", "90")),
47
+ help="seconds to wait for the LLM per attempt (default: 90)",
48
+ )
49
+ p.add_argument(
50
+ "--reasoning-effort",
51
+ default=os.environ.get("TFSCOUT_REASONING_EFFORT", ""),
52
+ help="reasoning_effort for thinking models: minimal|low|medium|high (gemini default: low)",
53
+ )
54
+ p.add_argument("--format", choices=["markdown", "json"], default="markdown")
55
+ p.add_argument("--output", "-o", default="", help="write the report to a file instead of stdout")
56
+ p.add_argument("--terraform-bin", default="terraform", help="terraform/tofu binary for binary plans")
57
+ p.add_argument("--chdir", default=None, help="working dir for `terraform show` on binary plans")
58
+
59
+
60
+ def build_parser() -> argparse.ArgumentParser:
61
+ parser = argparse.ArgumentParser(
62
+ prog="tfscout", description="AI-assisted Terraform plan review and drift detection."
63
+ )
64
+ parser.add_argument("--version", action="version", version=f"tfscout {__version__}")
65
+ sub = parser.add_subparsers(dest="command", required=True)
66
+
67
+ r = sub.add_parser("review", help="review a plan: risks, security, data loss")
68
+ _common(r)
69
+ r.add_argument("--github-comment", action="store_true", help="create/update a PR comment")
70
+ r.add_argument("--pr", type=int, default=None, help="PR number (default: from GITHUB_EVENT_PATH)")
71
+ r.add_argument(
72
+ "--fail-on",
73
+ choices=["none", *SEVERITIES],
74
+ default="none",
75
+ help=f"exit {EXIT_FAIL_ON} if a finding has at least this severity",
76
+ )
77
+
78
+ d = sub.add_parser("drift", help="analyse drift from `terraform plan -refresh-only`")
79
+ _common(d)
80
+ d.add_argument("--github-issue", action="store_true", help="create/update/close a drift issue")
81
+ d.add_argument("--exit-code", action="store_true", help=f"exit {EXIT_DRIFT} when actionable drift is found")
82
+ return parser
83
+
84
+
85
+ def _emit(text: str, output: str) -> None:
86
+ if output:
87
+ Path(output).write_text(text + "\n", encoding="utf-8")
88
+ else:
89
+ print(text)
90
+
91
+
92
+ def _gh_output(**values: str) -> None:
93
+ path = os.environ.get("GITHUB_OUTPUT")
94
+ if path:
95
+ with open(path, "a", encoding="utf-8") as f:
96
+ for k, v in values.items():
97
+ f.write(f"{k}={v}\n")
98
+
99
+
100
+ def _gh_summary(markdown: str) -> None:
101
+ path = os.environ.get("GITHUB_STEP_SUMMARY")
102
+ if path:
103
+ with open(path, "a", encoding="utf-8") as f:
104
+ f.write(markdown + "\n\n")
105
+
106
+
107
+ def _cmd_review(args: argparse.Namespace, plan, client) -> int:
108
+ report = run_review(plan, client)
109
+ md = render_review_md(report, args.name)
110
+ _emit(md if args.format == "markdown" else review_json(report), args.output)
111
+ _gh_summary(md)
112
+ _gh_output(**{"max-severity": report.max_severity, "findings-count": str(len(report.findings))})
113
+
114
+ if args.github_comment:
115
+ pr = args.pr or pr_number_from_event()
116
+ if not pr:
117
+ print("tfscout: --github-comment needs a pull_request event or --pr", file=sys.stderr)
118
+ else:
119
+ try:
120
+ url = GitHub.from_env().upsert_pr_comment(pr, review_marker(args.name or "default"), md)
121
+ print(f"tfscout: PR comment: {url}", file=sys.stderr)
122
+ except GitHubError as e:
123
+ print(f"tfscout: failed to comment on PR: {e}", file=sys.stderr)
124
+ return 1
125
+
126
+ if args.fail_on != "none" and report.findings and sev_rank(report.max_severity) >= sev_rank(args.fail_on):
127
+ print(f"tfscout: findings at or above '{args.fail_on}' severity", file=sys.stderr)
128
+ return EXIT_FAIL_ON
129
+ return 0
130
+
131
+
132
+ def _cmd_drift(args: argparse.Namespace, plan, client) -> int:
133
+ report = run_drift(plan, client)
134
+ md = render_drift_md(report, args.name)
135
+ _emit(md if args.format == "markdown" else drift_json(report), args.output)
136
+ _gh_summary(md)
137
+ has_drift = bool(report.actionable)
138
+ _gh_output(
139
+ **{
140
+ "drift-detected": str(has_drift).lower(),
141
+ "fingerprint": report.fingerprint,
142
+ "findings-count": str(len(report.actionable)),
143
+ }
144
+ )
145
+
146
+ if args.github_issue:
147
+ title = f"Terraform drift detected{f': {args.name}' if args.name else ''}"
148
+ try:
149
+ action, url = GitHub.from_env().sync_drift_issue(
150
+ drift_marker(args.name or "default"), title, md, report.fingerprint
151
+ )
152
+ print(f"tfscout: drift issue {action}{f': {url}' if url else ''}", file=sys.stderr)
153
+ except GitHubError as e:
154
+ print(f"tfscout: failed to sync drift issue: {e}", file=sys.stderr)
155
+ return 1
156
+
157
+ return EXIT_DRIFT if args.exit_code and has_drift else 0
158
+
159
+
160
+ def main(argv: list[str] | None = None) -> int:
161
+ args = build_parser().parse_args(argv)
162
+ try:
163
+ plan = load_plan(args.plan, args.terraform_bin, args.chdir)
164
+ except PlanError as e:
165
+ print(f"tfscout: error: {e}", file=sys.stderr)
166
+ return 1
167
+ try:
168
+ client = make_client(
169
+ args.provider,
170
+ args.model,
171
+ args.base_url,
172
+ args.api_key,
173
+ timeout=args.timeout,
174
+ reasoning_effort=args.reasoning_effort,
175
+ )
176
+ except LLMError as e:
177
+ # e.g. secrets are not available to pull requests from forks: still produce the rules-only report
178
+ print(f"tfscout: warning: AI disabled: {e}", file=sys.stderr)
179
+ client = None
180
+ if args.command == "review":
181
+ return _cmd_review(args, plan, client)
182
+ return _cmd_drift(args, plan, client)
tfscout/diff.py ADDED
@@ -0,0 +1,113 @@
1
+ """Attribute-level diff between `before` and `after` of a resource change."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ UNKNOWN = "(known after apply)"
10
+ _MISSING = object()
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AttrDiff:
15
+ path: str
16
+ before: Any
17
+ after: Any
18
+
19
+
20
+ def _flatten(value: Any, prefix: str, out: dict[str, Any]) -> None:
21
+ if isinstance(value, dict) and value:
22
+ for k, v in value.items():
23
+ _flatten(v, f"{prefix}.{k}" if prefix else str(k), out)
24
+ elif isinstance(value, list) and value and any(isinstance(v, (dict, list)) for v in value):
25
+ for i, v in enumerate(value):
26
+ _flatten(v, f"{prefix}[{i}]", out)
27
+ else:
28
+ out[prefix] = value
29
+
30
+
31
+ def flatten(value: Any) -> dict[str, Any]:
32
+ out: dict[str, Any] = {}
33
+ if value is not None:
34
+ _flatten(value, "", out)
35
+ return out
36
+
37
+
38
+ def _unknown_paths(after_unknown: Any) -> set[str]:
39
+ return {p for p, v in flatten(after_unknown).items() if v is True}
40
+
41
+
42
+ def _short(v: Any, limit: int) -> Any:
43
+ if isinstance(v, (dict, list)):
44
+ v = json.dumps(v, sort_keys=True)
45
+ if isinstance(v, str) and len(v) > limit:
46
+ return v[:limit] + f"...(+{len(v) - limit} chars)"
47
+ return v
48
+
49
+
50
+ def _is_object_list(v: Any) -> bool:
51
+ return isinstance(v, list) and bool(v) and all(isinstance(x, dict) for x in v)
52
+
53
+
54
+ def _canon(v: Any) -> str:
55
+ return json.dumps(v, sort_keys=True, default=str)
56
+
57
+
58
+ def _split_object_lists(before: Any, after: Any, prefix: str, extra: list[AttrDiff], sets: set[str]) -> tuple:
59
+ """Diff lists of objects (e.g. SG ingress, nested blocks) as sets, not by index.
60
+
61
+ Matching elements are dropped, the rest are reported as `path[-]` (removed) / `path[+]` (added),
62
+ so adding one rule doesn't show up as every rule after it "changing".
63
+ """
64
+ if not (isinstance(before, dict) and isinstance(after, dict)):
65
+ return before, after
66
+ nb: dict[str, Any] = {}
67
+ na: dict[str, Any] = {}
68
+ for k in before.keys() | after.keys():
69
+ path = f"{prefix}.{k}" if prefix else str(k)
70
+ vb, va = before.get(k, _MISSING), after.get(k, _MISSING)
71
+ sides_ok = all(x is _MISSING or x is None or isinstance(x, list) for x in (vb, va))
72
+ if sides_ok and (_is_object_list(vb) or _is_object_list(va)):
73
+ sets.add(path)
74
+ lb = vb if isinstance(vb, list) else []
75
+ la = va if isinstance(va, list) else []
76
+ kb, ka = [_canon(x) for x in lb], [_canon(x) for x in la]
77
+ extra += [AttrDiff(f"{path}[-]", x, None) for x, c in zip(lb, kb, strict=True) if c not in ka]
78
+ extra += [AttrDiff(f"{path}[+]", None, x) for x, c in zip(la, ka, strict=True) if c not in kb]
79
+ continue
80
+ rb, ra = _split_object_lists(vb, va, path, extra, sets)
81
+ if vb is not _MISSING:
82
+ nb[k] = rb
83
+ if va is not _MISSING:
84
+ na[k] = ra
85
+ return nb, na
86
+
87
+
88
+ def diff(before: Any, after: Any, after_unknown: Any = None, value_limit: int = 200) -> list[AttrDiff]:
89
+ extra: list[AttrDiff] = []
90
+ sets: set[str] = set()
91
+ before, after = _split_object_lists(before, after, "", extra, sets)
92
+ b, a = flatten(before), flatten(after)
93
+ unknown = {p for p in _unknown_paths(after_unknown) if not any(p.startswith(s + "[") for s in sets)}
94
+ result: list[AttrDiff] = [
95
+ AttrDiff(d.path, _short(d.before, value_limit), _short(d.after, value_limit)) for d in extra
96
+ ]
97
+ for path in sorted(set(b) | set(a) | unknown):
98
+ bv = b.get(path, _MISSING)
99
+ av = UNKNOWN if path in unknown else a.get(path, _MISSING)
100
+ if bv == av:
101
+ continue
102
+ # Treat null / empty / missing as equivalent to avoid noisy diffs
103
+ if bv in (_MISSING, None, "", [], {}) and av in (_MISSING, None, "", [], {}):
104
+ continue
105
+ result.append(
106
+ AttrDiff(
107
+ path=path,
108
+ before=None if bv is _MISSING else _short(bv, value_limit),
109
+ after=None if av is _MISSING else _short(av, value_limit),
110
+ )
111
+ )
112
+ result.sort(key=lambda d: d.path)
113
+ return result
tfscout/github.py ADDED
@@ -0,0 +1,130 @@
1
+ """Tiny GitHub REST client (stdlib only) for PR comments and drift issues."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ MAX_BODY = 65000
14
+ DRIFT_LABEL = "tfscout-drift"
15
+
16
+
17
+ class GitHubError(Exception):
18
+ pass
19
+
20
+
21
+ def _truncate(body: str) -> str:
22
+ if len(body) <= MAX_BODY:
23
+ return body
24
+ return body[: MAX_BODY - 200] + "\n\n…\n\n_Report truncated (GitHub size limit). See the workflow artifact._"
25
+
26
+
27
+ @dataclass
28
+ class GitHub:
29
+ token: str
30
+ repo: str
31
+ api_url: str = "https://api.github.com"
32
+
33
+ @classmethod
34
+ def from_env(cls, token: str = "") -> GitHub:
35
+ token = token or os.environ.get("GITHUB_TOKEN", "")
36
+ repo = os.environ.get("GITHUB_REPOSITORY", "")
37
+ if not token or not repo:
38
+ raise GitHubError("GITHUB_TOKEN and GITHUB_REPOSITORY must be set")
39
+ return cls(token, repo, os.environ.get("GITHUB_API_URL", "https://api.github.com"))
40
+
41
+ def _req(self, method: str, path: str, body: Any = None, params: dict[str, Any] | None = None) -> Any:
42
+ url = f"{self.api_url.rstrip('/')}{path}"
43
+ if params:
44
+ url += "?" + urllib.parse.urlencode(params)
45
+ data = json.dumps(body).encode() if body is not None else None
46
+ req = urllib.request.Request(
47
+ url,
48
+ data=data,
49
+ method=method,
50
+ headers={
51
+ "Authorization": f"Bearer {self.token}",
52
+ "Accept": "application/vnd.github+json",
53
+ "X-GitHub-Api-Version": "2022-11-28",
54
+ "User-Agent": "tfscout",
55
+ **({"Content-Type": "application/json"} if data else {}),
56
+ },
57
+ )
58
+ try:
59
+ with urllib.request.urlopen(req, timeout=30) as resp:
60
+ raw = resp.read()
61
+ return json.loads(raw) if raw else None
62
+ except urllib.error.HTTPError as e:
63
+ raise GitHubError(f"{method} {path}: HTTP {e.code} {e.read().decode(errors='replace')[:300]}") from e
64
+
65
+ def _paginate(self, path: str, params: dict[str, Any] | None = None, limit: int = 10):
66
+ for page in range(1, limit + 1):
67
+ items = self._req("GET", path, params={**(params or {}), "per_page": 100, "page": page})
68
+ if not items:
69
+ return
70
+ yield from items
71
+ if len(items) < 100:
72
+ return
73
+
74
+ # ------------------------------------------------------------------ PR comments
75
+
76
+ def upsert_pr_comment(self, pr: int, marker: str, body: str) -> str:
77
+ body = _truncate(body)
78
+ for c in self._paginate(f"/repos/{self.repo}/issues/{pr}/comments"):
79
+ if marker in (c.get("body") or ""):
80
+ self._req("PATCH", f"/repos/{self.repo}/issues/comments/{c['id']}", {"body": body})
81
+ return c["html_url"]
82
+ return self._req("POST", f"/repos/{self.repo}/issues/{pr}/comments", {"body": body})["html_url"]
83
+
84
+ # ------------------------------------------------------------------ drift issues
85
+
86
+ def _find_issue(self, marker: str) -> dict[str, Any] | None:
87
+ params = {"state": "open", "labels": DRIFT_LABEL}
88
+ for issue in self._paginate(f"/repos/{self.repo}/issues", params):
89
+ if "pull_request" not in issue and marker in (issue.get("body") or ""):
90
+ return issue
91
+ return None
92
+
93
+ def sync_drift_issue(self, marker: str, title: str, body: str, fingerprint: str) -> tuple[str, str]:
94
+ """Create/update/close the drift issue. Returns (action, url)."""
95
+ issue = self._find_issue(marker)
96
+ base = f"/repos/{self.repo}/issues"
97
+ if not fingerprint:
98
+ if issue:
99
+ self._req(
100
+ "POST",
101
+ f"{base}/{issue['number']}/comments",
102
+ {"body": "āœ… Drift resolved — infrastructure matches Terraform state again."},
103
+ )
104
+ self._req("PATCH", f"{base}/{issue['number']}", {"state": "closed", "state_reason": "completed"})
105
+ return "closed", issue["html_url"]
106
+ return "none", ""
107
+ body = _truncate(body)
108
+ if issue:
109
+ if f"`{fingerprint}`" in (issue.get("body") or ""):
110
+ return "unchanged", issue["html_url"]
111
+ self._req("PATCH", f"{base}/{issue['number']}", {"title": title, "body": body})
112
+ self._req(
113
+ "POST",
114
+ f"{base}/{issue['number']}/comments",
115
+ {"body": f"šŸ”„ Drift changed (fingerprint `{fingerprint}`). Report updated above."},
116
+ )
117
+ return "updated", issue["html_url"]
118
+ created = self._req("POST", base, {"title": title, "body": body, "labels": [DRIFT_LABEL]})
119
+ return "created", created["html_url"]
120
+
121
+
122
+ def pr_number_from_event() -> int | None:
123
+ path = os.environ.get("GITHUB_EVENT_PATH")
124
+ if not path or not os.path.isfile(path):
125
+ return None
126
+ with open(path, encoding="utf-8") as f:
127
+ event = json.load(f)
128
+ pr = event.get("pull_request") or {}
129
+ num = pr.get("number") or (event.get("issue") or {}).get("number")
130
+ return int(num) if num else None