goldenChat base source add
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Known Javadoc warnings accepted during release publishing.
|
||||
# Keep this file empty unless a warning is intentionally carried as release debt.
|
||||
ta4j-core/src/main/java/org/ta4j/core/indicators/ChopIndicator.java: warning: unknown tag. Unregistered custom tag?
|
||||
ta4j-core/src/main/java/org/ta4j/core/analysis/CumulativePnL.java: warning: reference not found: org.ta4j.core.PerformanceIndicator
|
||||
ta4j-core/src/main/java/org/ta4j/core/num/DoubleNum.java: warning: unknown tag. Unregistered custom tag?
|
||||
ta4j-core/src/main/java/org/ta4j/core/indicators/elliott/ElliottScenarioSet.java: warning: invalid input: '<'
|
||||
ta4j-core/src/main/java/org/ta4j/core/indicators/FisherIndicator.java: warning: unknown tag. Unregistered custom tag?
|
||||
ta4j-core/src/main/java/org/ta4j/core/criteria/drawdown/ReturnOverMaxDrawdownCriterion.java: warning: reference not found: NetReturnCriterion
|
||||
ta4j-core/src/main/java/org/ta4j/core/criteria/drawdown/ReturnOverMaxDrawdownCriterion.java: warning: reference not found: ReturnRepresentationPolicy
|
||||
ta4j-core/src/main/java/org/ta4j/core/criteria/ReturnRepresentation.java: warning: reference not found: Returns
|
||||
ta4j-examples/src/main/java/ta4jexamples/backtesting/CoinbaseBacktest.java: warning: invalid input: '&'
|
||||
ta4j-examples/src/main/java/ta4jexamples/analysis/elliottwave/ElliottWaveAnalysisReport.java: warning: reference not found: ElliottWaveIndicatorSuiteDemo#logBaseCaseScenario(ElliottScenario)
|
||||
ta4j-examples/src/main/java/ta4jexamples/analysis/elliottwave/ElliottWaveAnalysisReport.java: warning: reference not found: ElliottWaveIndicatorSuiteDemo#logAlternativeScenarios(List)
|
||||
ta4j-examples/src/main/java/ta4jexamples/datasources/json/JsonBarsSerializer.java: warning: reference not found: JsonFileBarSeriesDataSource
|
||||
ta4j-examples/src/main/java/ta4jexamples/datasources/json/JsonBarsSerializer.java: warning: reference not found: JsonFileBarSeriesDataSource#loadSeries(String)
|
||||
@@ -0,0 +1,941 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Utilities shared by ta4j release GitHub Actions workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
|
||||
SECRET_PATTERNS = (
|
||||
(re.compile(r"https?://\S+"), "[REDACTED_URL]"),
|
||||
(re.compile(r"gh[oprsu]_[A-Za-z0-9_]{20,}"), "[REDACTED_TOKEN]"),
|
||||
(
|
||||
re.compile(r"(?<![A-Za-z0-9_])(?=[A-Za-z0-9+/=_-]{32,})(?=.*[0-9])[A-Za-z0-9+/=_-]{32,}"),
|
||||
"[REDACTED_SECRET]",
|
||||
),
|
||||
)
|
||||
|
||||
EXPECTED_RELEASE_ARTIFACTS = (
|
||||
"ta4j-core/target/ta4j-core-{version}.jar",
|
||||
"ta4j-core/target/ta4j-core-{version}-sources.jar",
|
||||
"ta4j-core/target/ta4j-core-{version}-javadoc.jar",
|
||||
"ta4j-core/target/ta4j-core-{version}-tests.jar",
|
||||
"ta4j-examples/target/ta4j-examples-{version}.jar",
|
||||
"ta4j-examples/target/ta4j-examples-{version}-sources.jar",
|
||||
"ta4j-examples/target/ta4j-examples-{version}-javadoc.jar",
|
||||
)
|
||||
|
||||
JAVADOC_PATH_PATTERN = re.compile(r"^.*?(ta4j-(?:core|examples)/src/.+)$")
|
||||
SNAPSHOT_METADATA_URL = "https://central.sonatype.com/repository/maven-snapshots/org/ta4j/ta4j-parent/maven-metadata.xml"
|
||||
SNAPSHOT_WORKFLOW_NAME = "Publish Snapshot to Maven Central"
|
||||
|
||||
|
||||
def redact(value: str) -> str:
|
||||
redacted = value
|
||||
for pattern, replacement in SECRET_PATTERNS:
|
||||
redacted = pattern.sub(replacement, redacted)
|
||||
return redacted
|
||||
|
||||
|
||||
def redact_log_path(value: str) -> str:
|
||||
redacted = value
|
||||
for pattern, replacement in SECRET_PATTERNS[:2]:
|
||||
redacted = pattern.sub(replacement, redacted)
|
||||
return redacted
|
||||
|
||||
|
||||
def run_git(args: list[str], *, check: bool = True) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
check=check,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def write_json(path: pathlib.Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def write_text(path: pathlib.Path, value: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(value, encoding="utf-8")
|
||||
|
||||
|
||||
def append_output(name: str, value: str, output_path: str | None = None) -> None:
|
||||
target = output_path or os.environ.get("GITHUB_OUTPUT")
|
||||
if not target:
|
||||
return
|
||||
with open(target, "a", encoding="utf-8") as handle:
|
||||
if "\n" in value:
|
||||
delimiter = f"EOF_{name}_{os.getpid()}"
|
||||
handle.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||
else:
|
||||
handle.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def load_catalog(args: argparse.Namespace) -> list[dict[str, Any]]:
|
||||
if args.catalog_file:
|
||||
with open(args.catalog_file, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
else:
|
||||
request = urllib.request.Request(
|
||||
args.catalog_url,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "ta4j-release-automation",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=args.timeout_seconds) as response:
|
||||
data = json.load(response)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("model catalog response must be a JSON array")
|
||||
return data
|
||||
|
||||
|
||||
def command_catalog_preflight(args: argparse.Namespace) -> int:
|
||||
model_id = args.model.strip()
|
||||
if not model_id:
|
||||
raise ValueError("--model is required")
|
||||
|
||||
catalog = load_catalog(args)
|
||||
selected = next((entry for entry in catalog if entry.get("id") == model_id), None)
|
||||
if selected is None:
|
||||
available = ", ".join(sorted(str(entry.get("id")) for entry in catalog if entry.get("id")))
|
||||
print(f"::error::Configured RELEASE_AI_MODEL '{model_id}' was not found in the GitHub Models catalog.", file=sys.stderr)
|
||||
print(f"Available models: {available}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
limits = selected.get("limits") if isinstance(selected.get("limits"), dict) else {}
|
||||
max_input_tokens = str(limits.get("max_input_tokens", ""))
|
||||
max_output_tokens = str(limits.get("max_output_tokens", ""))
|
||||
result = {
|
||||
"id": selected.get("id", model_id),
|
||||
"name": selected.get("name", ""),
|
||||
"publisher": selected.get("publisher", ""),
|
||||
"summary": selected.get("summary", ""),
|
||||
"rate_limit_tier": selected.get("rate_limit_tier", ""),
|
||||
"max_input_tokens": max_input_tokens,
|
||||
"max_output_tokens": max_output_tokens,
|
||||
"html_url": selected.get("html_url", ""),
|
||||
}
|
||||
write_json(args.output, result)
|
||||
|
||||
append_output("model_id", str(result["id"]))
|
||||
append_output("model_name", str(result["name"]))
|
||||
append_output("model_summary", str(result["summary"]))
|
||||
append_output("model_rate_limit_tier", str(result["rate_limit_tier"]))
|
||||
append_output("model_max_input_tokens", max_input_tokens)
|
||||
append_output("model_max_output_tokens", max_output_tokens)
|
||||
append_output("model_html_url", str(result["html_url"]))
|
||||
|
||||
print(
|
||||
"audit:model_catalog_preflight "
|
||||
f"model={result['id']} max_input_tokens={max_input_tokens or 'unknown'} "
|
||||
f"max_output_tokens={max_output_tokens or 'unknown'} rate_limit_tier={result['rate_limit_tier'] or 'unknown'}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def changed_files_since(last_tag: str) -> list[str]:
|
||||
if last_tag == "none":
|
||||
output = run_git(["ls-tree", "-r", "--name-only", "HEAD"])
|
||||
else:
|
||||
output = run_git(["diff", "--name-only", f"{last_tag}..HEAD"])
|
||||
return sorted(path for path in output.splitlines() if path)
|
||||
|
||||
|
||||
def category_for(path: str) -> str:
|
||||
if path == "pom.xml" or path.endswith("/pom.xml"):
|
||||
return "build metadata"
|
||||
if "/src/main/" in f"/{path}":
|
||||
return "production code"
|
||||
if "/src/test/" in f"/{path}":
|
||||
return "tests"
|
||||
if path.startswith(".github/workflows/"):
|
||||
return "workflows"
|
||||
if path.startswith("scripts/"):
|
||||
return "release/tooling scripts" if "release" in path or path.startswith("scripts/tests/") else "scripts"
|
||||
if path == "CHANGELOG.md" or path.startswith("release/") or path == "RELEASE_PROCESS.md" or path == "README.md":
|
||||
return "release documentation"
|
||||
return "other"
|
||||
|
||||
|
||||
def extract_unreleased_changelog() -> str:
|
||||
path = pathlib.Path("CHANGELOG.md")
|
||||
if not path.exists():
|
||||
return "(CHANGELOG.md not found)"
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
captured: list[str] = []
|
||||
in_section = False
|
||||
for line in lines:
|
||||
if re.match(r"^##\s+\[?Unreleased\]?", line):
|
||||
in_section = True
|
||||
continue
|
||||
if in_section and line.startswith("## "):
|
||||
break
|
||||
if in_section:
|
||||
captured.append(line)
|
||||
text = "\n".join(captured).strip()
|
||||
return text or "(Unreleased section is empty)"
|
||||
|
||||
|
||||
def collect_diff(last_tag: str, paths: list[str], max_chars: int) -> tuple[str, bool]:
|
||||
if last_tag == "none" or not paths:
|
||||
return "(No prior release tag diff available.)", False
|
||||
|
||||
priority = sorted(
|
||||
paths,
|
||||
key=lambda path: (
|
||||
0 if category_for(path) in {"production code", "build metadata"} else 1,
|
||||
path,
|
||||
),
|
||||
)
|
||||
try:
|
||||
diff = run_git(["diff", "--no-ext-diff", "--find-renames", "--unified=80", f"{last_tag}..HEAD", "--", *priority])
|
||||
except subprocess.CalledProcessError as exc:
|
||||
diff = exc.stdout + "\n" + exc.stderr
|
||||
redacted = redact(diff)
|
||||
if len(redacted) > max_chars:
|
||||
return redacted[:max_chars] + "\n\n[TRUNCATED: selected diff exceeded dossier budget]\n", True
|
||||
return redacted, False
|
||||
|
||||
|
||||
def public_api_signals(diff_text: str) -> list[str]:
|
||||
signals: list[str] = []
|
||||
pattern = re.compile(
|
||||
r"^[+-]\s*(?:public|protected)\s+(?:static\s+|final\s+|abstract\s+|default\s+|sealed\s+|non-sealed\s+)*"
|
||||
r"(?:class|interface|enum|record|@interface|[A-Za-z0-9_<>\[\], ?]+)\s+[A-Za-z0-9_]+",
|
||||
re.MULTILINE,
|
||||
)
|
||||
for match in pattern.finditer(diff_text):
|
||||
line = match.group(0).strip()
|
||||
if line not in signals:
|
||||
signals.append(line)
|
||||
if len(signals) >= 80:
|
||||
signals.append("[TRUNCATED: more public API signal lines omitted]")
|
||||
break
|
||||
return signals
|
||||
|
||||
|
||||
def javadoc_signals(diff_text: str) -> list[str]:
|
||||
signals: list[str] = []
|
||||
for line in diff_text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith(("+", "-")):
|
||||
continue
|
||||
if "@since" in stripped or "/**" in stripped or stripped.startswith(("+ *", "- *")):
|
||||
signals.append(stripped)
|
||||
if len(signals) >= 80:
|
||||
signals.append("[TRUNCATED: more Javadoc signal lines omitted]")
|
||||
break
|
||||
return signals
|
||||
|
||||
|
||||
def command_build_dossier(args: argparse.Namespace) -> int:
|
||||
files = changed_files_since(args.last_tag)
|
||||
categories: dict[str, list[str]] = {}
|
||||
for path in files:
|
||||
categories.setdefault(category_for(path), []).append(path)
|
||||
|
||||
diff_text, diff_truncated = collect_diff(args.last_tag, files, args.max_diff_chars)
|
||||
api_signals = public_api_signals(diff_text)
|
||||
doc_signals = javadoc_signals(diff_text)
|
||||
test_files = [path for path in files if category_for(path) == "tests"]
|
||||
|
||||
generated_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
|
||||
audit = {
|
||||
"generated_at": generated_at,
|
||||
"last_tag": args.last_tag,
|
||||
"current_version": args.current_version,
|
||||
"pom_base": args.pom_base,
|
||||
"changed_file_count": len(files),
|
||||
"category_counts": {key: len(value) for key, value in sorted(categories.items())},
|
||||
"selected_diff_chars": len(diff_text),
|
||||
"selected_diff_truncated": diff_truncated,
|
||||
"public_api_signal_count": len(api_signals),
|
||||
"javadoc_signal_count": len(doc_signals),
|
||||
"test_file_count": len(test_files),
|
||||
}
|
||||
|
||||
sections = [
|
||||
"# ta4j Release Dossier",
|
||||
"",
|
||||
"## Metadata",
|
||||
"",
|
||||
f"- generated_at: {generated_at}",
|
||||
f"- current_version: {args.current_version}",
|
||||
f"- pom_base: {args.pom_base}",
|
||||
f"- last_reachable_tag: {args.last_tag}",
|
||||
f"- changed_file_count: {len(files)}",
|
||||
"",
|
||||
"## Changed Files by Category",
|
||||
"",
|
||||
]
|
||||
for category in sorted(categories):
|
||||
sections.append(f"### {category} ({len(categories[category])})")
|
||||
sections.extend(f"- `{path}`" for path in categories[category])
|
||||
sections.append("")
|
||||
|
||||
sections.extend(
|
||||
[
|
||||
"## Unreleased Changelog Context",
|
||||
"",
|
||||
"```markdown",
|
||||
redact(extract_unreleased_changelog()),
|
||||
"```",
|
||||
"",
|
||||
"## Public API Signals",
|
||||
"",
|
||||
]
|
||||
)
|
||||
sections.extend(f"- `{line}`" for line in api_signals) if api_signals else sections.append("- (none detected)")
|
||||
sections.extend(["", "## Javadoc and @since Signals", ""])
|
||||
sections.extend(f"- `{line}`" for line in doc_signals) if doc_signals else sections.append("- (none detected)")
|
||||
sections.extend(["", "## Test File Signals", ""])
|
||||
sections.extend(f"- `{path}`" for path in test_files) if test_files else sections.append("- (none detected)")
|
||||
sections.extend(
|
||||
[
|
||||
"",
|
||||
"## Selected Diff",
|
||||
"",
|
||||
"```diff",
|
||||
diff_text,
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
write_text(args.output, "\n".join(sections))
|
||||
write_json(args.audit_output, audit)
|
||||
print(
|
||||
"audit:release_dossier "
|
||||
f"file={args.output} changed_files={len(files)} selected_diff_chars={len(diff_text)} "
|
||||
f"selected_diff_truncated={'true' if diff_truncated else 'false'}"
|
||||
)
|
||||
append_output("dossier_path", str(args.output))
|
||||
append_output("audit_path", str(args.audit_output))
|
||||
append_output("changed_file_count", str(len(files)))
|
||||
append_output("selected_diff_chars", str(len(diff_text)))
|
||||
append_output("selected_diff_truncated", "true" if diff_truncated else "false")
|
||||
return 0
|
||||
|
||||
|
||||
def command_build_ai_request(args: argparse.Namespace) -> int:
|
||||
dossier = args.dossier.read_text(encoding="utf-8")
|
||||
if len(dossier) > args.max_dossier_chars:
|
||||
dossier = dossier[: args.max_dossier_chars] + "\n\n[TRUNCATED: dossier exceeded request budget]\n"
|
||||
|
||||
if args.semver_rules.exists() and args.semver_rules.stat().st_size > 0:
|
||||
semver_rules = args.semver_rules.read_text(encoding="utf-8")
|
||||
rules_source = str(args.semver_rules)
|
||||
else:
|
||||
semver_rules = textwrap.dedent(
|
||||
"""\
|
||||
Decide release go/no-go from unreleased binary-impacting changes.
|
||||
If go, choose bump: patch|minor.
|
||||
MINOR: backward-compatible, user-visible new features, and breaking-change cases.
|
||||
PATCH: backward-compatible bug fixes or internal improvements.
|
||||
If binary change count is 0 => should_release=false.
|
||||
If binary change list empty or changelog-only => should_release=false.
|
||||
If unsure between MINOR and PATCH, prefer PATCH.
|
||||
Pre-1.0.0: breaking changes can be MINOR.
|
||||
"""
|
||||
).strip()
|
||||
rules_source = "default"
|
||||
|
||||
request = {
|
||||
"model": args.model,
|
||||
"temperature": 0,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a SemVer release reviewer for a Java library. "
|
||||
"Return JSON only. Base every conclusion on the release dossier."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Decide whether ta4j should cut a release from this dossier. "
|
||||
"If yes, choose bump patch or minor. Major is disabled for this workflow.\n\n"
|
||||
"SemVer rules:\n"
|
||||
f"{semver_rules}\n\n"
|
||||
"Return JSON only with this shape:\n"
|
||||
"{"
|
||||
"\"should_release\": true|false, "
|
||||
"\"bump\": \"patch|minor\", "
|
||||
"\"confidence\": 0.0-1.0, "
|
||||
"\"reason\": \"1-2 sentences\", "
|
||||
"\"evidence\": [\"specific dossier facts\"], "
|
||||
"\"risks\": [\"release risks or empty array\"], "
|
||||
"\"missing\": [\"missing changelog/javadoc/test evidence or empty array\"]"
|
||||
"}.\n\n"
|
||||
f"{dossier}"
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
write_json(args.output, request)
|
||||
request_size = args.output.stat().st_size
|
||||
print(
|
||||
"audit:ai_request "
|
||||
f"file={args.output} model={args.model} semver_rules_source={rules_source} request_json_size_bytes={request_size}"
|
||||
)
|
||||
append_output("request_json_size_bytes", str(request_size))
|
||||
append_output("dossier_chars", str(len(dossier)))
|
||||
return 0
|
||||
|
||||
|
||||
def parse_json_object(raw: str) -> dict[str, Any] | None:
|
||||
candidate = raw.strip()
|
||||
if candidate.startswith("```"):
|
||||
candidate = re.sub(r"^```(?:json)?\s*", "", candidate)
|
||||
candidate = re.sub(r"\s*```$", "", candidate)
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
start = candidate.find("{")
|
||||
end = candidate.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
parsed = json.loads(candidate[start : end + 1])
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def parse_release_flag(value: Any) -> tuple[bool, str]:
|
||||
if isinstance(value, bool):
|
||||
return value, ""
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
if value == 1:
|
||||
return True, ""
|
||||
if value == 0:
|
||||
return False, ""
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"true", "1", "yes", "y", "on"}:
|
||||
return True, ""
|
||||
if normalized in {"false", "0", "no", "n", "off", ""}:
|
||||
return False, ""
|
||||
return False, f"invalid should_release '{value}', defaulted to false"
|
||||
|
||||
|
||||
def normalize_decision(parsed: dict[str, Any] | None, raw: str) -> dict[str, Any]:
|
||||
if parsed is None:
|
||||
hint = raw[:200].replace("\n", " ").strip()
|
||||
reason = "AI response was not valid JSON"
|
||||
if hint:
|
||||
reason = f"{reason}: {hint}"
|
||||
return {
|
||||
"should_release": False,
|
||||
"bump": "patch",
|
||||
"confidence": 0.0,
|
||||
"warning": "Invalid AI JSON",
|
||||
"reason": reason,
|
||||
"evidence": [],
|
||||
"risks": ["AI response could not be parsed"],
|
||||
"missing": [],
|
||||
}
|
||||
|
||||
should_release, flag_warning = parse_release_flag(parsed.get("should_release", False))
|
||||
bump = str(parsed.get("bump") or "patch").strip().lower()
|
||||
warning = str(parsed.get("warning") or "")
|
||||
if flag_warning:
|
||||
warning = (warning + "; " if warning else "") + flag_warning
|
||||
if bump == "major":
|
||||
bump = "minor"
|
||||
warning = (warning + "; " if warning else "") + "major bump disabled, downgraded to minor"
|
||||
if bump not in {"patch", "minor"}:
|
||||
warning = (warning + "; " if warning else "") + f"invalid bump '{bump}', defaulted to patch"
|
||||
bump = "patch"
|
||||
if not should_release:
|
||||
bump = "patch"
|
||||
|
||||
confidence_raw = parsed.get("confidence", 0.0)
|
||||
try:
|
||||
confidence = float(confidence_raw)
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.0
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
def string_list(key: str) -> list[str]:
|
||||
value = parsed.get(key, [])
|
||||
if not isinstance(value, list):
|
||||
return [str(value)]
|
||||
return [str(item) for item in value]
|
||||
|
||||
return {
|
||||
"should_release": should_release,
|
||||
"bump": bump,
|
||||
"confidence": confidence,
|
||||
"warning": warning,
|
||||
"reason": str(parsed.get("reason") or ""),
|
||||
"evidence": string_list("evidence"),
|
||||
"risks": string_list("risks"),
|
||||
"missing": string_list("missing"),
|
||||
}
|
||||
|
||||
|
||||
def command_parse_decision(args: argparse.Namespace) -> int:
|
||||
raw = args.raw_file.read_text(encoding="utf-8") if args.raw_file.exists() else ""
|
||||
decision = normalize_decision(parse_json_object(raw), raw)
|
||||
write_json(args.output, decision)
|
||||
append_output("should_release", "true" if decision["should_release"] else "false", args.github_output)
|
||||
append_output("bump", str(decision["bump"]), args.github_output)
|
||||
append_output("confidence", str(decision["confidence"]), args.github_output)
|
||||
append_output("warning", str(decision["warning"]), args.github_output)
|
||||
append_output("reason", str(decision["reason"]), args.github_output)
|
||||
print(
|
||||
"audit:ai_decision "
|
||||
f"should_release={'true' if decision['should_release'] else 'false'} bump={decision['bump']} "
|
||||
f"confidence={decision['confidence']} output={args.output}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def normalize_javadoc_warning(line: str) -> str | None:
|
||||
value = line.strip()
|
||||
if not value:
|
||||
return None
|
||||
value = re.sub(r"^\[[A-Z]+\]\s*", "", value)
|
||||
if value == "Javadoc Warnings":
|
||||
return None
|
||||
lower = value.lower()
|
||||
|
||||
is_javadoc_warning = (
|
||||
("javadoc" in lower and "warning" in lower)
|
||||
or re.search(r"\bwarning\s*[-:]", lower) is not None
|
||||
)
|
||||
if not is_javadoc_warning:
|
||||
return None
|
||||
|
||||
# Compiler deprecation diagnostics are Maven warnings, not Javadoc debt.
|
||||
if re.search(r"\.java:\[\d+,\d+\]", value):
|
||||
return None
|
||||
|
||||
path_match = JAVADOC_PATH_PATTERN.match(value)
|
||||
if path_match:
|
||||
value = path_match.group(1)
|
||||
value = re.sub(r"(\.java):\d+:\s+warning:", r"\1: warning:", value)
|
||||
return redact_log_path(value)
|
||||
|
||||
|
||||
def unique_preserving_order(values: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for value in values:
|
||||
if value not in seen:
|
||||
seen.add(value)
|
||||
unique.append(value)
|
||||
return unique
|
||||
|
||||
|
||||
def collect_javadoc_warnings(logs: list[pathlib.Path]) -> tuple[list[str], list[str]]:
|
||||
warnings: list[str] = []
|
||||
missing_logs: list[str] = []
|
||||
for log_file in logs:
|
||||
if not log_file.exists():
|
||||
missing_logs.append(str(log_file))
|
||||
continue
|
||||
for line in log_file.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
warning = normalize_javadoc_warning(line)
|
||||
if warning:
|
||||
warnings.append(warning)
|
||||
return unique_preserving_order(warnings), missing_logs
|
||||
|
||||
|
||||
def load_javadoc_baseline(path: pathlib.Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
values: list[str] = []
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
normalized = normalize_javadoc_warning(line)
|
||||
values.append(normalized if normalized else line)
|
||||
return unique_preserving_order(values)
|
||||
|
||||
|
||||
def command_javadoc_warnings(args: argparse.Namespace) -> int:
|
||||
current, missing_logs = collect_javadoc_warnings(args.logs)
|
||||
baseline = load_javadoc_baseline(args.baseline)
|
||||
baseline_set = set(baseline)
|
||||
current_set = set(current)
|
||||
new_warnings = [warning for warning in current if warning not in baseline_set]
|
||||
resolved_warnings = [warning for warning in baseline if warning not in current_set]
|
||||
|
||||
lines = [
|
||||
"# Javadoc Warning Baseline Check",
|
||||
"",
|
||||
f"baseline={args.baseline}",
|
||||
f"current_count={len(current)}",
|
||||
f"baseline_count={len(baseline)}",
|
||||
f"new_count={len(new_warnings)}",
|
||||
f"resolved_count={len(resolved_warnings)}",
|
||||
"",
|
||||
"## Current warnings",
|
||||
*(current if current else ["(none)"]),
|
||||
"",
|
||||
"## New warnings",
|
||||
*(new_warnings if new_warnings else ["(none)"]),
|
||||
"",
|
||||
"## Baseline warnings not seen in this run",
|
||||
*(resolved_warnings if resolved_warnings else ["(none)"]),
|
||||
"",
|
||||
"## Missing logs",
|
||||
*(missing_logs if missing_logs else ["(none)"]),
|
||||
"",
|
||||
]
|
||||
write_text(args.output, "\n".join(lines))
|
||||
append_output("javadoc_warning_count", str(len(current)), args.github_output)
|
||||
append_output("javadoc_warning_baseline_count", str(len(baseline)), args.github_output)
|
||||
append_output("javadoc_warning_new_count", str(len(new_warnings)), args.github_output)
|
||||
append_output("javadoc_warning_resolved_count", str(len(resolved_warnings)), args.github_output)
|
||||
|
||||
print(
|
||||
"audit:javadoc_warnings "
|
||||
f"current={len(current)} baseline={len(baseline)} new={len(new_warnings)} "
|
||||
f"resolved={len(resolved_warnings)} output={args.output}"
|
||||
)
|
||||
for missing_log in missing_logs:
|
||||
print(f"::warning::Javadoc warning log not found: {missing_log}")
|
||||
if new_warnings and args.fail_on_new:
|
||||
for warning in new_warnings:
|
||||
print(f"::error::New Javadoc warning: {warning}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def command_artifact_manifest(args: argparse.Namespace) -> int:
|
||||
version = args.version.strip()
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
|
||||
print(f"::error::Release artifact version must be major.minor.patch: {version}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
expected = [pathlib.Path(template.format(version=version)) for template in EXPECTED_RELEASE_ARTIFACTS]
|
||||
existing_jars = sorted(pathlib.Path(".").glob("*/target/*.jar"))
|
||||
expected_set = {path.as_posix() for path in expected}
|
||||
existing_set = {path.as_posix().removeprefix("./") for path in existing_jars}
|
||||
|
||||
missing = sorted(path.as_posix() for path in expected if not path.exists())
|
||||
unexpected = sorted(path for path in existing_set if path not in expected_set)
|
||||
manifest_lines = [
|
||||
f"version={version}",
|
||||
"",
|
||||
"Expected release artifacts:",
|
||||
*[f"- {path.as_posix()}" for path in expected],
|
||||
"",
|
||||
"Missing release artifacts:",
|
||||
*([f"- {path}" for path in missing] if missing else ["- (none)"]),
|
||||
"",
|
||||
"Unexpected target jars:",
|
||||
*([f"- {path}" for path in unexpected] if unexpected else ["- (none)"]),
|
||||
"",
|
||||
]
|
||||
write_text(args.output, "\n".join(manifest_lines))
|
||||
|
||||
files_output = "\n".join(path.as_posix() for path in expected)
|
||||
append_output("files", files_output, args.github_output)
|
||||
append_output("artifact_manifest", str(args.output), args.github_output)
|
||||
append_output("missing_count", str(len(missing)), args.github_output)
|
||||
append_output("unexpected_count", str(len(unexpected)), args.github_output)
|
||||
|
||||
print(
|
||||
"audit:artifact_manifest "
|
||||
f"version={version} expected={len(expected)} missing={len(missing)} unexpected={len(unexpected)} output={args.output}"
|
||||
)
|
||||
if missing or (unexpected and args.strict):
|
||||
if missing:
|
||||
print(f"::error::Missing release artifacts: {', '.join(missing)}", file=sys.stderr)
|
||||
if unexpected and args.strict:
|
||||
print(f"::error::Unexpected target jars: {', '.join(unexpected)}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def snapshot_publication_result(
|
||||
*,
|
||||
version: str,
|
||||
published: str,
|
||||
latest: str,
|
||||
last_updated: str,
|
||||
source: str,
|
||||
error: str = "",
|
||||
versions: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a stable snapshot-publication payload for workflow consumers."""
|
||||
return {
|
||||
"version": version,
|
||||
"published": published,
|
||||
"latest": latest,
|
||||
"lastUpdated": last_updated,
|
||||
"source": source,
|
||||
"error": error,
|
||||
"versions": versions or [],
|
||||
}
|
||||
|
||||
|
||||
def emit_snapshot_publication_outputs(result: dict[str, Any], github_output: str | None) -> None:
|
||||
"""Expose snapshot-publication fields through the GitHub Actions output contract."""
|
||||
append_output("snapshot_publication", str(result["published"]), github_output)
|
||||
append_output("snapshot_publication_latest", str(result["latest"]), github_output)
|
||||
append_output("snapshot_publication_last_updated", str(result["lastUpdated"]), github_output)
|
||||
append_output("snapshot_publication_source", str(result["source"]), github_output)
|
||||
append_output("snapshot_publication_error", str(result["error"]), github_output)
|
||||
|
||||
|
||||
def snapshot_publication_policy_result(
|
||||
*,
|
||||
event_name: str,
|
||||
workflow_name: str,
|
||||
enforce: bool,
|
||||
pending_reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Describe when release-health should treat snapshot metadata as authoritative."""
|
||||
return {
|
||||
"eventName": event_name,
|
||||
"workflowName": workflow_name,
|
||||
"enforce": enforce,
|
||||
"pendingReason": pending_reason,
|
||||
}
|
||||
|
||||
|
||||
def emit_snapshot_publication_policy_outputs(result: dict[str, Any], github_output: str | None) -> None:
|
||||
"""Expose snapshot-publication policy fields through the GitHub Actions output contract."""
|
||||
append_output("snapshot_publication_enforced", "true" if result["enforce"] else "false", github_output)
|
||||
append_output("snapshot_publication_pending_reason", str(result["pendingReason"]), github_output)
|
||||
|
||||
|
||||
def validate_snapshot_metadata_url(metadata_url: str) -> str:
|
||||
"""Reject non-HTTPS metadata URLs; local fixtures must use --metadata-file instead."""
|
||||
parsed = urlparse(metadata_url.strip())
|
||||
if parsed.scheme.lower() != "https" or not parsed.netloc:
|
||||
raise ValueError("--metadata-url must use https")
|
||||
return metadata_url
|
||||
|
||||
|
||||
def load_snapshot_metadata(args: argparse.Namespace) -> tuple[str, str]:
|
||||
"""Load snapshot metadata XML from a local fixture or the live snapshot repository."""
|
||||
if args.metadata_file:
|
||||
source = args.metadata_file.resolve().as_uri()
|
||||
return args.metadata_file.read_text(encoding="utf-8"), source
|
||||
|
||||
metadata_url = validate_snapshot_metadata_url(args.metadata_url)
|
||||
request = urllib.request.Request(
|
||||
metadata_url,
|
||||
headers={
|
||||
"Accept": "application/xml",
|
||||
"User-Agent": "ta4j-release-automation",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=args.timeout_seconds) as response:
|
||||
return response.read().decode("utf-8"), metadata_url
|
||||
|
||||
|
||||
def parse_snapshot_metadata(metadata_text: str) -> tuple[str, str, list[str]]:
|
||||
"""Extract latest version, timestamp, and published versions from metadata XML."""
|
||||
root = ET.fromstring(metadata_text)
|
||||
versioning = root.find("versioning")
|
||||
if versioning is None:
|
||||
raise ValueError("snapshot metadata is missing the <versioning> section")
|
||||
|
||||
latest = (versioning.findtext("latest") or "").strip()
|
||||
last_updated = (versioning.findtext("lastUpdated") or "").strip()
|
||||
versions_parent = versioning.find("versions")
|
||||
versions = []
|
||||
if versions_parent is not None:
|
||||
versions = [
|
||||
(node.text or "").strip()
|
||||
for node in versions_parent.findall("version")
|
||||
if (node.text or "").strip()
|
||||
]
|
||||
return latest, last_updated, versions
|
||||
|
||||
|
||||
def command_snapshot_publication(args: argparse.Namespace) -> int:
|
||||
"""Resolve whether the requested ta4j snapshot version is currently published."""
|
||||
version = args.version.strip()
|
||||
if not version:
|
||||
print("::error::--version is required", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not version.endswith("-SNAPSHOT"):
|
||||
result = snapshot_publication_result(
|
||||
version=version,
|
||||
published="n/a",
|
||||
latest="",
|
||||
last_updated="",
|
||||
source=args.metadata_file.resolve().as_uri() if args.metadata_file else args.metadata_url,
|
||||
)
|
||||
write_json(args.output, result)
|
||||
emit_snapshot_publication_outputs(result, args.github_output)
|
||||
print(f"audit:snapshot_publication version={version} published=n/a output={args.output}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
metadata_text, source = load_snapshot_metadata(args)
|
||||
latest, last_updated, versions = parse_snapshot_metadata(metadata_text)
|
||||
except (ET.ParseError, OSError, UnicodeDecodeError, ValueError) as exc:
|
||||
result = snapshot_publication_result(
|
||||
version=version,
|
||||
published="unknown",
|
||||
latest="",
|
||||
last_updated="",
|
||||
source=args.metadata_file.resolve().as_uri() if args.metadata_file else args.metadata_url,
|
||||
error=str(exc),
|
||||
)
|
||||
write_json(args.output, result)
|
||||
emit_snapshot_publication_outputs(result, args.github_output)
|
||||
print(f"::warning::Unable to verify snapshot publication for {version}: {exc}")
|
||||
print(f"audit:snapshot_publication version={version} published=unknown output={args.output}")
|
||||
return 0
|
||||
|
||||
published = "true" if version in set(versions) else "false"
|
||||
result = snapshot_publication_result(
|
||||
version=version,
|
||||
published=published,
|
||||
latest=latest,
|
||||
last_updated=last_updated,
|
||||
source=source,
|
||||
versions=versions,
|
||||
)
|
||||
write_json(args.output, result)
|
||||
emit_snapshot_publication_outputs(result, args.github_output)
|
||||
print(
|
||||
"audit:snapshot_publication "
|
||||
f"version={version} published={published} latest={latest or 'unknown'} "
|
||||
f"last_updated={last_updated or 'unknown'} output={args.output}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_snapshot_publication_policy(args: argparse.Namespace) -> int:
|
||||
"""Decide whether snapshot-publication failures should count as release-health drift."""
|
||||
event_name = args.event_name.strip()
|
||||
workflow_name = args.workflow_name.strip()
|
||||
enforce = event_name in {"schedule", "workflow_dispatch"} or (
|
||||
event_name == "workflow_run" and workflow_name == SNAPSHOT_WORKFLOW_NAME
|
||||
)
|
||||
pending_reason = (
|
||||
""
|
||||
if enforce
|
||||
else "awaiting snapshot workflow completion before treating snapshot metadata drift as authoritative"
|
||||
)
|
||||
result = snapshot_publication_policy_result(
|
||||
event_name=event_name,
|
||||
workflow_name=workflow_name,
|
||||
enforce=enforce,
|
||||
pending_reason=pending_reason,
|
||||
)
|
||||
write_json(args.output, result)
|
||||
emit_snapshot_publication_policy_outputs(result, args.github_output)
|
||||
print(
|
||||
"audit:snapshot_publication_policy "
|
||||
f"event={event_name or 'unknown'} workflow={workflow_name or 'none'} "
|
||||
f"enforced={'true' if enforce else 'false'} output={args.output}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
catalog = subparsers.add_parser("catalog-preflight")
|
||||
catalog.add_argument("--model", required=True)
|
||||
catalog.add_argument("--catalog-url", default="https://models.github.ai/catalog/models")
|
||||
catalog.add_argument("--catalog-file")
|
||||
catalog.add_argument("--timeout-seconds", type=int, default=30)
|
||||
catalog.add_argument("--output", type=pathlib.Path, default=pathlib.Path("release-ai-model.json"))
|
||||
catalog.set_defaults(func=command_catalog_preflight)
|
||||
|
||||
dossier = subparsers.add_parser("build-dossier")
|
||||
dossier.add_argument("--last-tag", required=True)
|
||||
dossier.add_argument("--current-version", required=True)
|
||||
dossier.add_argument("--pom-base", required=True)
|
||||
dossier.add_argument("--max-diff-chars", type=int, default=600_000)
|
||||
dossier.add_argument("--output", type=pathlib.Path, default=pathlib.Path("release-dossier.md"))
|
||||
dossier.add_argument("--audit-output", type=pathlib.Path, default=pathlib.Path("release-audit.json"))
|
||||
dossier.set_defaults(func=command_build_dossier)
|
||||
|
||||
request = subparsers.add_parser("build-ai-request")
|
||||
request.add_argument("--model", required=True)
|
||||
request.add_argument("--dossier", type=pathlib.Path, default=pathlib.Path("release-dossier.md"))
|
||||
request.add_argument("--semver-rules", type=pathlib.Path, default=pathlib.Path(".github/workflows/semver-rules-override.txt"))
|
||||
request.add_argument("--max-dossier-chars", type=int, default=900_000)
|
||||
request.add_argument("--output", type=pathlib.Path, default=pathlib.Path("request.json"))
|
||||
request.set_defaults(func=command_build_ai_request)
|
||||
|
||||
parse = subparsers.add_parser("parse-decision")
|
||||
parse.add_argument("--raw-file", type=pathlib.Path, default=pathlib.Path("ai-content.txt"))
|
||||
parse.add_argument("--output", type=pathlib.Path, default=pathlib.Path("release-decision.json"))
|
||||
parse.add_argument("--github-output")
|
||||
parse.set_defaults(func=command_parse_decision)
|
||||
|
||||
javadoc = subparsers.add_parser("javadoc-warnings")
|
||||
javadoc.add_argument("--baseline", type=pathlib.Path, default=pathlib.Path("scripts/release/javadoc-warning-baseline.txt"))
|
||||
javadoc.add_argument("--output", type=pathlib.Path, default=pathlib.Path("javadoc-warnings.txt"))
|
||||
javadoc.add_argument("--github-output")
|
||||
javadoc.add_argument("--fail-on-new", action="store_true")
|
||||
javadoc.add_argument("logs", type=pathlib.Path, nargs="*")
|
||||
javadoc.set_defaults(func=command_javadoc_warnings)
|
||||
|
||||
manifest = subparsers.add_parser("artifact-manifest")
|
||||
manifest.add_argument("--version", required=True)
|
||||
manifest.add_argument("--output", type=pathlib.Path, default=pathlib.Path("artifact-manifest.txt"))
|
||||
manifest.add_argument("--github-output")
|
||||
manifest.add_argument("--strict", action="store_true")
|
||||
manifest.set_defaults(func=command_artifact_manifest)
|
||||
|
||||
snapshot = subparsers.add_parser("snapshot-publication")
|
||||
snapshot.add_argument("--version", required=True)
|
||||
snapshot.add_argument("--metadata-url", default=SNAPSHOT_METADATA_URL)
|
||||
snapshot.add_argument("--metadata-file", type=pathlib.Path)
|
||||
snapshot.add_argument("--timeout-seconds", type=int, default=30)
|
||||
snapshot.add_argument("--output", type=pathlib.Path, default=pathlib.Path("snapshot-publication.json"))
|
||||
snapshot.add_argument("--github-output")
|
||||
snapshot.set_defaults(func=command_snapshot_publication)
|
||||
|
||||
snapshot_policy = subparsers.add_parser("snapshot-publication-policy")
|
||||
snapshot_policy.add_argument("--event-name", required=True)
|
||||
snapshot_policy.add_argument("--workflow-name", default="")
|
||||
snapshot_policy.add_argument("--output", type=pathlib.Path, default=pathlib.Path("snapshot-publication-policy.json"))
|
||||
snapshot_policy.add_argument("--github-output")
|
||||
snapshot_policy.set_defaults(func=command_snapshot_publication_policy)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
return int(args.func(args))
|
||||
except Exception as exc:
|
||||
print(f"::error::{exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user