specfact-cli 0.4.2__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.
- specfact_cli/__init__.py +14 -0
- specfact_cli/agents/__init__.py +24 -0
- specfact_cli/agents/analyze_agent.py +392 -0
- specfact_cli/agents/base.py +95 -0
- specfact_cli/agents/plan_agent.py +202 -0
- specfact_cli/agents/registry.py +176 -0
- specfact_cli/agents/sync_agent.py +133 -0
- specfact_cli/analyzers/__init__.py +11 -0
- specfact_cli/analyzers/code_analyzer.py +796 -0
- specfact_cli/cli.py +396 -0
- specfact_cli/commands/__init__.py +7 -0
- specfact_cli/commands/enforce.py +88 -0
- specfact_cli/commands/import_cmd.py +365 -0
- specfact_cli/commands/init.py +125 -0
- specfact_cli/commands/plan.py +1089 -0
- specfact_cli/commands/repro.py +192 -0
- specfact_cli/commands/sync.py +408 -0
- specfact_cli/common/__init__.py +25 -0
- specfact_cli/common/logger_setup.py +654 -0
- specfact_cli/common/logging_utils.py +41 -0
- specfact_cli/common/text_utils.py +52 -0
- specfact_cli/common/utils.py +48 -0
- specfact_cli/comparators/__init__.py +11 -0
- specfact_cli/comparators/plan_comparator.py +391 -0
- specfact_cli/generators/__init__.py +14 -0
- specfact_cli/generators/plan_generator.py +105 -0
- specfact_cli/generators/protocol_generator.py +115 -0
- specfact_cli/generators/report_generator.py +200 -0
- specfact_cli/generators/workflow_generator.py +120 -0
- specfact_cli/importers/__init__.py +7 -0
- specfact_cli/importers/speckit_converter.py +773 -0
- specfact_cli/importers/speckit_scanner.py +711 -0
- specfact_cli/models/__init__.py +33 -0
- specfact_cli/models/deviation.py +105 -0
- specfact_cli/models/enforcement.py +150 -0
- specfact_cli/models/plan.py +97 -0
- specfact_cli/models/protocol.py +28 -0
- specfact_cli/modes/__init__.py +19 -0
- specfact_cli/modes/detector.py +126 -0
- specfact_cli/modes/router.py +153 -0
- specfact_cli/resources/semgrep/async.yml +285 -0
- specfact_cli/sync/__init__.py +12 -0
- specfact_cli/sync/repository_sync.py +279 -0
- specfact_cli/sync/speckit_sync.py +388 -0
- specfact_cli/utils/__init__.py +58 -0
- specfact_cli/utils/console.py +70 -0
- specfact_cli/utils/feature_keys.py +212 -0
- specfact_cli/utils/git.py +241 -0
- specfact_cli/utils/github_annotations.py +399 -0
- specfact_cli/utils/ide_setup.py +382 -0
- specfact_cli/utils/prompts.py +180 -0
- specfact_cli/utils/structure.py +497 -0
- specfact_cli/utils/yaml_utils.py +200 -0
- specfact_cli/validators/__init__.py +20 -0
- specfact_cli/validators/fsm.py +262 -0
- specfact_cli/validators/repro_checker.py +759 -0
- specfact_cli/validators/schema.py +196 -0
- specfact_cli-0.4.2.dist-info/METADATA +370 -0
- specfact_cli-0.4.2.dist-info/RECORD +62 -0
- specfact_cli-0.4.2.dist-info/WHEEL +4 -0
- specfact_cli-0.4.2.dist-info/entry_points.txt +2 -0
- specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +61 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub Action annotations and PR comment utilities.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for creating GitHub Action annotations
|
|
5
|
+
and PR comments from SpecFact validation reports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from beartype import beartype
|
|
16
|
+
from icontract import ensure, require
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@beartype
|
|
20
|
+
@require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
|
|
21
|
+
@require(lambda level: level in ("notice", "warning", "error"), "Level must be notice, warning, or error")
|
|
22
|
+
@require(
|
|
23
|
+
lambda file: file is None or (isinstance(file, str) and len(file) > 0), "File must be None or non-empty string"
|
|
24
|
+
)
|
|
25
|
+
@require(lambda line: line is None or (isinstance(line, int) and line > 0), "Line must be None or positive integer")
|
|
26
|
+
@require(lambda col: col is None or (isinstance(col, int) and col > 0), "Column must be None or positive integer")
|
|
27
|
+
@require(
|
|
28
|
+
lambda title: title is None or (isinstance(title, str) and len(title) > 0), "Title must be None or non-empty string"
|
|
29
|
+
)
|
|
30
|
+
def create_annotation(
|
|
31
|
+
message: str,
|
|
32
|
+
level: str = "error",
|
|
33
|
+
file: str | None = None,
|
|
34
|
+
line: int | None = None,
|
|
35
|
+
col: int | None = None,
|
|
36
|
+
title: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Create a GitHub Action annotation.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
message: Annotation message
|
|
43
|
+
level: Annotation level (notice, warning, error)
|
|
44
|
+
file: Optional file path
|
|
45
|
+
line: Optional line number
|
|
46
|
+
col: Optional column number
|
|
47
|
+
title: Optional annotation title
|
|
48
|
+
"""
|
|
49
|
+
# Format: ::level file=file,line=line,col=col,title=title::message
|
|
50
|
+
parts: list[str] = [f"::{level}"]
|
|
51
|
+
|
|
52
|
+
if file or line or col or title:
|
|
53
|
+
opts: list[str] = []
|
|
54
|
+
if file:
|
|
55
|
+
opts.append(f"file={file}")
|
|
56
|
+
if line:
|
|
57
|
+
opts.append(f"line={line}")
|
|
58
|
+
if col:
|
|
59
|
+
opts.append(f"col={col}")
|
|
60
|
+
if title:
|
|
61
|
+
opts.append(f"title={title}")
|
|
62
|
+
parts.append(",".join(opts))
|
|
63
|
+
|
|
64
|
+
parts.append(f"::{message}")
|
|
65
|
+
|
|
66
|
+
print("".join(parts), file=sys.stdout)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@beartype
|
|
70
|
+
@require(lambda report_path: report_path.exists(), "Report path must exist")
|
|
71
|
+
@require(lambda report_path: report_path.suffix in (".yaml", ".yml"), "Report must be YAML file")
|
|
72
|
+
@require(lambda report_path: report_path.is_file(), "Report path must be a file")
|
|
73
|
+
@ensure(lambda result: isinstance(result, dict), "Must return dictionary")
|
|
74
|
+
@ensure(lambda result: "checks" in result or "total_checks" in result, "Report must contain checks or total_checks")
|
|
75
|
+
def parse_repro_report(report_path: Path) -> dict[str, Any]:
|
|
76
|
+
"""
|
|
77
|
+
Parse a repro report YAML file.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
report_path: Path to repro report YAML file
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Parsed report dictionary with checks and metadata
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
FileNotFoundError: If report file doesn't exist
|
|
87
|
+
ValueError: If report is not valid YAML or doesn't match expected structure
|
|
88
|
+
"""
|
|
89
|
+
from specfact_cli.utils.yaml_utils import load_yaml
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
report = load_yaml(report_path)
|
|
93
|
+
if not isinstance(report, dict):
|
|
94
|
+
raise ValueError(f"Report must be a dictionary, got {type(report)}")
|
|
95
|
+
return report
|
|
96
|
+
except Exception as e:
|
|
97
|
+
raise ValueError(f"Failed to parse repro report: {e}") from e
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@beartype
|
|
101
|
+
@require(lambda report: isinstance(report, dict), "Report must be dictionary")
|
|
102
|
+
@require(lambda report: "checks" in report or "total_checks" in report, "Report must contain checks or total_checks")
|
|
103
|
+
@ensure(lambda result: isinstance(result, bool), "Must return boolean")
|
|
104
|
+
def create_annotations_from_report(report: dict[str, Any]) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Create GitHub Action annotations from a repro report.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
report: Repro report dictionary
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if any failures found, False otherwise
|
|
113
|
+
"""
|
|
114
|
+
checks = report.get("checks", [])
|
|
115
|
+
has_failures = False
|
|
116
|
+
|
|
117
|
+
for check in checks:
|
|
118
|
+
status = check.get("status", "unknown")
|
|
119
|
+
name = check.get("name", "Unknown check")
|
|
120
|
+
tool = check.get("tool", "unknown")
|
|
121
|
+
error = check.get("error", "")
|
|
122
|
+
output = check.get("output", "")
|
|
123
|
+
|
|
124
|
+
# Check if this is a CrossHair signature analysis limitation (not a real failure)
|
|
125
|
+
is_signature_issue = False
|
|
126
|
+
if tool.lower() == "crosshair" and status == "failed":
|
|
127
|
+
# Check for signature analysis limitation patterns
|
|
128
|
+
combined_output = f"{error} {output}".lower()
|
|
129
|
+
is_signature_issue = (
|
|
130
|
+
"wrong parameter order" in combined_output
|
|
131
|
+
or "keyword-only parameter" in combined_output
|
|
132
|
+
or "valueerror: wrong parameter" in combined_output
|
|
133
|
+
or ("signature" in combined_output and ("error" in combined_output or "failure" in combined_output))
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if status == "failed" and not is_signature_issue:
|
|
137
|
+
has_failures = True
|
|
138
|
+
|
|
139
|
+
# Create error annotation
|
|
140
|
+
message = f"{name} ({tool}) failed"
|
|
141
|
+
if error:
|
|
142
|
+
message += f": {error}"
|
|
143
|
+
elif output:
|
|
144
|
+
# Truncate output for annotation
|
|
145
|
+
truncated = output[:500] + "..." if len(output) > 500 else output
|
|
146
|
+
message += f": {truncated}"
|
|
147
|
+
|
|
148
|
+
create_annotation(
|
|
149
|
+
message=message,
|
|
150
|
+
level="error",
|
|
151
|
+
title=f"{name} failed",
|
|
152
|
+
)
|
|
153
|
+
elif status == "failed" and is_signature_issue:
|
|
154
|
+
# CrossHair signature analysis limitation - treat as skipped, not failed
|
|
155
|
+
create_annotation(
|
|
156
|
+
message=f"{name} ({tool}) - signature analysis limitation (non-blocking, runtime contracts valid)",
|
|
157
|
+
level="notice",
|
|
158
|
+
title=f"{name} skipped (signature limitation)",
|
|
159
|
+
)
|
|
160
|
+
elif status == "timeout":
|
|
161
|
+
has_failures = True
|
|
162
|
+
create_annotation(
|
|
163
|
+
message=f"{name} ({tool}) timed out",
|
|
164
|
+
level="warning",
|
|
165
|
+
title=f"{name} timeout",
|
|
166
|
+
)
|
|
167
|
+
elif status == "skipped":
|
|
168
|
+
# Explicitly skipped checks - don't treat as failures
|
|
169
|
+
create_annotation(
|
|
170
|
+
message=f"{name} ({tool}) was skipped",
|
|
171
|
+
level="notice",
|
|
172
|
+
title=f"{name} skipped",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Create summary annotation
|
|
176
|
+
total_checks = report.get("total_checks", 0)
|
|
177
|
+
passed_checks = report.get("passed_checks", 0)
|
|
178
|
+
failed_checks = report.get("failed_checks", 0)
|
|
179
|
+
timeout_checks = report.get("timeout_checks", 0)
|
|
180
|
+
budget_exceeded = report.get("budget_exceeded", False)
|
|
181
|
+
|
|
182
|
+
if budget_exceeded:
|
|
183
|
+
has_failures = True # Budget exceeded is a failure
|
|
184
|
+
create_annotation(
|
|
185
|
+
message="Validation budget exceeded",
|
|
186
|
+
level="error",
|
|
187
|
+
title="Budget exceeded",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
summary = f"Validation summary: {passed_checks}/{total_checks} passed"
|
|
191
|
+
if failed_checks > 0:
|
|
192
|
+
summary += f", {failed_checks} failed"
|
|
193
|
+
if timeout_checks > 0:
|
|
194
|
+
summary += f", {timeout_checks} timed out"
|
|
195
|
+
|
|
196
|
+
level = "error" if has_failures else "notice"
|
|
197
|
+
create_annotation(
|
|
198
|
+
message=summary,
|
|
199
|
+
level=level,
|
|
200
|
+
title="Validation summary",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return has_failures
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@beartype
|
|
207
|
+
@require(lambda report: isinstance(report, dict), "Report must be dictionary")
|
|
208
|
+
@require(lambda report: "total_checks" in report or "checks" in report, "Report must contain total_checks or checks")
|
|
209
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
210
|
+
@ensure(lambda result: len(result) > 0, "Comment must not be empty")
|
|
211
|
+
@ensure(lambda result: result.startswith("##"), "Comment must start with markdown header")
|
|
212
|
+
def generate_pr_comment(report: dict[str, Any]) -> str:
|
|
213
|
+
"""
|
|
214
|
+
Generate a PR comment from a repro report.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
report: Repro report dictionary
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Formatted PR comment markdown
|
|
221
|
+
"""
|
|
222
|
+
lines: list[str] = []
|
|
223
|
+
lines.append("## SpecFact CLI Validation Report\n")
|
|
224
|
+
|
|
225
|
+
total_checks = report.get("total_checks", 0)
|
|
226
|
+
passed_checks = report.get("passed_checks", 0)
|
|
227
|
+
failed_checks = report.get("failed_checks", 0)
|
|
228
|
+
timeout_checks = report.get("timeout_checks", 0)
|
|
229
|
+
skipped_checks = report.get("skipped_checks", 0)
|
|
230
|
+
budget_exceeded = report.get("budget_exceeded", False)
|
|
231
|
+
total_duration = report.get("total_duration", 0.0)
|
|
232
|
+
|
|
233
|
+
# Summary
|
|
234
|
+
if failed_checks == 0 and timeout_checks == 0 and not budget_exceeded:
|
|
235
|
+
lines.append("✅ **All validations passed!**\n")
|
|
236
|
+
else:
|
|
237
|
+
lines.append("❌ **Validation issues detected**\n")
|
|
238
|
+
|
|
239
|
+
lines.append(f"**Duration**: {total_duration:.2f}s\n")
|
|
240
|
+
lines.append(f"**Checks**: {total_checks} total")
|
|
241
|
+
if passed_checks > 0:
|
|
242
|
+
lines.append(f" ({passed_checks} passed)")
|
|
243
|
+
if failed_checks > 0:
|
|
244
|
+
lines.append(f" ({failed_checks} failed)")
|
|
245
|
+
if timeout_checks > 0:
|
|
246
|
+
lines.append(f" ({timeout_checks} timed out)")
|
|
247
|
+
if skipped_checks > 0:
|
|
248
|
+
lines.append(f" ({skipped_checks} skipped)")
|
|
249
|
+
lines.append("\n\n")
|
|
250
|
+
|
|
251
|
+
# Failed checks (excluding signature analysis limitations)
|
|
252
|
+
checks = report.get("checks", [])
|
|
253
|
+
failed_checks_list = []
|
|
254
|
+
signature_issues_list = []
|
|
255
|
+
|
|
256
|
+
for check in checks:
|
|
257
|
+
if check.get("status") == "failed":
|
|
258
|
+
tool = check.get("tool", "unknown").lower()
|
|
259
|
+
error = check.get("error", "")
|
|
260
|
+
output = check.get("output", "")
|
|
261
|
+
|
|
262
|
+
# Check if this is a CrossHair signature analysis limitation
|
|
263
|
+
is_signature_issue = False
|
|
264
|
+
if tool == "crosshair":
|
|
265
|
+
combined_output = f"{error} {output}".lower()
|
|
266
|
+
is_signature_issue = (
|
|
267
|
+
"wrong parameter order" in combined_output
|
|
268
|
+
or "keyword-only parameter" in combined_output
|
|
269
|
+
or "valueerror: wrong parameter" in combined_output
|
|
270
|
+
or ("signature" in combined_output and ("error" in combined_output or "failure" in combined_output))
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if is_signature_issue:
|
|
274
|
+
signature_issues_list.append(check)
|
|
275
|
+
else:
|
|
276
|
+
failed_checks_list.append(check)
|
|
277
|
+
|
|
278
|
+
if failed_checks_list:
|
|
279
|
+
lines.append("### ❌ Failed Checks\n\n")
|
|
280
|
+
for check in failed_checks_list:
|
|
281
|
+
name = check.get("name", "Unknown")
|
|
282
|
+
tool = check.get("tool", "unknown")
|
|
283
|
+
error = check.get("error")
|
|
284
|
+
output = check.get("output")
|
|
285
|
+
|
|
286
|
+
lines.append(f"#### {name} ({tool})\n\n")
|
|
287
|
+
if error:
|
|
288
|
+
lines.append(f"**Error**: `{error}`\n\n")
|
|
289
|
+
if output:
|
|
290
|
+
lines.append("<details>\n<summary>Output</summary>\n\n")
|
|
291
|
+
lines.append("```\n")
|
|
292
|
+
lines.append(output[:2000]) # Limit output size
|
|
293
|
+
if len(output) > 2000:
|
|
294
|
+
lines.append("\n... (truncated)")
|
|
295
|
+
lines.append("\n```\n\n")
|
|
296
|
+
lines.append("</details>\n\n")
|
|
297
|
+
|
|
298
|
+
# Add fix suggestions for Semgrep checks
|
|
299
|
+
if tool == "semgrep":
|
|
300
|
+
lines.append(
|
|
301
|
+
"💡 **Auto-fix available**: Run `specfact repro --fix` to apply automatic fixes for violations with fix capabilities.\n\n"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Signature analysis limitations (non-blocking)
|
|
305
|
+
if signature_issues_list:
|
|
306
|
+
lines.append("### ⚠️ Signature Analysis Limitations (Non-blocking)\n\n")
|
|
307
|
+
lines.append(
|
|
308
|
+
"The following checks encountered CrossHair signature analysis limitations. "
|
|
309
|
+
"These are non-blocking issues related to complex function signatures (Typer decorators, keyword-only parameters) "
|
|
310
|
+
"and do not indicate actual contract violations. Runtime contracts remain valid.\n\n"
|
|
311
|
+
)
|
|
312
|
+
for check in signature_issues_list:
|
|
313
|
+
name = check.get("name", "Unknown")
|
|
314
|
+
tool = check.get("tool", "unknown")
|
|
315
|
+
lines.append(f"- **{name}** ({tool}) - signature analysis limitation\n")
|
|
316
|
+
lines.append("\n")
|
|
317
|
+
|
|
318
|
+
# Timeout checks
|
|
319
|
+
timeout_checks_list = [c for c in checks if c.get("status") == "timeout"]
|
|
320
|
+
if timeout_checks_list:
|
|
321
|
+
lines.append("### ⏱️ Timeout Checks\n\n")
|
|
322
|
+
for check in timeout_checks_list:
|
|
323
|
+
name = check.get("name", "Unknown")
|
|
324
|
+
tool = check.get("tool", "unknown")
|
|
325
|
+
lines.append(f"- **{name}** ({tool}) - timed out\n")
|
|
326
|
+
lines.append("\n")
|
|
327
|
+
|
|
328
|
+
# Budget exceeded
|
|
329
|
+
if budget_exceeded:
|
|
330
|
+
lines.append("### ⚠️ Budget Exceeded\n\n")
|
|
331
|
+
lines.append("The validation budget was exceeded. Consider increasing the budget or optimizing the checks.\n\n")
|
|
332
|
+
|
|
333
|
+
# Suggestions
|
|
334
|
+
if failed_checks > 0:
|
|
335
|
+
lines.append("### 💡 Suggestions\n\n")
|
|
336
|
+
lines.append("1. Review the failed checks above")
|
|
337
|
+
lines.append("2. Fix the issues in your code")
|
|
338
|
+
lines.append("3. Re-run validation: `specfact repro --budget 90`\n\n")
|
|
339
|
+
lines.append("To run in warn mode (non-blocking), set `mode: warn` in your workflow configuration.\n\n")
|
|
340
|
+
|
|
341
|
+
return "".join(lines)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@beartype
|
|
345
|
+
@ensure(lambda result: result in (0, 1), "Exit code must be 0 or 1")
|
|
346
|
+
def main() -> int:
|
|
347
|
+
"""
|
|
348
|
+
Main entry point for GitHub annotations script.
|
|
349
|
+
|
|
350
|
+
Reads repro report from environment variable or default path,
|
|
351
|
+
creates annotations, and optionally generates PR comment.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Exit code (0 = success/no failures, 1 = failures detected or error)
|
|
355
|
+
"""
|
|
356
|
+
# Get report path from environment or use default
|
|
357
|
+
report_path_str = os.environ.get("SPECFACT_REPORT_PATH")
|
|
358
|
+
if report_path_str:
|
|
359
|
+
report_path = Path(report_path_str)
|
|
360
|
+
else:
|
|
361
|
+
# Default: look for latest report in .specfact/reports/enforcement/
|
|
362
|
+
default_dir = Path(".specfact/reports/enforcement")
|
|
363
|
+
if default_dir.exists():
|
|
364
|
+
reports = sorted(default_dir.glob("report-*.yaml"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
365
|
+
if reports:
|
|
366
|
+
report_path = reports[0]
|
|
367
|
+
else:
|
|
368
|
+
print("No repro report found", file=sys.stderr)
|
|
369
|
+
return 1
|
|
370
|
+
else:
|
|
371
|
+
print("No repro report directory found", file=sys.stderr)
|
|
372
|
+
return 1
|
|
373
|
+
|
|
374
|
+
if not report_path.exists():
|
|
375
|
+
print(f"Report file not found: {report_path}", file=sys.stderr)
|
|
376
|
+
return 1
|
|
377
|
+
|
|
378
|
+
# Parse report
|
|
379
|
+
report = parse_repro_report(report_path)
|
|
380
|
+
|
|
381
|
+
# Create annotations
|
|
382
|
+
has_failures = create_annotations_from_report(report)
|
|
383
|
+
|
|
384
|
+
# Generate PR comment if requested
|
|
385
|
+
if os.environ.get("GITHUB_EVENT_NAME") == "pull_request":
|
|
386
|
+
comment = generate_pr_comment(report)
|
|
387
|
+
|
|
388
|
+
# Write comment to file for GitHub Actions to use
|
|
389
|
+
comment_path = Path(".specfact/pr-comment.md")
|
|
390
|
+
comment_path.parent.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
comment_path.write_text(comment, encoding="utf-8")
|
|
392
|
+
|
|
393
|
+
print(f"PR comment written to: {comment_path}", file=sys.stderr)
|
|
394
|
+
|
|
395
|
+
return 1 if has_failures else 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
sys.exit(main())
|