Skip to content

Commit 928de4e

Browse files
committed
Automate service coverage data update for Azure
1 parent d5fd2a1 commit 928de4e

File tree

3 files changed

+280
-0
lines changed

3 files changed

+280
-0
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Update Azure Coverage Data
2+
3+
on:
4+
schedule:
5+
- cron: 0 5 * * MON
6+
workflow_dispatch:
7+
inputs:
8+
targetBranch:
9+
required: false
10+
type: string
11+
default: "main"
12+
pull_request:
13+
types: [opened, synchronize]
14+
15+
jobs:
16+
update-azure-coverage:
17+
name: Update Azure coverage data
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: write
21+
pull-requests: write
22+
steps:
23+
- name: Checkout docs
24+
uses: actions/checkout@v4
25+
with:
26+
fetch-depth: 0
27+
path: docs
28+
ref: ${{ github.event.inputs.targetBranch || 'main' }}
29+
30+
- name: Set up system wide dependencies
31+
run: |
32+
sudo apt-get install jq wget
33+
34+
- name: Set up Python 3.11
35+
uses: actions/setup-python@v5
36+
with:
37+
python-version: "3.11"
38+
39+
- name: Download Azure implementation metrics artifact
40+
working-directory: docs
41+
run: bash ./scripts/get_latest_github_metrics.sh ./target main
42+
env:
43+
GITHUB_TOKEN: ${{ secrets.PRO_ACCESS_TOKEN }}
44+
REPOSITORY_NAME: localstack-pro
45+
ARTIFACT_ID: implemented_features_python-amd64.csv
46+
WORKFLOW: "Az / Build, Test, Push"
47+
48+
- name: Generate Azure coverage JSON data
49+
working-directory: docs
50+
run: |
51+
python3 scripts/create_azure_coverage.py -i target/implemented_features_python-amd64.csv/implemented_features.csv -o target/updated_azure_coverage
52+
mv -f target/updated_azure_coverage/*.json src/data/azure-coverage/
53+
54+
- name: Check for changes
55+
id: check-for-changes
56+
working-directory: docs
57+
env:
58+
TARGET_BRANCH: ${{ github.event.inputs.targetBranch || 'main' }}
59+
run: |
60+
mkdir -p resources
61+
(git diff --name-only origin/automated-azure-coverage-updates src/data/azure-coverage/ 2>/dev/null || git diff --name-only "origin/$TARGET_BRANCH" src/data/azure-coverage/ 2>/dev/null) | tee -a resources/diff-check.log
62+
echo "diff-count=$(cat resources/diff-check.log | wc -l)" >> "$GITHUB_OUTPUT"
63+
cat resources/diff-check.log
64+
65+
- name: Create PR
66+
uses: peter-evans/create-pull-request@v7
67+
if: ${{ success() && steps.check-for-changes.outputs.diff-count != '0' && steps.check-for-changes.outputs.diff-count != '' }}
68+
with:
69+
path: docs
70+
title: "Update Azure coverage data"
71+
body: "Update generated Azure coverage JSON data from the latest LocalStack Pro parity metrics artifact."
72+
branch: "automated-azure-coverage-updates"
73+
author: "LocalStack Bot <localstack-bot@users.noreply.github.com>"
74+
committer: "LocalStack Bot <localstack-bot@users.noreply.github.com>"
75+
commit-message: "update generated azure coverage data"
76+
token: ${{ secrets.PRO_ACCESS_TOKEN }}

scripts/create_azure_coverage.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""
2+
Generate Azure coverage JSON files from implementation CSV data.
3+
"""
4+
5+
import argparse
6+
import csv
7+
import json
8+
from pathlib import Path
9+
from typing import Any
10+
11+
12+
def _as_bool(value: Any, default: bool = True) -> bool:
13+
if value is None:
14+
return default
15+
if isinstance(value, bool):
16+
return value
17+
return str(value).strip().lower() in {"1", "true", "yes", "y"}
18+
19+
20+
def _group_name(service_name: str, category: str) -> str:
21+
service_name = (service_name or "").strip()
22+
category = (category or "").strip()
23+
if not category:
24+
return service_name
25+
if category.lower() in {"none", "null", "n/a"}:
26+
return service_name
27+
if category == service_name:
28+
return service_name
29+
return f"{service_name} ({category})"
30+
31+
32+
def _normalize_provider(value: str) -> str:
33+
return (value or "").strip().replace("_", ".")
34+
35+
36+
def _resolve_input_csv(path: Path) -> Path:
37+
if path.exists():
38+
if path.is_file():
39+
return path
40+
# Support passing a directory that contains the extracted artifact.
41+
nested_csv = path / "implemented_features.csv"
42+
if nested_csv.exists():
43+
return nested_csv
44+
matches = sorted(path.rglob("implemented_features.csv"))
45+
if matches:
46+
return matches[0]
47+
raise FileNotFoundError(f"No implemented_features.csv found under: {path}")
48+
49+
# Backward-compatible fallback for target/implemented_features.csv.
50+
if path.name == "implemented_features.csv" and path.parent.exists():
51+
matches = sorted(path.parent.rglob("implemented_features.csv"))
52+
if matches:
53+
return matches[0]
54+
55+
raise FileNotFoundError(f"Input CSV not found: {path}")
56+
57+
58+
def _load_csv(path: Path) -> dict[str, dict[str, dict[str, dict[str, Any]]]]:
59+
path = _resolve_input_csv(path)
60+
61+
coverage: dict[str, dict[str, dict[str, dict[str, Any]]]] = {}
62+
with path.open(mode="r", encoding="utf-8") as file:
63+
reader = csv.DictReader(file)
64+
if not reader.fieldnames:
65+
raise ValueError("Input CSV has no headers.")
66+
67+
for row in reader:
68+
provider = _normalize_provider(row.get("resource_provider", ""))
69+
if not provider:
70+
continue
71+
72+
feature_name = (row.get("feature") or row.get("operation") or "").strip()
73+
if not feature_name:
74+
continue
75+
76+
group = _group_name(row.get("service", ""), row.get("category", ""))
77+
if not group:
78+
group = "General"
79+
80+
implemented = _as_bool(
81+
row.get("implemented", row.get("is_implemented", row.get("isImplemented"))),
82+
default=True,
83+
)
84+
pro_only = _as_bool(row.get("pro", row.get("is_pro", row.get("isPro"))), default=True)
85+
86+
provider_data = coverage.setdefault(provider, {})
87+
group_data = provider_data.setdefault(group, {})
88+
group_data[feature_name] = {
89+
"implemented": implemented,
90+
"pro": pro_only,
91+
}
92+
93+
return coverage
94+
95+
96+
def _sorted_details(details: dict[str, dict[str, dict[str, Any]]]) -> dict[str, dict[str, dict[str, Any]]]:
97+
sorted_details: dict[str, dict[str, dict[str, Any]]] = {}
98+
for group_name in sorted(details.keys()):
99+
operations = details[group_name]
100+
sorted_details[group_name] = dict(sorted(operations.items(), key=lambda item: item[0]))
101+
return sorted_details
102+
103+
104+
def write_coverage_files(coverage: dict[str, dict[str, dict[str, dict[str, Any]]]], output_dir: Path) -> None:
105+
output_dir.mkdir(parents=True, exist_ok=True)
106+
for provider in sorted(coverage.keys()):
107+
payload = {
108+
"service": provider,
109+
"operations": [],
110+
"details": _sorted_details(coverage[provider]),
111+
}
112+
file_path = output_dir / f"{provider}.json"
113+
with file_path.open(mode="w", encoding="utf-8") as fd:
114+
json.dump(payload, fd, indent=2)
115+
fd.write("\n")
116+
117+
118+
def main() -> None:
119+
parser = argparse.ArgumentParser(description="Generate Azure coverage JSON data.")
120+
parser.add_argument(
121+
"-i",
122+
"--implementation-details",
123+
required=True,
124+
help="Path to implementation details CSV.",
125+
)
126+
parser.add_argument(
127+
"-o",
128+
"--output-dir",
129+
required=True,
130+
help="Directory where generated JSON files will be written.",
131+
)
132+
args = parser.parse_args()
133+
134+
coverage = _load_csv(Path(args.implementation_details))
135+
write_coverage_files(coverage, Path(args.output_dir))
136+
137+
138+
if __name__ == "__main__":
139+
main()
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
4+
# input params
5+
PARENT_FOLDER=${1:-target}
6+
METRICS_ARTIFACTS_BRANCH=${2:-main}
7+
8+
# env vars
9+
REPOSITORY_NAME=${REPOSITORY_NAME:-localstack-pro}
10+
ARTIFACT_ID=${ARTIFACT_ID:-implemented_features_python-amd64.csv}
11+
WORKFLOW=${WORKFLOW:-"Az / Build, Test, Push"}
12+
PREFIX_ARTIFACT=${PREFIX_ARTIFACT:-}
13+
FILTER_SUCCESS=${FILTER_SUCCESS:-1}
14+
LIMIT=${LIMIT:-20}
15+
16+
RESOURCE_FOLDER=${RESOURCE_FOLDER:-}
17+
REPOSITORY_OWNER=${REPOSITORY_OWNER:-localstack}
18+
TARGET_FOLDER="$PARENT_FOLDER/$RESOURCE_FOLDER"
19+
20+
TMP_FOLDER="$PARENT_FOLDER/tmp_download"
21+
mkdir -p "$TMP_FOLDER"
22+
23+
echo "Searching for artifact '$ARTIFACT_ID' in workflow '$WORKFLOW' on branch '$METRICS_ARTIFACTS_BRANCH' in repo '$REPOSITORY_OWNER/$REPOSITORY_NAME'."
24+
25+
if [ "$FILTER_SUCCESS" = "1" ]; then
26+
echo "Filtering runs by conclusion=success"
27+
SELECTOR='.[] | select(.conclusion=="success")'
28+
else
29+
echo "Filtering runs by completed status (success/failure)"
30+
SELECTOR='.[] | select(.status=="completed" and (.conclusion=="failure" or .conclusion=="success"))'
31+
fi
32+
33+
RUN_IDS=$(gh run list --limit "$LIMIT" --branch "$METRICS_ARTIFACTS_BRANCH" --repo "$REPOSITORY_OWNER/$REPOSITORY_NAME" --workflow "$WORKFLOW" --json databaseId,conclusion,status --jq "$SELECTOR")
34+
35+
if [ "$(echo "$RUN_IDS" | jq -rs '.[0].databaseId')" = "null" ]; then
36+
echo "No matching workflow run found."
37+
exit 1
38+
fi
39+
40+
for ((i=0; i<LIMIT; i++)); do
41+
RUN_ID=$(echo "$RUN_IDS" | jq -rs ".[$i].databaseId")
42+
echo "Trying run id: $RUN_ID"
43+
44+
gh run download "$RUN_ID" --repo "$REPOSITORY_OWNER/$REPOSITORY_NAME" -p "$ARTIFACT_ID" -D "$TMP_FOLDER" || true
45+
46+
if [ "$(ls -1 "$TMP_FOLDER" 2>/dev/null | wc -l)" -gt 0 ]; then
47+
echo "Downloaded artifact successfully."
48+
break
49+
fi
50+
done
51+
52+
echo "Moving artifact to $TARGET_FOLDER"
53+
mkdir -p "$TARGET_FOLDER"
54+
if [[ -z "${PREFIX_ARTIFACT}" ]]; then
55+
cp -R "$TMP_FOLDER"/. "$TARGET_FOLDER"/
56+
else
57+
while IFS= read -r file; do
58+
org_file_name=$(echo "$file" | sed "s/.*\///")
59+
mv -- "$file" "$TARGET_FOLDER/$PREFIX_ARTIFACT-$org_file_name"
60+
done < <(find "$TMP_FOLDER" -type f -name "*.csv")
61+
fi
62+
63+
rm -rf "$TMP_FOLDER"
64+
echo "Contents of $TARGET_FOLDER:"
65+
ls -la "$TARGET_FOLDER"

0 commit comments

Comments
 (0)