mirror of
https://github.com/HDFGroup/hdf5.git
synced 2026-09-25 04:09:44 +03:00
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: 100) {
|
|
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())
|