Files
hdf5/.github/workflows/update-progress.py
T
Scot Breitenfeld 31d9d3c322 Fix release-progress badges to read native issue-level Priority field (#6668)
* Fix release-progress badges to read native issue-level Priority field

The Project Priority board field was consolidated into a Priority field
managed at the issue level (GitHub's org-wide custom issue fields), which
the GraphQL API mirrors into projects as ProjectV2ItemIssueFieldValue
instead of ProjectV2ItemFieldSingleSelectValue. The badge script's query
had no fragment for that type, so it silently found zero priority values
and would raise ProjectFieldMissingError on every run.

* Show TBD on the Next Release badge when no milestone due date is set

Previously the badge silently omitted any due-date annotation when the
milestone had none, which read as if the badge just hadn't picked one up.

* Update release-schedule image and plantuml source from add-next-release-badge branch

* Don't flag CODEOWNERS auto-assignment as a manual reviewer add on a PR's first pass

GitHub's own CODEOWNERS engine fires an identical review_requested webhook
(sender type User, not Bot) when it auto-assigns owners at PR-open time. If
one of those survives the cancel-in-progress race as the run that actually
executes, it was mistaken for a human deliberately requesting that reviewer,
permanently flagging them "manually added" and requiring separate approval —
e.g. a catch-all "*" owner also named on a touched area's CODEOWNERS line got
flagged on every PR touching that area, even though no human ever picked them.

Gate the manual-add detection and the sticky per-area assignment on
!isFirstCoordinationPass (no checklist comment posted yet), since that's
exactly the window where GitHub's own auto-assignment is indistinguishable
from a real human pick.
2026-09-15 14:32:55 -05:00

528 lines
22 KiB
Python

#!/usr/bin/env python3
"""
GitHub Project Priority Issue Progress Tracker
Fetches critical and high priority issues from the HDF5 project and calculates completion percentage.
"""
import os
import sys
import requests
from typing import Dict, Any, Optional
class ProjectFieldMissingError(Exception):
"""Raised when a critical project field is missing or renamed."""
pass
class ProjectDataError(Exception):
"""Raised when project data is invalid or incomplete."""
pass
# Configuration: Expected field names in GitHub Project
# Update these if the project field names change
FIELD_PRIORITY = "Priority"
FIELD_STATUS = "Status"
# Expected values for Priority field
VALUE_CRITICAL = "P0 - Critical"
VALUE_HIGH = "P1 - High"
VALUE_MEDIUM = "P2 - Medium"
VALUE_LOW = "P3 - Low"
# Expected value for Status field when an item is completed
VALUE_STATUS_DONE = "Done"
# Milestone filtering
# Set to None to include all milestones, or specify a version like "2.1" to filter
DEFAULT_MILESTONE_FILTER = None # Will be set from environment or H5public.h
class GitHubProjectTracker:
"""Tracks priority issue progress in GitHub projects."""
def __init__(self, token: str, owner: str, project_number: int, milestone_filter: Optional[str] = None):
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
self.milestone_filter = milestone_filter
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 } }
}
... on ProjectV2ItemIssueFieldValue {
field {
... on ProjectV2Field { name }
... on ProjectV2SingleSelectField { name }
... on ProjectV2IterationField { name }
... on ProjectV2MultiSelectField { name }
}
issueFieldValue {
__typename
... on IssueFieldSingleSelectValue { value }
... on IssueFieldTextValue { value }
... on IssueFieldNumberValue { value }
... on IssueFieldDateValue { value }
}
}
}
}
content {
... on Issue {
id, title, url
milestone { title }
}
... on PullRequest {
id, title, url
milestone { title }
}
}
}
}
}
}
}
"""
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")
# Fields backed by GitHub's native issue-level custom fields (e.g. a
# Priority field managed at the org/repo level rather than as a
# project-only field) are mirrored into the project as
# ProjectV2ItemIssueFieldValue, with the actual value nested under
# issueFieldValue instead of alongside __typename like the other cases.
if type_name == "ProjectV2ItemIssueFieldValue":
issue_value = field_data.get("issueFieldValue") or {}
return issue_value.get("value")
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 critical, high, medium, and low priority issue statistics from the GitHub project.
Returns:
Dict with 'total', 'done', 'percentage', 'blocker_total', 'blocker_done',
'mustdo_total', 'mustdo_done', 'medium_total', 'medium_done',
'low_total', 'low_done' keys
"""
blocker_total = 0
blocker_done = 0
mustdo_total = 0
mustdo_done = 0
medium_total = 0
medium_done = 0
low_total = 0
low_done = 0
cursor = None
# Track if we've seen the expected fields at least once
seen_priority = False
seen_status = False
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
# Check milestone filter if configured
content = item.get("content", {})
milestone = content.get("milestone", {})
milestone_title = milestone.get("title", "") if milestone else ""
# Skip if milestone filter is set and doesn't match
if self.milestone_filter:
# Match milestone versions like "2.1" with milestones like "2.1.0" or "HDF5 2.1"
if not (milestone_title and self.milestone_filter in milestone_title):
continue
fields = self._parse_item_fields(item)
# Validate expected fields exist
if FIELD_PRIORITY in fields:
seen_priority = True
if FIELD_STATUS in fields:
seen_status = True
priority = fields.get(FIELD_PRIORITY, "")
status = fields.get(FIELD_STATUS, "")
if priority == VALUE_CRITICAL:
blocker_total += 1
if status == VALUE_STATUS_DONE:
blocker_done += 1
elif priority == VALUE_HIGH:
mustdo_total += 1
if status == VALUE_STATUS_DONE:
mustdo_done += 1
elif priority == VALUE_MEDIUM:
medium_total += 1
if status == VALUE_STATUS_DONE:
medium_done += 1
elif priority == VALUE_LOW:
low_total += 1
if status == VALUE_STATUS_DONE:
low_done += 1
# Check for next page
page_info = items.get("pageInfo", {})
if not page_info.get("hasNextPage", False):
break
cursor = page_info.get("endCursor")
# Validate that expected fields were found - FAIL HARD if missing
# This prevents false positives where field renames would cause 0 items to be reported
if not seen_priority:
print(f"ERROR: Critical field '{FIELD_PRIORITY}' not found in any project items.",
file=sys.stderr)
print("This field is required to identify priority items.",
file=sys.stderr)
print("Possible causes:", file=sys.stderr)
print(f" 1. Field '{FIELD_PRIORITY}' was renamed in the project", file=sys.stderr)
print(" 2. Project structure changed", file=sys.stderr)
print(" 3. Project is empty or inaccessible", file=sys.stderr)
print("Action required: Update FIELD_PRIORITY constant in this script.", file=sys.stderr)
raise ProjectFieldMissingError(f"Critical field '{FIELD_PRIORITY}' not found")
if not seen_status:
print(f"ERROR: Critical field '{FIELD_STATUS}' not found in any project items.",
file=sys.stderr)
print("This field is required to determine completion status.", file=sys.stderr)
print("Possible causes:", file=sys.stderr)
print(f" 1. Field '{FIELD_STATUS}' was renamed in the project", file=sys.stderr)
print(" 2. Project structure changed", file=sys.stderr)
print(" 3. Project is empty or inaccessible", file=sys.stderr)
print("Action required: Update FIELD_STATUS constant in this script.", file=sys.stderr)
raise ProjectFieldMissingError(f"Critical field '{FIELD_STATUS}' not found")
total = blocker_total + mustdo_total + medium_total + low_total
done = blocker_done + mustdo_done + medium_done + low_done
# Validate that we found at least some items
# If total is 0, either the project is empty or field matching failed
if total == 0:
if self.milestone_filter:
print(f"INFO: No priority items found for milestone '{self.milestone_filter}'.", file=sys.stderr)
print("This may be expected if no items exist for this milestone yet.", file=sys.stderr)
# Don't fail - return N/A indicators when filtering by milestone with no items
percentage = -1.0 # Use -1 to indicate N/A
else:
print("ERROR: No priority items found (total=0).", file=sys.stderr)
print("This likely indicates:", file=sys.stderr)
print(f" 1. The '{FIELD_PRIORITY}' field values changed", file=sys.stderr)
print(f" Expected values: '{VALUE_CRITICAL}', '{VALUE_HIGH}', '{VALUE_MEDIUM}', or '{VALUE_LOW}'", file=sys.stderr)
print(" 2. Project has no items with these field values", file=sys.stderr)
print(" 3. Field matching logic needs to be updated", file=sys.stderr)
print("Refusing to report 0% or 100% with no items to prevent false positives.", file=sys.stderr)
raise ProjectDataError("No priority items found - refusing to report false completion status")
else:
percentage = round((done / total * 100), 1)
return {
'total': total,
'done': done,
'percentage': percentage,
'blocker_total': blocker_total,
'blocker_done': blocker_done,
'mustdo_total': mustdo_total,
'medium_total': medium_total,
'medium_done': medium_done,
'low_total': low_total,
'low_done': low_done,
'mustdo_done': mustdo_done
}
def get_hdf5_version_from_header(header_path: str = "../../src/H5public.h") -> Optional[str]:
"""
Extract HDF5 major.minor version from H5public.h
Returns version string like "2.1" or None if not found.
"""
try:
# Try multiple possible paths relative to the script location
script_dir = os.path.dirname(os.path.abspath(__file__))
possible_paths = [
os.path.join(script_dir, header_path),
os.path.join(script_dir, "../../src/H5public.h"),
"src/H5public.h",
"../../src/H5public.h"
]
for path in possible_paths:
if os.path.exists(path):
with open(path, 'r') as f:
major = None
minor = None
for line in f:
if '#define H5_VERS_MAJOR' in line:
major = line.split()[-1]
elif '#define H5_VERS_MINOR' in line:
minor = line.split()[-1]
if major and minor:
return f"{major}.{minor}"
break
except Exception as e:
print(f"Warning: Could not read version from H5public.h: {e}", file=sys.stderr)
return None
def get_latest_series_release(token: str, repo: str, major_version: str) -> Optional[Dict[str, str]]:
"""
Finds the most recent published (non-draft, non-prerelease) GitHub Release
whose tag belongs to the given major version series (e.g. "2" matches "2.1.1").
Returns a dict with 'tag' and 'date' (YYYY-MM-DD) keys, or None if no
matching release was found or the API call failed. Failures here are
non-fatal - this is purely informational, unlike the priority field checks.
"""
try:
headers = {"Accept": "application/vnd.github.v3+json"}
if token:
headers["Authorization"] = f"token {token}"
response = requests.get(
f"https://api.github.com/repos/{repo}/releases",
headers=headers,
params={"per_page": 30},
timeout=30
)
response.raise_for_status()
releases = response.json()
prefix = f"{major_version}."
for release in releases:
if release.get("draft") or release.get("prerelease"):
continue
tag = release.get("tag_name", "")
if tag.startswith(prefix):
published_at = release.get("published_at", "")
return {
"tag": tag,
"date": published_at.split("T")[0] if published_at else ""
}
print(f"INFO: No published releases found for series '{major_version}.x'", file=sys.stderr)
return None
except requests.RequestException as e:
print(f"Warning: Could not fetch latest release from GitHub API: {e}", file=sys.stderr)
return None
def get_milestone_due_date(token: str, repo: str, milestone_filter: str) -> Optional[str]:
"""
Finds the open GitHub milestone whose title contains milestone_filter
(e.g. "2.2" matches "HDF5 2.2.0", the same substring match used for
project item filtering) and returns its due date (YYYY-MM-DD), or None
if no matching milestone exists or it has no due date set. Non-fatal.
"""
try:
headers = {"Accept": "application/vnd.github.v3+json"}
if token:
headers["Authorization"] = f"token {token}"
response = requests.get(
f"https://api.github.com/repos/{repo}/milestones",
headers=headers,
params={"state": "open", "per_page": 100},
timeout=30
)
response.raise_for_status()
milestones = response.json()
for milestone in milestones:
title = milestone.get("title", "")
if milestone_filter in title:
due_on = milestone.get("due_on")
return due_on.split("T")[0] if due_on else None
print(f"INFO: No open milestone matching '{milestone_filter}' found", file=sys.stderr)
return None
except requests.RequestException as e:
print(f"Warning: Could not fetch milestone due date from GitHub API: {e}", file=sys.stderr)
return None
def main():
"""Main function to run the tracker."""
# 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"))
# Milestone filtering - can be set via env var or auto-detected from H5public.h
MILESTONE_FILTER = os.getenv("MILESTONE_FILTER")
if MILESTONE_FILTER is None:
# Try to auto-detect from H5public.h
MILESTONE_FILTER = get_hdf5_version_from_header()
if MILESTONE_FILTER:
print(f"Auto-detected milestone filter from H5public.h: {MILESTONE_FILTER}", file=sys.stderr)
if MILESTONE_FILTER:
print(f"Filtering by milestone: {MILESTONE_FILTER}", file=sys.stderr)
else:
print("No milestone filter - counting all release items", file=sys.stderr)
# Look up the most recent published release in the current major version series
# (e.g. "2" -> "2.1.1"), independent of the project-board data above.
REPO = os.getenv("GITHUB_REPOSITORY", "HDFGroup/hdf5")
MAJOR_VERSION = MILESTONE_FILTER.split(".")[0] if MILESTONE_FILTER else None
latest_release = get_latest_series_release(TOKEN, REPO, MAJOR_VERSION) if MAJOR_VERSION else None
LATEST_RELEASE_TAG = latest_release["tag"] if latest_release else ""
LATEST_RELEASE_DATE = latest_release["date"] if latest_release else ""
# Look up the target due date for the in-development milestone (e.g. "HDF5 2.2.0"),
# to annotate the Next Release badge.
MILESTONE_DUE_DATE = get_milestone_due_date(TOKEN, REPO, MILESTONE_FILTER) if MILESTONE_FILTER else None
MILESTONE_DUE_DATE = MILESTONE_DUE_DATE or ""
# Write these independent of the project-board query below, so a project-board
# failure doesn't discard release/milestone data that was already fetched.
github_output = os.getenv("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as f:
f.write(f"version={MILESTONE_FILTER or 'all'}\n")
f.write(f"latest_release_tag={LATEST_RELEASE_TAG}\n")
f.write(f"latest_release_date={LATEST_RELEASE_DATE}\n")
f.write(f"milestone_due_date={MILESTONE_DUE_DATE}\n")
print(f"version={MILESTONE_FILTER or 'all'}")
print(f"latest_release_tag={LATEST_RELEASE_TAG}")
print(f"latest_release_date={LATEST_RELEASE_DATE}")
print(f"milestone_due_date={MILESTONE_DUE_DATE}")
if LATEST_RELEASE_TAG:
print(f"Latest {MAJOR_VERSION}.x release: {LATEST_RELEASE_TAG} ({LATEST_RELEASE_DATE})")
if MILESTONE_DUE_DATE:
print(f"Milestone due date: {MILESTONE_DUE_DATE}")
try:
tracker = GitHubProjectTracker(TOKEN, OWNER, PROJECT_NUMBER, MILESTONE_FILTER)
stats = tracker.fetch_release_blocker_stats()
# Output for GitHub Actions
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")
f.write(f"blocker_total={stats['blocker_total']}\n")
f.write(f"blocker_done={stats['blocker_done']}\n")
f.write(f"mustdo_total={stats['mustdo_total']}\n")
f.write(f"mustdo_done={stats['mustdo_done']}\n")
f.write(f"medium_total={stats['medium_total']}\n")
f.write(f"medium_done={stats['medium_done']}\n")
f.write(f"low_total={stats['low_total']}\n")
f.write(f"low_done={stats['low_done']}\n")
# Also output to stdout for local testing
print(f"percentage={stats['percentage']}")
print(f"blocker_done={stats['blocker_done']}")
print(f"blocker_total={stats['blocker_total']}")
print(f"mustdo_done={stats['mustdo_done']}")
print(f"mustdo_total={stats['mustdo_total']}")
print(f"medium_done={stats['medium_done']}")
print(f"medium_total={stats['medium_total']}")
print(f"low_done={stats['low_done']}")
print(f"low_total={stats['low_total']}")
print(f"Calculated progress: {stats['percentage']}%")
print(f"Done / Total: {stats['done']} / {stats['total']}")
print(f"Critical Priority: {stats['blocker_done']} / {stats['blocker_total']}")
print(f"High Priority: {stats['mustdo_done']} / {stats['mustdo_total']}")
print(f"Medium Priority: {stats['medium_done']} / {stats['medium_total']}")
print(f"Low Priority: {stats['low_done']} / {stats['low_total']}")
if MILESTONE_FILTER:
print(f"Milestone filter: {MILESTONE_FILTER}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
exit(main())