mirror of
https://github.com/HDFGroup/hdf5.git
synced 2026-09-25 04:09:44 +03:00
* 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.
528 lines
22 KiB
Python
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())
|