Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/ISSUE_TEMPLATE/1-rule-issue.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: New Rule or Existing Rule Bug
description: A request for a new feature or enhancement related to a specific rule
projects:
- cdisc-org/19
body:
- type: dropdown
id: request_type
attributes:
label: Request Type
options:
- Bug with Existing Rule
- New Rule Issue
validations:
required: true
- type: dropdown
id: standard
attributes:
label: Standard
options:
- SDTMIG
- SENDIG
- ADaM
- TIG
- USDM
- FDA Business Rules
validations:
required: true
- type: input
id: rule_id
attributes:
label: Rule ID
description: "For bugs: provide the CORE ID (e.g. CORE-000123). For new rules: provide the standard rule ID (e.g. CG0001)."
placeholder: CORE-000123 or CG0001
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Describe the bug or new rule request in detail.
validations:
required: true
- type: textarea
id: test_data
attributes:
label: Test Data
description: "If reporting a bug, attach your test data files here (CSV, Excel, etc.). You can drag and drop multiple files directly into this field."
placeholder: Drag and drop files here, or describe the test data inline.
9 changes: 9 additions & 0 deletions .github/ISSUE_TEMPLATE/2-other-feature.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name: Non-rule-related Request
description: A request for a new feature or enhancement not related to a specific rule
projects:
- cdisc-org/19
type: Feature
body:
- type: textarea
attributes:
label: Feature Description
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
blank_issues_enabled: false
21 changes: 13 additions & 8 deletions .github/scripts/generate_mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@
from collections import defaultdict
from pathlib import Path

try:
import yaml
except ImportError:
print("PyYAML is required. Install with: pip install pyyaml")
sys.exit(1)

def get_yaml():
try:
from ruamel.yaml import YAML
return YAML
except ImportError:
print("ruamel.yaml is required. Install with: pip install ruamel.yaml")
sys.exit(1)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -129,11 +132,13 @@ def build_standard_rows(rule_files: list[Path]) -> dict[str, list[dict]]:

"""
accumulator: dict[str, dict[str, dict]] = defaultdict(dict)

yaml = get_yaml()()
yaml.preserve_quotes = True
yaml.default_flow_style = False
for rule_file in rule_files:
raw = rule_file.read_text(encoding="utf-8")
try:
data = yaml.safe_load(raw)
data = yaml.load(raw)
except Exception as exc:
print(f" [WARN] Could not parse {rule_file}: {exc}")
continue
Expand Down Expand Up @@ -240,4 +245,4 @@ def main() -> None:


if __name__ == "__main__":
main()
main()
85 changes: 85 additions & 0 deletions .github/scripts/publish.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import csv
import re
import sys
from pathlib import Path
import argparse

CORE_PATTERN = re.compile(r"^CORE-(\d{6})$")
PUBLISHED_DIR = Path("Published")


def get_yaml():
try:
from ruamel.yaml import YAML
return YAML
except ImportError:
print("ruamel.yaml is required. Install with: pip install ruamel.yaml")
sys.exit(1)


def get_next_core_id(mappings_dir: Path, algorithm="max"):
existing_ids = []

for file in mappings_dir.glob("*_mapping.csv"):
with open(file, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
core = row.get("CORE-ID", "").strip()
match = CORE_PATTERN.match(core)
if match:
existing_ids.append(int(match.group(1)))

existing_ids.sort()

if algorithm == "min":
next_id = 1
for eid in existing_ids:
if eid != next_id:
break
next_id += 1
else:
next_id = max(existing_ids, default=0) + 1

return f"CORE-{next_id:06d}"


def update_rule_yaml(core_id: str, rule_path: Path):
yaml = get_yaml()()
with open(rule_path, encoding="utf-8") as f:
doc = yaml.load(f)
if "Core" not in doc:
doc["Core"] = {}
doc["Core"]["Id"] = core_id
doc["Core"]["Status"] = "Published"
with open(rule_path, "w", encoding="utf-8") as f:
yaml.dump(doc, f)


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--new-dirs", required=True, help="Space-separated rule directories to publish")
parser.add_argument(
"--algorithm", choices=["min", "max"], default="max", help="CORE-ID assignment algorithm"
)
args = parser.parse_args()

mappings_dir = Path("mappings")
PUBLISHED_DIR.mkdir(exist_ok=True)

for rule_dir in args.new_dirs.split():
rule_path = Path(rule_dir) / "rule.yaml"
if not rule_path.exists():
print(f"[SKIP] No rule.yaml found in {rule_dir}")
continue

core_id = get_next_core_id(mappings_dir, args.algorithm)

update_rule_yaml(core_id, rule_path)

new_path = PUBLISHED_DIR / core_id
Path(rule_dir).rename(new_path)
print(f"[OK] {rule_dir} -> {new_path} ({core_id})")


if __name__ == "__main__":
main()
127 changes: 127 additions & 0 deletions .github/scripts/validate_yaml_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
validate_yaml_schema.py — Validates one or more rule YAML files against the
CDISC CORE JSON Schema (draft/2020-12).

Usage:
python validate_yaml_schema.py <schema_url_or_path> <rule_file> [<rule_file> ...]

Exit codes:
0 — all files are valid
1 — one or more files failed validation or an unexpected error occurred
"""

import json
import sys
import urllib.request
from pathlib import Path

import yaml

try:
import jsonschema
from jsonschema import Draft202012Validator, ValidationError
except ImportError:
print("ERROR: 'jsonschema' package is not installed. Run: pip install 'jsonschema[format-nongpl]'")
sys.exit(1)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def load_schema(source: str) -> dict:
"""Load JSON schema from a URL or local file path."""
if source.startswith("http://") or source.startswith("https://"):
with urllib.request.urlopen(source, timeout=30) as resp: # noqa: S310
return json.loads(resp.read())
return json.loads(Path(source).read_text(encoding="utf-8"))


def validate_file(path: Path, validator: Draft202012Validator) -> list[str]:
"""
Validate a YAML file against the schema.
Returns a list of human-readable error strings (empty == valid).
"""
try:
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
return [f"YAML parse error: {exc}"]

if doc is None:
return ["File is empty or contains only comments."]

errors = sorted(validator.iter_errors(doc), key=lambda e: list(e.path))
return [f" [{' > '.join(str(p) for p in err.path) or '/'}] {err.message}" for err in errors]


def github_annotation(level: str, file: str, msg: str) -> str:
"""Produce a GitHub Actions workflow command annotation."""
# Escape special characters per GHA spec
msg = msg.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
return f"::{level} file={file}::{msg}"


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> int:
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <schema_url_or_path> <rule_file> [<rule_file> ...]")
return 1

schema_source = sys.argv[1]
rule_files = [Path(p) for p in sys.argv[2:]]

# Load schema
print(f"Loading schema from: {schema_source}")
try:
schema = load_schema(schema_source)
except Exception as exc:
print(f"ERROR: Failed to load schema — {exc}")
return 1

validator = Draft202012Validator(schema)

total = 0
failed = 0

report_lines: list[str] = []

for rule_path in rule_files:
if not rule_path.exists():
print(f"WARNING: File not found — {rule_path}")
continue

total += 1
errors = validate_file(rule_path, validator)

if errors:
failed += 1
print(github_annotation("error", str(rule_path), f"Schema validation failed ({len(errors)} error(s))"))
print(f"❌ {rule_path}")
for err in errors:
print(err)
report_lines.append(f"### ❌ `{rule_path}`\n")
report_lines.append("```\n" + "\n".join(errors) + "\n```\n")
else:
print(f"✅ {rule_path}")
report_lines.append(f"### ✅ `{rule_path}`\n")

# Write markdown report (consumed by the workflow)
report_path = Path("schema_validation_report.md")
with report_path.open("w", encoding="utf-8") as fh:
fh.write("# Schema Validation Report\n\n")
fh.write(f"**Schema:** `{schema_source}`\n\n")
fh.write(f"**Files checked:** {total} | **Failed:** {failed}\n\n")
fh.writelines(report_lines)

print(f"\nSummary: {total - failed}/{total} file(s) passed schema validation.")

return 1 if failed else 0


if __name__ == "__main__":
sys.exit(main())

Loading
Loading