Skip to content

Commit d55b3e0

Browse files
authored
- orchestrates the contributor path (#766)
1 parent 28f7e36 commit d55b3e0

1 file changed

Lines changed: 308 additions & 0 deletions

File tree

tools/check_contribution.py

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
#!/usr/bin/env python3
2+
"""
3+
check_contribution.py
4+
─────────────────────────────────────────────────────────────────────────────
5+
Pre-merge contribution validation orchestrator.
6+
7+
Runs the full SOC Framework contribution check chain against all packs
8+
changed on the current branch. This is the single entry point for both
9+
local development and CI — the same command, the same output, the same
10+
exit code in both environments.
11+
12+
WORKFLOW
13+
────────
14+
1. normalize_contribution.py Strip UI export artifacts, rename to canonical
15+
filename, place in correct directory structure.
16+
2. pack_prep.py SDK validation, xsoar_config JSON integrity,
17+
cross-pack dependency version check.
18+
3. fix_errors.py (report) Report BA101/BA106 issues without auto-fixing.
19+
Errors here mean normalize didn't catch something
20+
— useful signal for improving the pipeline.
21+
4. check_contracts.py Layer contract violations — setIssue from
22+
Workflow, wrong namespace writes, missing
23+
Lifecycle phase boundaries.
24+
5. validate_shadow_mode.py Shadow mode consistency across all UC actions.
25+
6. upload_package.sh Deploy changed packs to review tenant.
26+
Runs in both local and CI — credentials come
27+
from .env locally, GitHub Secrets in CI.
28+
29+
EXIT CODES
30+
──────────
31+
0 All checks passed. Upload succeeded (or skipped with --no-upload).
32+
1 One or more checks failed. Review output before merging.
33+
34+
SCOPE
35+
─────
36+
By default, git diff origin/main finds the changed packs automatically.
37+
Use --input to target a specific pack when not on a branch.
38+
39+
Usage:
40+
# Full run — find changed packs from git diff, validate and upload
41+
python3 tools/check_contribution.py
42+
43+
# Dry run — validate only, skip upload
44+
python3 tools/check_contribution.py --no-upload
45+
46+
# Target a specific pack
47+
python3 tools/check_contribution.py --input Packs/soc-framework-nist-ir
48+
49+
# CI mode — same as default but formats output for GitHub Actions annotations
50+
python3 tools/check_contribution.py --ci
51+
"""
52+
53+
import argparse
54+
import subprocess
55+
import sys
56+
from pathlib import Path
57+
58+
59+
# ─────────────────────────────────────────────────────────────────────────────
60+
# ANSI colour helpers
61+
# ─────────────────────────────────────────────────────────────────────────────
62+
63+
_TTY = sys.stdout.isatty()
64+
65+
def _c(code, t): return f"\033[{code}m{t}\033[0m" if _TTY else t
66+
def OK(t): return _c("32;1", t)
67+
def ERR(t): return _c("31;1", t)
68+
def WARN(t): return _c("33;1", t)
69+
def INFO(t): return _c("36", t)
70+
def BOLD(t): return _c("1", t)
71+
def DIM(t): return _c("2", t)
72+
def STEP(t): return _c("35;1", t)
73+
74+
75+
# ─────────────────────────────────────────────────────────────────────────────
76+
# Git integration — find changed packs
77+
# ─────────────────────────────────────────────────────────────────────────────
78+
79+
def git_changed_packs(base: str = "origin/main") -> list[Path]:
80+
"""
81+
Return pack directories that have added or modified files on this branch.
82+
83+
Uses --diff-filter=ACMR to exclude deletions — removing a file from a
84+
pack doesn't constitute a contribution that needs validating.
85+
"""
86+
try:
87+
result = subprocess.run(
88+
["git", "diff", base, "--name-only", "--diff-filter=ACMR"],
89+
capture_output=True, text=True, check=True,
90+
)
91+
except (subprocess.CalledProcessError, FileNotFoundError):
92+
return []
93+
94+
packs: dict[str, Path] = {}
95+
for line in result.stdout.splitlines():
96+
p = Path(line.strip())
97+
if p.parts and p.parts[0] == "Packs" and len(p.parts) > 1:
98+
pack_name = p.parts[1]
99+
pack_path = Path("Packs") / pack_name
100+
if (pack_path / "pack_metadata.json").exists():
101+
packs[pack_name] = pack_path
102+
103+
return sorted(packs.values())
104+
105+
106+
# ─────────────────────────────────────────────────────────────────────────────
107+
# Step runner — executes a tool and captures result
108+
# ─────────────────────────────────────────────────────────────────────────────
109+
110+
class StepResult:
111+
def __init__(self, name: str, rc: int, output: str):
112+
self.name = name
113+
self.rc = rc
114+
self.output = output
115+
116+
@property
117+
def passed(self) -> bool:
118+
return self.rc == 0
119+
120+
121+
def run_step(
122+
name: str,
123+
cmd: list[str],
124+
ci_mode: bool = False,
125+
allow_fail: bool = False,
126+
) -> StepResult:
127+
"""
128+
Run a single pipeline step, stream output to stdout, and return a result.
129+
130+
allow_fail: if True, a non-zero exit code is treated as a warning rather
131+
than a hard failure (used for fix_errors report-only mode).
132+
"""
133+
print(f"\n {STEP('▶')} {BOLD(name)}")
134+
print(f" {DIM(' '.join(str(c) for c in cmd))}")
135+
print()
136+
137+
result = subprocess.run(cmd, text=True)
138+
139+
if result.returncode == 0:
140+
print(f"\n {OK('✓')} {name} passed")
141+
elif allow_fail:
142+
print(f"\n {WARN('⚠')} {name} reported issues (non-blocking)")
143+
else:
144+
print(f"\n {ERR('✗')} {name} FAILED")
145+
if ci_mode:
146+
# GitHub Actions error annotation
147+
print(f"::error::{name} failed — see output above")
148+
149+
return StepResult(name, result.returncode if not allow_fail else 0, "")
150+
151+
152+
# ─────────────────────────────────────────────────────────────────────────────
153+
# Main
154+
# ─────────────────────────────────────────────────────────────────────────────
155+
156+
def main() -> None:
157+
parser = argparse.ArgumentParser(
158+
description="SOC Framework pre-merge contribution validator",
159+
formatter_class=argparse.RawDescriptionHelpFormatter,
160+
epilog=__doc__,
161+
)
162+
parser.add_argument(
163+
"--input", "-i", default=None,
164+
help="Specific pack directory to validate (default: git diff scope)",
165+
)
166+
parser.add_argument(
167+
"--base", default="origin/main",
168+
help="Git base ref for diff (default: origin/main)",
169+
)
170+
parser.add_argument(
171+
"--no-upload", action="store_true",
172+
help="Skip upload to review tenant (validate only)",
173+
)
174+
parser.add_argument(
175+
"--ci", action="store_true",
176+
help="CI mode — emit GitHub Actions annotations",
177+
)
178+
args = parser.parse_args()
179+
180+
# ── Resolve packs to validate ─────────────────────────────────────────────
181+
if args.input:
182+
input_path = Path(args.input)
183+
if not input_path.exists():
184+
print(ERR(f"✗ not found: {input_path}"))
185+
sys.exit(1)
186+
if not (input_path / "pack_metadata.json").exists():
187+
print(ERR(f"✗ not a pack directory (no pack_metadata.json): {input_path}"))
188+
sys.exit(1)
189+
packs = [input_path]
190+
else:
191+
packs = git_changed_packs(args.base)
192+
193+
# ── Header ────────────────────────────────────────────────────────────────
194+
print()
195+
print("━" * 62)
196+
print(" check_contribution.py — SOC Framework pre-merge validator")
197+
if args.input:
198+
print(f" scope : {INFO(str(args.input))}")
199+
else:
200+
print(f" scope : {INFO(f'git diff {args.base}')}")
201+
if args.no_upload:
202+
print(f" upload : {WARN('skipped (--no-upload)')}")
203+
print("━" * 62)
204+
205+
if not packs:
206+
print(WARN("\n No changed packs found."))
207+
print(DIM(
208+
" Run 'git fetch origin' if you expected changes,\n"
209+
" or use --input <pack> to target a specific pack."
210+
))
211+
sys.exit(0)
212+
213+
print(f"\n {len(packs)} pack(s) in scope: "
214+
+ ", ".join(INFO(p.name) for p in packs))
215+
216+
# ── Step 1: Normalize ─────────────────────────────────────────────────────
217+
# Run once across all changed files — normalize finds them via git diff.
218+
# When --input is given, pass it through so normalize scopes to that pack.
219+
normalize_cmd = [sys.executable, "tools/normalize_contribution.py"]
220+
if args.input:
221+
normalize_cmd += ["--input", str(args.input)]
222+
223+
results: list[StepResult] = []
224+
results.append(run_step("Normalize contribution", normalize_cmd, args.ci))
225+
226+
# ── Per-pack steps ────────────────────────────────────────────────────────
227+
for pack in packs:
228+
print(f"\n{'─' * 62}")
229+
print(f" Pack: {BOLD(pack.name)}")
230+
print(f"{'─' * 62}")
231+
232+
# ── Step 2: pack_prep ─────────────────────────────────────────────────
233+
results.append(run_step(
234+
f"pack_prep — {pack.name}",
235+
[sys.executable, "tools/pack_prep.py", str(pack)],
236+
args.ci,
237+
))
238+
239+
# ── Step 3: fix_errors (report only) ──────────────────────────────────
240+
# fix_errors reads from output/sdk_errors.txt produced by pack_prep.
241+
# We run it in report mode — it prints what it would fix but makes no
242+
# changes. If it fires, normalize missed something worth investigating.
243+
sdk_errors = Path("output/sdk_errors.txt")
244+
if sdk_errors.exists() and sdk_errors.stat().st_size > 0:
245+
results.append(run_step(
246+
f"fix_errors report — {pack.name}",
247+
[sys.executable, "tools/fix_errors.py", str(sdk_errors), "--dry-run"],
248+
args.ci,
249+
allow_fail=True, # report only — not a hard block
250+
))
251+
else:
252+
print(f"\n {OK('✓')} fix_errors — no SDK errors to report")
253+
254+
# ── Step 4: check_contracts ───────────────────────────────────────────
255+
results.append(run_step(
256+
f"check_contracts — {pack.name}",
257+
[sys.executable, "tools/check_contracts.py", "--input", str(pack)],
258+
args.ci,
259+
))
260+
261+
# ── Step 5: validate_shadow_mode (once, --all) ────────────────────────────
262+
# Always runs across the entire framework — shadow mode consistency is a
263+
# global property, not per-pack. One broken action affects every playbook.
264+
print(f"\n{'─' * 62}")
265+
print(f" Framework-wide checks")
266+
print(f"{'─' * 62}")
267+
268+
results.append(run_step(
269+
"validate_shadow_mode --all",
270+
[sys.executable, "tools/validate_shadow_mode.py", "--all"],
271+
args.ci,
272+
))
273+
274+
# ── Step 6: upload ────────────────────────────────────────────────────────
275+
if not args.no_upload:
276+
for pack in packs:
277+
results.append(run_step(
278+
f"upload — {pack.name}",
279+
["bash", "tools/upload_package.sh", str(pack)],
280+
args.ci,
281+
))
282+
else:
283+
print(f"\n {DIM('⊘ Upload skipped (--no-upload)')}")
284+
285+
# ── Summary ───────────────────────────────────────────────────────────────
286+
print()
287+
print("━" * 62)
288+
failures = [r for r in results if not r.passed]
289+
290+
if not failures:
291+
print(OK(f" ✓ All checks passed — {len(packs)} pack(s) validated"))
292+
if not args.no_upload:
293+
print(OK(f" ✓ Uploaded to tenant — review and open PR"))
294+
else:
295+
print(ERR(f" ✗ {len(failures)} check(s) failed:"))
296+
for f in failures:
297+
print(ERR(f" • {f.name}"))
298+
print()
299+
print(DIM(" Fix the errors above before opening a PR."))
300+
301+
print("━" * 62)
302+
print()
303+
304+
sys.exit(0 if not failures else 1)
305+
306+
307+
if __name__ == "__main__":
308+
main()

0 commit comments

Comments
 (0)