Added release blocker monitor badge (#5743)

This PR implements an automated release progress monitoring system for the HDF5 project. The changes add a dynamic badge to the README that tracks completion of release-blocking issues from GitHub Project #39, addressing issue #5742. The implementation consists of three components:

- Python script (update-progress.py) that uses GitHub's GraphQL API to query project items, filter for 'Release_Blocker' items, and calculate completion percentage
- GitHub Actions workflow (update-progress.yml) that runs the script every 24 hours, generates badge data, and publishes it via GitHub Gist
- README updates that remove tentative release language and add the new progress badge with color-coded status indicators

The system replaces static release information with real-time progress tracking, providing better transparency into HDF5's feature-driven release process. The badge displays progress percentages with green (90%+), yellow (60-79%), orange (40-59%), and red (<40%) color coding.
This commit is contained in:
Scot Breitenfeld
2025-08-27 16:41:32 -05:00
committed by GitHub
parent 63803d7115
commit 40bde85ea9
3 changed files with 449 additions and 5 deletions
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
GitHub Project Release Blocker Progress Tracker
Fetches release blocker issues from the HDF5 project and calculates completion percentage.
"""
import requests
from typing import Dict, Any, Optional
class GitHubProjectTracker:
"""Tracks release blocker progress in GitHub projects."""
def __init__(self, token: str, owner: str, project_number: int):
self.api_url = "https://api.github.com/graphql"
self.headers = {
"Authorization": f"bearer {token}",
"Content-Type": "application/json"
}
self.owner = owner
self.project_number = project_number
def _get_query(self) -> str:
"""Returns the GraphQL query for fetching project items."""
return """
query($owner: String!, $projectNumber: Int!, $cursor: String) {
organization(login: $owner) {
projectV2(number: $projectNumber) {
items(first: 100, after: $cursor) {
pageInfo { hasNextPage, endCursor }
nodes {
id
fieldValues(first: 20) {
nodes {
__typename
... on ProjectV2ItemFieldTextValue {
text, field { ... on ProjectV2Field { name } }
}
... on ProjectV2ItemFieldSingleSelectValue {
name, field { ... on ProjectV2SingleSelectField { name } }
}
... on ProjectV2ItemFieldIterationValue {
title, field { ... on ProjectV2IterationField { name } }
}
... on ProjectV2ItemFieldNumberValue {
number, field { ... on ProjectV2Field { name } }
}
... on ProjectV2ItemFieldDateValue {
date, field { ... on ProjectV2Field { name } }
}
}
}
content { ... on Issue { id, title, url } }
}
}
}
}
}
"""
def _extract_field_value(self, field_data: Dict[str, Any]) -> Optional[str]:
"""Extracts value from a field based on its type."""
type_name = field_data.get("__typename")
value_map = {
"ProjectV2ItemFieldSingleSelectValue": "name",
"ProjectV2ItemFieldIterationValue": "title",
"ProjectV2ItemFieldTextValue": "text",
"ProjectV2ItemFieldNumberValue": "number",
"ProjectV2ItemFieldDateValue": "date"
}
value_key = value_map.get(type_name)
return field_data.get(value_key) if value_key else None
def _parse_item_fields(self, item: Dict[str, Any]) -> Dict[str, str]:
"""Parses field values from a project item."""
fields = {}
for field_data in item.get("fieldValues", {}).get("nodes", []):
field_name = field_data.get("field", {}).get("name")
if not field_name:
continue
value = self._extract_field_value(field_data)
if value is not None:
fields[field_name] = str(value)
return fields
def fetch_release_blocker_stats(self) -> Dict[str, int]:
"""
Fetches release blocker statistics from the GitHub project.
Returns:
Dict with 'total', 'done', and 'percentage' keys
"""
total = 0
done = 0
cursor = None
while True:
variables = {
"owner": self.owner,
"projectNumber": self.project_number,
"cursor": cursor
}
try:
response = requests.post(
self.api_url,
json={'query': self._get_query(), 'variables': variables},
headers=self.headers,
timeout=30
)
response.raise_for_status()
result = response.json()
if "errors" in result:
raise Exception(f"GraphQL errors: {result['errors']}")
except requests.RequestException as e:
raise Exception(f"API request failed: {e}")
# Parse response
project = result.get("data", {}).get("organization", {}).get("projectV2", {})
items = project.get("items", {})
for item in items.get("nodes", []):
if not item.get("content"):
continue
fields = self._parse_item_fields(item)
if fields.get("Release gating") == "Release_Blocker":
total += 1
if fields.get("Status") == "Done":
done += 1
# Check for next page
page_info = items.get("pageInfo", {})
if not page_info.get("hasNextPage", False):
break
cursor = page_info.get("endCursor")
percentage = round((done / total * 100), 1) if total > 0 else 0
return {
'total': total,
'done': done,
'percentage': percentage
}
def main():
"""Main function to run the tracker."""
import os
import sys
# Configuration - can be overridden by environment variables
TOKEN = os.getenv("GITHUB_TOKEN")
OWNER = os.getenv("GITHUB_OWNER", "HDFGroup")
PROJECT_NUMBER = int(os.getenv("GITHUB_PROJECT_NUMBER", "39"))
try:
tracker = GitHubProjectTracker(TOKEN, OWNER, PROJECT_NUMBER)
stats = tracker.fetch_release_blocker_stats()
# Output for GitHub Actions
github_output = os.getenv("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as f:
f.write(f"percentage={stats['percentage']}\n")
f.write(f"done={stats['done']}\n")
f.write(f"total={stats['total']}\n")
# Also output to stdout for local testing
print(f"percentage={stats['percentage']}")
print(f"Calculated progress: {stats['percentage']}%")
print(f"Done / Total: {stats['done']} / {stats['total']}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
exit(main())
+250
View File
@@ -0,0 +1,250 @@
name: Release Progress
on:
schedule:
- cron: '0 */24 * * *' # Run every 24 hours
workflow_dispatch: # Allow manual triggering
jobs:
check-progress:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11' # Use specific version for consistency
- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
pip install requests
- name: Validate required secrets
run: |
if [ -z "${{ secrets.GIST_TOKEN }}" ]; then
echo "::error::GIST_TOKEN secret is required"
exit 1
fi
if [ -z "${{ secrets.GIST_ID }}" ]; then
echo "::error::GIST_ID secret is required"
exit 1
fi
echo "::notice::All required secrets are present"
- name: Debug information
if: runner.debug == '1'
run: |
echo "Repository: $GITHUB_REPOSITORY"
echo "Workflow: $GITHUB_WORKFLOW"
echo "Run ID: $GITHUB_RUN_ID"
echo "Python version: $(python --version)"
echo "Environment variables:"
env | grep -E '^GITHUB_' | sort
- name: Calculate progress (run once)
id: progress
run: |
# Check if Python script exists
if [ ! -f "update-progress.py" ]; then
echo "::error::update-progress.py not found in repository root"
exit 1
fi
echo "::notice::Running progress calculation script..."
# Run the Python script with error handling
if ! python update-progress.py > progress_output.txt 2>&1; then
echo "::error::Python script execution failed"
echo "Script output:"
cat progress_output.txt
exit 1
fi
# Display the captured output for debugging
echo "=== Python Script Output ==="
cat progress_output.txt
echo "=========================="
# Extract and validate percentage
PERCENTAGE=$(grep "^percentage=" progress_output.txt | cut -d'=' -f2 | head -1)
if [ -z "$PERCENTAGE" ] || ! [[ "$PERCENTAGE" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
echo "::error::Invalid or missing percentage value: '$PERCENTAGE'"
echo "Expected format: percentage=XX.X"
exit 1
fi
# Extract and validate done/total counts
DONE_TOTAL=$(grep "^Done / Total:" progress_output.txt | cut -d':' -f2 | xargs | head -1)
if [ -z "$DONE_TOTAL" ]; then
echo "::error::Missing 'Done / Total:' line in script output"
exit 1
fi
DONE=$(echo "$DONE_TOTAL" | cut -d' ' -f1)
TOTAL=$(echo "$DONE_TOTAL" | cut -d' ' -f3) # Account for "XX / YY" format
# Validate numeric values
if ! [[ "$DONE" =~ ^[0-9]+$ ]] || ! [[ "$TOTAL" =~ ^[0-9]+$ ]]; then
echo "::error::Invalid done/total values: done='$DONE', total='$TOTAL'"
echo "Expected format: 'Done / Total: XX / YY'"
exit 1
fi
# Validate logical consistency
if [ "$TOTAL" -eq 0 ]; then
echo "::error::Total count cannot be zero"
exit 1
fi
if [ "$DONE" -gt "$TOTAL" ]; then
echo "::error::Done count ($DONE) cannot exceed total count ($TOTAL)"
exit 1
fi
# Set outputs for use in subsequent steps
echo "percentage=$PERCENTAGE" >> $GITHUB_OUTPUT
echo "done=$DONE" >> $GITHUB_OUTPUT
echo "total=$TOTAL" >> $GITHUB_OUTPUT
echo "::notice::Progress calculation successful: ${PERCENTAGE}% (${DONE}/${TOTAL})"
# Clean up
rm -f progress_output.txt
env:
GITHUB_TOKEN: ${{ secrets.GIST_TOKEN }}
GITHUB_OWNER: "HDFGroup"
GITHUB_PROJECT_NUMBER: "39"
- name: Update progress badge Gist
env:
GITHUB_TOKEN: ${{ secrets.GIST_TOKEN }}
GIST_ID: ${{ secrets.GIST_ID }}
run: |
PERCENTAGE="${{ steps.progress.outputs.percentage }}"
DONE="${{ steps.progress.outputs.done }}"
TOTAL="${{ steps.progress.outputs.total }}"
echo "::notice::Updating badge with: ${PERCENTAGE}% (${DONE}/${TOTAL})"
# Use integer comparison for more reliable thresholds
PERCENTAGE_INT=$(printf "%.0f" "$PERCENTAGE")
# Determine badge color and status with cleaner logic
if [ "$PERCENTAGE_INT" -ge 90 ]; then
COLOR="brightgreen"
STATUS="🟢 Readying for Deployment"
elif [ "$PERCENTAGE_INT" -ge 60 ]; then
COLOR="yellow"
STATUS="🟡 Nearing Completion"
elif [ "$PERCENTAGE_INT" -ge 40 ]; then
COLOR="orange"
STATUS="🟠 In Development"
else
COLOR="red"
STATUS="🔴 Initial Phase"
fi
echo "::notice title=Release Progress::${PERCENTAGE}% Complete (${DONE}/${TOTAL}) - ${STATUS}"
# Create badge JSON for shields.io endpoint with proper escaping
BADGE_JSON=$(jq -n \
--arg percentage "$PERCENTAGE" \
--arg done "$DONE" \
--arg total "$TOTAL" \
--arg color "$COLOR" \
'{
"schemaVersion": 1,
"label": "Release Progress",
"message": "\($percentage)% (\($done)/\($total))",
"color": $color,
"style": "flat-square"
}')
# Validate JSON was created successfully
if [ -z "$BADGE_JSON" ] || ! echo "$BADGE_JSON" | jq empty 2>/dev/null; then
echo "::error::Failed to generate valid badge JSON"
exit 1
fi
# The filename in the Gist must match the one created manually
GIST_NAME="release-progress-${GITHUB_REPOSITORY##*/}.json"
echo "::notice::Updating Gist file: $GIST_NAME"
# Create the request payload
REQUEST_PAYLOAD=$(jq -n \
--arg filename "$GIST_NAME" \
--argjson content "$BADGE_JSON" \
'{
"files": {
($filename): {
"content": ($content | tostring)
}
}
}')
# Update the existing Gist with response validation
echo "Updating Gist: ${GIST_ID}"
RESPONSE=$(curl -s -w "\n%{http_code}" -L -X PATCH \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/gists/${GIST_ID}" \
-d "$REQUEST_PAYLOAD")
# Extract HTTP status code (last line) and response body
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$RESPONSE" | head -n -1)
# Validate API response
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::Gist update failed with HTTP status $HTTP_CODE"
echo "Response body: $RESPONSE_BODY"
exit 1
fi
echo "::notice::Gist updated successfully"
# Generate badge URL for use in README
BADGE_URL="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/${GITHUB_REPOSITORY_OWNER}/${GIST_ID}/raw/${GIST_NAME}"
PROJECT_URL="https://github.com/${GITHUB_REPOSITORY}/projects/39"
echo "::notice::Badge URL generated: $BADGE_URL"
echo "badge_url=$BADGE_URL" >> $GITHUB_OUTPUT
echo "project_url=$PROJECT_URL" >> $GITHUB_OUTPUT
# Create enhanced step summary
echo "## 📊 Release Progress: ${PERCENTAGE}%" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Status:** ${STATUS}" >> $GITHUB_STEP_SUMMARY
echo "**Progress:** ${DONE} of ${TOTAL} release blockers completed" >> $GITHUB_STEP_SUMMARY
echo "**Badge Color:** ${COLOR}" >> $GITHUB_STEP_SUMMARY
echo "**Gist ID:** ${GIST_ID}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Badge URLs" >> $GITHUB_STEP_SUMMARY
echo "**Markdown:** \`[![Release Progress](${BADGE_URL})](${PROJECT_URL})\`" >> $GITHUB_STEP_SUMMARY
echo "**Image Only:** \`![Release Progress](${BADGE_URL})\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Badge JSON Preview" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo "$BADGE_JSON" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Cleanup on failure
if: failure()
run: |
echo "::warning::Workflow failed - cleaning up temporary files"
rm -f progress_output.txt
echo "::notice::Check the logs above for specific error details"