mirror of
https://github.com/XRPLF/rippled.git
synced 2026-06-03 08:46:46 +00:00
github workflows
This commit is contained in:
29
.github/doc-coverage-thresholds.json
vendored
Normal file
29
.github/doc-coverage-thresholds.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"global_minimum": 0,
|
||||
"ratchet_mode": "no_decrease",
|
||||
"new_file_minimum": 80,
|
||||
"module_thresholds": {
|
||||
"include/xrpl/basics/": 0,
|
||||
"include/xrpl/crypto/": 0,
|
||||
"include/xrpl/protocol/": 0,
|
||||
"include/xrpl/ledger/": 0,
|
||||
"include/xrpl/tx/": 0,
|
||||
"include/xrpl/server/": 0,
|
||||
"include/xrpl/nodestore/": 0,
|
||||
"include/xrpl/shamap/": 0,
|
||||
"include/xrpl/resource/": 0,
|
||||
"src/xrpld/rpc/": 0,
|
||||
"src/xrpld/overlay/": 0,
|
||||
"src/xrpld/peerfinder/": 0,
|
||||
"src/xrpld/consensus/": 0,
|
||||
"src/xrpld/app/": 0,
|
||||
"src/libxrpl/": 0
|
||||
},
|
||||
"schedule": {
|
||||
"2026-Q3": { "global_minimum": 30 },
|
||||
"2026-Q4": { "global_minimum": 40 },
|
||||
"2027-Q1": { "global_minimum": 50 },
|
||||
"2027-Q2": { "global_minimum": 60 },
|
||||
"2027-Q3": { "global_minimum": 70 }
|
||||
}
|
||||
}
|
||||
277
.github/scripts/doc-coverage-check.py
vendored
Normal file
277
.github/scripts/doc-coverage-check.py
vendored
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Documentation coverage checker for xrpld.
|
||||
|
||||
Parses coverxygen LCOV output, compares against per-module thresholds
|
||||
defined in .github/doc-coverage-thresholds.json, and generates a
|
||||
markdown report suitable for posting as a PR comment.
|
||||
|
||||
Usage:
|
||||
python3 doc-coverage-check.py \
|
||||
--lcov-file doc-coverage.info \
|
||||
--threshold-file .github/doc-coverage-thresholds.json \
|
||||
--output doc-coverage-report.md \
|
||||
[--base-lcov-file base-doc-coverage.info]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_lcov(lcov_path: str) -> dict[str, dict[str, int]]:
|
||||
"""Parse LCOV-format file into per-file coverage data.
|
||||
|
||||
Returns a dict mapping file paths to {"documented": N, "total": N}.
|
||||
"""
|
||||
coverage = {}
|
||||
current_file = None
|
||||
documented = 0
|
||||
total = 0
|
||||
|
||||
with open(lcov_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith("SF:"):
|
||||
current_file = line[3:]
|
||||
documented = 0
|
||||
total = 0
|
||||
elif line.startswith("DA:"):
|
||||
parts = line[3:].split(",")
|
||||
if len(parts) >= 2:
|
||||
total += 1
|
||||
if int(parts[1]) > 0:
|
||||
documented += 1
|
||||
elif line == "end_of_record":
|
||||
if current_file:
|
||||
coverage[current_file] = {
|
||||
"documented": documented,
|
||||
"total": total,
|
||||
}
|
||||
current_file = None
|
||||
|
||||
return coverage
|
||||
|
||||
|
||||
def compute_module_coverage(
|
||||
coverage: dict[str, dict[str, int]],
|
||||
module_prefixes: list[str],
|
||||
) -> dict[str, dict[str, int | float]]:
|
||||
"""Aggregate file-level coverage into module-level stats."""
|
||||
modules = {}
|
||||
for prefix in module_prefixes:
|
||||
doc = 0
|
||||
tot = 0
|
||||
for filepath, stats in coverage.items():
|
||||
if filepath.startswith(prefix) or f"/{prefix}" in filepath:
|
||||
doc += stats["documented"]
|
||||
tot += stats["total"]
|
||||
pct = (doc / tot * 100) if tot > 0 else 0.0
|
||||
modules[prefix] = {"documented": doc, "total": tot, "percent": round(pct, 1)}
|
||||
return modules
|
||||
|
||||
|
||||
def compute_global_coverage(
|
||||
coverage: dict[str, dict[str, int]],
|
||||
) -> dict[str, int | float]:
|
||||
"""Compute overall coverage across all files."""
|
||||
doc = sum(s["documented"] for s in coverage.values())
|
||||
tot = sum(s["total"] for s in coverage.values())
|
||||
pct = (doc / tot * 100) if tot > 0 else 0.0
|
||||
return {"documented": doc, "total": tot, "percent": round(pct, 1)}
|
||||
|
||||
|
||||
def check_ratchet(
|
||||
current: dict[str, dict[str, int | float]],
|
||||
base: dict[str, dict[str, int | float]] | None,
|
||||
current_global: dict[str, int | float],
|
||||
base_global: dict[str, int | float] | None,
|
||||
) -> list[str]:
|
||||
"""Check that no module or global coverage decreased vs base branch."""
|
||||
violations = []
|
||||
|
||||
if base_global and current_global["percent"] < base_global["percent"]:
|
||||
violations.append(
|
||||
f"Global coverage decreased: {base_global['percent']}% -> "
|
||||
f"{current_global['percent']}%"
|
||||
)
|
||||
|
||||
if base:
|
||||
for module, stats in current.items():
|
||||
if module in base and stats["percent"] < base[module]["percent"]:
|
||||
violations.append(
|
||||
f"`{module}` coverage decreased: "
|
||||
f"{base[module]['percent']}% -> {stats['percent']}%"
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def check_new_files(
|
||||
coverage: dict[str, dict[str, int]],
|
||||
new_files: list[str],
|
||||
min_coverage: int,
|
||||
) -> list[str]:
|
||||
"""Check that new files meet minimum documentation coverage."""
|
||||
violations = []
|
||||
for filepath in new_files:
|
||||
for covered_path, stats in coverage.items():
|
||||
if filepath in covered_path or covered_path.endswith(filepath):
|
||||
if stats["total"] > 0:
|
||||
pct = stats["documented"] / stats["total"] * 100
|
||||
if pct < min_coverage:
|
||||
violations.append(
|
||||
f"`{filepath}` has {pct:.0f}% doc coverage "
|
||||
f"(minimum {min_coverage}%)"
|
||||
)
|
||||
break
|
||||
return violations
|
||||
|
||||
|
||||
def coverage_emoji(pct: float) -> str:
|
||||
if pct >= 80:
|
||||
return "+"
|
||||
if pct >= 50:
|
||||
return "~"
|
||||
return "-"
|
||||
|
||||
|
||||
def generate_report(
|
||||
global_stats: dict[str, int | float],
|
||||
module_stats: dict[str, dict[str, int | float]],
|
||||
thresholds: dict,
|
||||
violations: list[str],
|
||||
new_file_violations: list[str],
|
||||
) -> str:
|
||||
"""Generate a markdown report for the PR comment."""
|
||||
lines = []
|
||||
lines.append("## Documentation Coverage Report")
|
||||
lines.append("")
|
||||
|
||||
passed = not violations and not new_file_violations
|
||||
status = "PASSED" if passed else "FAILED"
|
||||
lines.append(f"**Status:** {status}")
|
||||
lines.append(
|
||||
f"**Global Coverage:** {global_stats['percent']}% "
|
||||
f"({global_stats['documented']}/{global_stats['total']} entities documented)"
|
||||
)
|
||||
lines.append(
|
||||
f"**Minimum Threshold:** {thresholds.get('global_minimum', 0)}%"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if violations or new_file_violations:
|
||||
lines.append("### Violations")
|
||||
lines.append("")
|
||||
for v in violations + new_file_violations:
|
||||
lines.append(f"- {v}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Module Coverage")
|
||||
lines.append("")
|
||||
lines.append("| Module | Coverage | Documented | Total | Threshold |")
|
||||
lines.append("|--------|----------|------------|-------|-----------|")
|
||||
|
||||
module_thresholds = thresholds.get("module_thresholds", {})
|
||||
for module in sorted(module_stats.keys()):
|
||||
stats = module_stats[module]
|
||||
threshold = module_thresholds.get(module, 0)
|
||||
emoji = coverage_emoji(stats["percent"])
|
||||
lines.append(
|
||||
f"| `{module}` | {stats['percent']}% | "
|
||||
f"{stats['documented']} | {stats['total']} | {threshold}% |"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"*Coverage measured by [coverxygen](https://github.com/psycofdj/coverxygen). "
|
||||
"See [docs/DOCUMENTATION_STANDARDS.md](../docs/DOCUMENTATION_STANDARDS.md) "
|
||||
"for documentation guidelines.*"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Check documentation coverage")
|
||||
parser.add_argument("--lcov-file", required=True, help="Path to LCOV coverage file")
|
||||
parser.add_argument(
|
||||
"--threshold-file", required=True, help="Path to thresholds JSON"
|
||||
)
|
||||
parser.add_argument("--output", required=True, help="Path to write markdown report")
|
||||
parser.add_argument(
|
||||
"--base-lcov-file", default=None, help="Path to base branch LCOV file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--new-files",
|
||||
default="",
|
||||
help="Comma-separated list of new C++ files in this PR",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.threshold_file) as f:
|
||||
thresholds = json.load(f)
|
||||
|
||||
coverage = parse_lcov(args.lcov_file)
|
||||
module_prefixes = list(thresholds.get("module_thresholds", {}).keys())
|
||||
module_stats = compute_module_coverage(coverage, module_prefixes)
|
||||
global_stats = compute_global_coverage(coverage)
|
||||
|
||||
base_coverage = None
|
||||
base_module_stats = None
|
||||
base_global_stats = None
|
||||
if args.base_lcov_file and Path(args.base_lcov_file).exists():
|
||||
base_coverage = parse_lcov(args.base_lcov_file)
|
||||
base_module_stats = compute_module_coverage(base_coverage, module_prefixes)
|
||||
base_global_stats = compute_global_coverage(base_coverage)
|
||||
|
||||
violations = []
|
||||
|
||||
if global_stats["percent"] < thresholds.get("global_minimum", 0):
|
||||
violations.append(
|
||||
f"Global coverage {global_stats['percent']}% is below minimum "
|
||||
f"{thresholds['global_minimum']}%"
|
||||
)
|
||||
|
||||
for module, threshold in thresholds.get("module_thresholds", {}).items():
|
||||
if module in module_stats and module_stats[module]["percent"] < threshold:
|
||||
violations.append(
|
||||
f"`{module}` coverage {module_stats[module]['percent']}% is below "
|
||||
f"threshold {threshold}%"
|
||||
)
|
||||
|
||||
if thresholds.get("ratchet_mode") == "no_decrease":
|
||||
violations.extend(
|
||||
check_ratchet(
|
||||
module_stats, base_module_stats, global_stats, base_global_stats
|
||||
)
|
||||
)
|
||||
|
||||
new_file_violations = []
|
||||
if args.new_files:
|
||||
new_files = [f.strip() for f in args.new_files.split(",") if f.strip()]
|
||||
new_file_min = thresholds.get("new_file_minimum", 80)
|
||||
new_file_violations = check_new_files(coverage, new_files, new_file_min)
|
||||
|
||||
report = generate_report(
|
||||
global_stats, module_stats, thresholds, violations, new_file_violations
|
||||
)
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
f.write(report)
|
||||
|
||||
print(report)
|
||||
|
||||
if violations or new_file_violations:
|
||||
print(f"\nFAILED: {len(violations) + len(new_file_violations)} violation(s)")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\nPASSED: All coverage thresholds met")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
279
.github/scripts/doc-review.py
vendored
Normal file
279
.github/scripts/doc-review.py
vendored
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diff-aware documentation review for xrpld PRs.
|
||||
|
||||
For each changed C++ file, extracts the diff hunks and existing doc
|
||||
comments, then asks the Anthropic API whether documentation needs
|
||||
updating. Produces:
|
||||
- doc-review-report.md: summary comment for the PR
|
||||
- doc-review-comments.json: inline review comments with file/line info
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
print("ERROR: anthropic package not installed. Run: pip install anthropic")
|
||||
sys.exit(1)
|
||||
|
||||
MODEL = "claude-sonnet-4-6"
|
||||
MAX_TOKENS = 2048
|
||||
|
||||
SYSTEM_PROMPT = """You are a documentation reviewer for the xrpld (XRP Ledger daemon) C++ codebase.
|
||||
|
||||
Your job is to review code changes and determine whether existing documentation
|
||||
comments need updating, or whether new documentation is needed.
|
||||
|
||||
Documentation style: Javadoc-style Doxygen comments (/** ... */).
|
||||
See the project's docs/DOCUMENTATION_STANDARDS.md for full guidelines.
|
||||
|
||||
Rules:
|
||||
- Only flag REAL semantic drift: changed behavior, new parameters, removed
|
||||
functionality, changed return values, new error conditions.
|
||||
- Do NOT flag cosmetic changes (whitespace, formatting, variable renames that
|
||||
don't change semantics).
|
||||
- Do NOT suggest docs for private implementation details unless the logic is
|
||||
genuinely non-obvious.
|
||||
- Do NOT paraphrase function signatures. Good docs explain WHY and WHAT
|
||||
BEHAVIOR, not WHAT THE CODE LITERALLY DOES.
|
||||
- Be terse. Each finding should be 1-3 sentences.
|
||||
|
||||
For each issue found, respond with a JSON array of objects:
|
||||
{
|
||||
"issues": [
|
||||
{
|
||||
"file": "path/to/file.h",
|
||||
"line": 42,
|
||||
"severity": "warning" | "suggestion",
|
||||
"message": "Brief description of the doc issue",
|
||||
"suggested_doc": "Optional: suggested doc comment text"
|
||||
}
|
||||
],
|
||||
"summary": "One-paragraph summary of documentation state for this file"
|
||||
}
|
||||
|
||||
If no issues are found, return: {"issues": [], "summary": "Documentation is up to date."}
|
||||
Respond ONLY with valid JSON. No markdown fences, no explanation outside JSON."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileAnalysis:
|
||||
path: str
|
||||
diff: str
|
||||
existing_docs: str
|
||||
file_content: str
|
||||
|
||||
|
||||
def get_diff(base_sha: str, head_sha: str, filepath: str) -> str:
|
||||
"""Get the unified diff for a specific file between two commits."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", f"{base_sha}...{head_sha}", "--", filepath],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
except subprocess.CalledProcessError:
|
||||
return ""
|
||||
|
||||
|
||||
def extract_doc_comments(content: str) -> str:
|
||||
"""Extract all /** ... */ doc comments from file content."""
|
||||
pattern = r'/\*\*[\s\S]*?\*/'
|
||||
matches = re.findall(pattern, content)
|
||||
return "\n\n".join(matches) if matches else "(no documentation comments found)"
|
||||
|
||||
|
||||
def read_file_safe(filepath: str) -> str:
|
||||
"""Read a file, returning empty string if it doesn't exist."""
|
||||
try:
|
||||
return Path(filepath).read_text(encoding="utf-8", errors="replace")
|
||||
except (FileNotFoundError, PermissionError):
|
||||
return ""
|
||||
|
||||
|
||||
def analyze_file(client: anthropic.Anthropic, analysis: FileAnalysis) -> dict:
|
||||
"""Send a file's diff and docs to the API for review."""
|
||||
user_prompt = f"""Review the following code change for documentation accuracy.
|
||||
|
||||
## File: {analysis.path}
|
||||
|
||||
## Git Diff:
|
||||
```
|
||||
{analysis.diff[:8000]}
|
||||
```
|
||||
|
||||
## Existing Documentation Comments:
|
||||
```
|
||||
{analysis.existing_docs[:4000]}
|
||||
```
|
||||
|
||||
## Current File Content (first 200 lines for context):
|
||||
```cpp
|
||||
{chr(10).join(analysis.file_content.split(chr(10))[:200])}
|
||||
```
|
||||
|
||||
Analyze whether the diff introduces changes that make existing docs inaccurate,
|
||||
or adds new public API surface that lacks documentation."""
|
||||
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
max_tokens=MAX_TOKENS,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
text = response.content[0].text.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r'^```\w*\n?', '', text)
|
||||
text = re.sub(r'\n?```$', '', text)
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
return {
|
||||
"issues": [],
|
||||
"summary": f"Analysis failed: {str(e)[:200]}",
|
||||
}
|
||||
|
||||
|
||||
def generate_report(
|
||||
results: dict[str, dict],
|
||||
changed_files: list[str],
|
||||
) -> str:
|
||||
"""Generate the markdown summary report."""
|
||||
lines = ["## Documentation Review Report", ""]
|
||||
|
||||
total_issues = sum(len(r.get("issues", [])) for r in results.values())
|
||||
warnings = sum(
|
||||
1
|
||||
for r in results.values()
|
||||
for i in r.get("issues", [])
|
||||
if i.get("severity") == "warning"
|
||||
)
|
||||
suggestions = total_issues - warnings
|
||||
|
||||
if total_issues == 0:
|
||||
lines.append("No documentation issues found.")
|
||||
else:
|
||||
lines.append(
|
||||
f"Found **{total_issues}** documentation issue(s) "
|
||||
f"across **{len(changed_files)}** changed file(s): "
|
||||
f"{warnings} warning(s), {suggestions} suggestion(s)."
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Files reviewed: {len(changed_files)}")
|
||||
lines.append("")
|
||||
|
||||
for filepath, result in sorted(results.items()):
|
||||
issues = result.get("issues", [])
|
||||
summary = result.get("summary", "")
|
||||
if issues:
|
||||
lines.append(f"### `{filepath}`")
|
||||
lines.append("")
|
||||
lines.append(summary)
|
||||
lines.append("")
|
||||
for issue in issues:
|
||||
severity = issue.get("severity", "suggestion")
|
||||
icon = "**Warning:**" if severity == "warning" else "**Suggestion:**"
|
||||
line_num = issue.get("line", "?")
|
||||
msg = issue.get("message", "")
|
||||
lines.append(f"- {icon} Line {line_num}: {msg}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append(
|
||||
"*Automated documentation review. "
|
||||
"See [docs/DOCUMENTATION_STANDARDS.md](../docs/DOCUMENTATION_STANDARDS.md) "
|
||||
"for guidelines.*"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_inline_comments(results: dict[str, dict]) -> list[dict]:
|
||||
"""Generate inline PR review comments from analysis results."""
|
||||
comments = []
|
||||
for filepath, result in results.items():
|
||||
for issue in result.get("issues", []):
|
||||
line = issue.get("line")
|
||||
if not line or not isinstance(line, int):
|
||||
continue
|
||||
|
||||
body = issue.get("message", "")
|
||||
suggested = issue.get("suggested_doc")
|
||||
if suggested:
|
||||
body += f"\n\n**Suggested documentation:**\n```cpp\n{suggested}\n```"
|
||||
|
||||
severity = issue.get("severity", "suggestion")
|
||||
prefix = "Doc Warning" if severity == "warning" else "Doc Suggestion"
|
||||
body = f"**{prefix}:** {body}"
|
||||
|
||||
comments.append({"path": filepath, "line": line, "body": body})
|
||||
|
||||
return comments
|
||||
|
||||
|
||||
def main():
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
print("ERROR: ANTHROPIC_API_KEY not set")
|
||||
sys.exit(1)
|
||||
|
||||
changed_files_str = os.environ.get("CHANGED_FILES", "")
|
||||
if not changed_files_str:
|
||||
print("No changed files to review")
|
||||
sys.exit(0)
|
||||
|
||||
base_sha = os.environ.get("BASE_SHA", "HEAD~1")
|
||||
head_sha = os.environ.get("HEAD_SHA", "HEAD")
|
||||
|
||||
changed_files = [f.strip() for f in changed_files_str.split() if f.strip()]
|
||||
cpp_files = [
|
||||
f for f in changed_files if f.endswith((".h", ".hpp", ".cpp"))
|
||||
]
|
||||
|
||||
if not cpp_files:
|
||||
print("No C++ files changed")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Reviewing {len(cpp_files)} file(s) for documentation accuracy...")
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
results = {}
|
||||
|
||||
for filepath in cpp_files:
|
||||
print(f" Analyzing: {filepath}")
|
||||
diff = get_diff(base_sha, head_sha, filepath)
|
||||
if not diff:
|
||||
continue
|
||||
|
||||
content = read_file_safe(filepath)
|
||||
existing_docs = extract_doc_comments(content)
|
||||
|
||||
analysis = FileAnalysis(
|
||||
path=filepath,
|
||||
diff=diff,
|
||||
existing_docs=existing_docs,
|
||||
file_content=content,
|
||||
)
|
||||
results[filepath] = analyze_file(client, analysis)
|
||||
|
||||
report = generate_report(results, cpp_files)
|
||||
Path("doc-review-report.md").write_text(report)
|
||||
print("\nReport written to doc-review-report.md")
|
||||
|
||||
comments = generate_inline_comments(results)
|
||||
Path("doc-review-comments.json").write_text(json.dumps(comments, indent=2))
|
||||
print(f"Generated {len(comments)} inline comment(s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
109
.github/workflows/doc-coverage.yml
vendored
Normal file
109
.github/workflows/doc-coverage.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
name: Documentation Coverage
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'include/**'
|
||||
- 'src/libxrpl/**'
|
||||
- 'src/xrpld/**'
|
||||
- 'docs/Doxyfile'
|
||||
- '.github/doc-coverage-thresholds.json'
|
||||
- '.github/workflows/doc-coverage.yml'
|
||||
|
||||
concurrency:
|
||||
group: doc-coverage-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/xrplf/ci/tools-rippled-documentation:sha-a8c7be1
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install coverxygen
|
||||
run: pip install coverxygen
|
||||
|
||||
- name: Determine new C++ files
|
||||
id: new-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
include/**/*.h
|
||||
src/**/*.h
|
||||
src/**/*.cpp
|
||||
since_last_remote_commit: false
|
||||
|
||||
- name: Build Doxygen XML (PR branch)
|
||||
env:
|
||||
BUILD_DIR: build-pr
|
||||
run: |
|
||||
mkdir -p "${BUILD_DIR}"
|
||||
cd "${BUILD_DIR}"
|
||||
cmake -Donly_docs=ON ..
|
||||
cmake --build . --target docs
|
||||
|
||||
- name: Generate coverage report (PR branch)
|
||||
run: |
|
||||
coverxygen \
|
||||
--xml-dir build-pr/docs/xml \
|
||||
--src-dir . \
|
||||
--output doc-coverage.info \
|
||||
--kind class,struct,function,enum,typedef,variable \
|
||||
--scope public
|
||||
|
||||
- name: Build Doxygen XML (base branch)
|
||||
env:
|
||||
BUILD_DIR: build-base
|
||||
run: |
|
||||
git checkout ${{ github.event.pull_request.base.sha }}
|
||||
mkdir -p "${BUILD_DIR}"
|
||||
cd "${BUILD_DIR}"
|
||||
cmake -Donly_docs=ON ..
|
||||
cmake --build . --target docs || true
|
||||
git checkout ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Generate coverage report (base branch)
|
||||
run: |
|
||||
if [ -d "build-base/docs/xml" ]; then
|
||||
coverxygen \
|
||||
--xml-dir build-base/docs/xml \
|
||||
--src-dir . \
|
||||
--output base-doc-coverage.info \
|
||||
--kind class,struct,function,enum,typedef,variable \
|
||||
--scope public || true
|
||||
fi
|
||||
|
||||
- name: Check coverage thresholds
|
||||
run: |
|
||||
BASE_FLAG=""
|
||||
if [ -f "base-doc-coverage.info" ]; then
|
||||
BASE_FLAG="--base-lcov-file base-doc-coverage.info"
|
||||
fi
|
||||
|
||||
NEW_FILES=""
|
||||
if [ -n "${{ steps.new-files.outputs.added_files }}" ]; then
|
||||
NEW_FILES="--new-files ${{ steps.new-files.outputs.added_files }}"
|
||||
fi
|
||||
|
||||
python3 .github/scripts/doc-coverage-check.py \
|
||||
--lcov-file doc-coverage.info \
|
||||
--threshold-file .github/doc-coverage-thresholds.json \
|
||||
--output doc-coverage-report.md \
|
||||
${BASE_FLAG} \
|
||||
${NEW_FILES} || true
|
||||
|
||||
- name: Post coverage report to PR
|
||||
if: always()
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
with:
|
||||
header: doc-coverage
|
||||
path: doc-coverage-report.md
|
||||
98
.github/workflows/doc-review.yml
vendored
Normal file
98
.github/workflows/doc-review.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
name: Documentation Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'include/**/*.h'
|
||||
- 'src/libxrpl/**/*.h'
|
||||
- 'src/libxrpl/**/*.cpp'
|
||||
- 'src/xrpld/**/*.h'
|
||||
- 'src/xrpld/**/*.cpp'
|
||||
|
||||
concurrency:
|
||||
group: doc-review-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install anthropic
|
||||
|
||||
- name: Determine changed C++ files
|
||||
id: changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
include/**/*.h
|
||||
src/libxrpl/**/*.h
|
||||
src/libxrpl/**/*.cpp
|
||||
src/xrpld/**/*.h
|
||||
src/xrpld/**/*.cpp
|
||||
|
||||
- name: Run documentation review
|
||||
if: steps.changes.outputs.any_changed == 'true'
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.changes.outputs.all_changed_files }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 .github/scripts/doc-review.py
|
||||
|
||||
- name: Post review summary
|
||||
if: steps.changes.outputs.any_changed == 'true' && always()
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
with:
|
||||
header: doc-review
|
||||
path: doc-review-report.md
|
||||
|
||||
- name: Post inline review comments
|
||||
if: steps.changes.outputs.any_changed == 'true' && always()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync('doc-review-comments.json')) return;
|
||||
|
||||
const comments = JSON.parse(fs.readFileSync('doc-review-comments.json', 'utf8'));
|
||||
if (comments.length === 0) return;
|
||||
|
||||
const pull_number = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
for (const comment of comments) {
|
||||
try {
|
||||
await github.rest.pulls.createReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
body: comment.body,
|
||||
commit_id: '${{ github.event.pull_request.head.sha }}',
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
side: 'RIGHT',
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Failed to post comment on ${comment.path}:${comment.line}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user