diff --git a/.github/workflows/update-badge.sh b/.github/workflows/update-badge.sh index 72882213453..28f36bababb 100755 --- a/.github/workflows/update-badge.sh +++ b/.github/workflows/update-badge.sh @@ -15,6 +15,9 @@ # BLOCKER_TOTAL - Total number of blockers # MUSTDO_DONE - Number of completed must-dos # MUSTDO_TOTAL - Total number of must-dos +# NICETOHAVE_DONE - Number of completed nice-to-haves +# NICETOHAVE_TOTAL - Total number of nice-to-haves +# VERSION - Version string (e.g., "2.1") - optional # set -euo pipefail @@ -83,32 +86,80 @@ create_badge_json() { local percentage="$4" local color="$5" + # Handle 0/0 case (when percentage is -1 or total is 0) + local message + if [ "$total" -eq 0 ] || [ "$percentage" = "-1.0" ]; then + message="0/0" + else + message="$done/$total ($percentage%)" + fi + jq -n \ --arg label "$label" \ - --arg percentage "$percentage" \ - --arg done "$done" \ - --arg total "$total" \ + --arg message "$message" \ --arg color "$color" \ '{ "schemaVersion": 1, "label": $label, - "message": "\($done)/\($total) (\($percentage)%)", + "message": $message, "color": $color, "style": "flat-square" }' } # Calculate percentages for each category -BLOCKER_PERCENTAGE=$(awk "BEGIN {printf \"%.1f\", ($BLOCKER_DONE / $BLOCKER_TOTAL * 100)}" 2>/dev/null || echo "0") -MUSTDO_PERCENTAGE=$(awk "BEGIN {printf \"%.1f\", ($MUSTDO_DONE / $MUSTDO_TOTAL * 100)}" 2>/dev/null || echo "0") +if [ "$BLOCKER_TOTAL" -eq 0 ]; then + BLOCKER_PERCENTAGE="-1.0" +else + BLOCKER_PERCENTAGE=$(awk "BEGIN {printf \"%.1f\", ($BLOCKER_DONE / $BLOCKER_TOTAL * 100)}") +fi -# Determine colors using the shared function -BLOCKER_COLOR=$(get_badge_color "$BLOCKER_PERCENTAGE") -MUSTDO_COLOR=$(get_badge_color "$MUSTDO_PERCENTAGE") +if [ "$MUSTDO_TOTAL" -eq 0 ]; then + MUSTDO_PERCENTAGE="-1.0" +else + MUSTDO_PERCENTAGE=$(awk "BEGIN {printf \"%.1f\", ($MUSTDO_DONE / $MUSTDO_TOTAL * 100)}") +fi + +if [ "$NICETOHAVE_TOTAL" -eq 0 ]; then + NICETOHAVE_PERCENTAGE="-1.0" +else + NICETOHAVE_PERCENTAGE=$(awk "BEGIN {printf \"%.1f\", ($NICETOHAVE_DONE / $NICETOHAVE_TOTAL * 100)}") +fi + +# Determine colors using the shared function (use lightgrey for 0/0) +if [ "$BLOCKER_PERCENTAGE" = "-1.0" ]; then + BLOCKER_COLOR="lightgrey" +else + BLOCKER_COLOR=$(get_badge_color "$BLOCKER_PERCENTAGE") +fi + +if [ "$MUSTDO_PERCENTAGE" = "-1.0" ]; then + MUSTDO_COLOR="lightgrey" +else + MUSTDO_COLOR=$(get_badge_color "$MUSTDO_PERCENTAGE") +fi + +if [ "$NICETOHAVE_PERCENTAGE" = "-1.0" ]; then + NICETOHAVE_COLOR="lightgrey" +else + NICETOHAVE_COLOR=$(get_badge_color "$NICETOHAVE_PERCENTAGE") +fi + +# Determine badge labels - include version if available +if [ -n "${VERSION:-}" ] && [ "$VERSION" != "all" ]; then + BLOCKER_LABEL="${VERSION} Release Blockers" + MUSTDO_LABEL="${VERSION} Release Must Do" + NICETOHAVE_LABEL="${VERSION} Release Nice to Have" +else + BLOCKER_LABEL="Release Blockers" + MUSTDO_LABEL="Release Must Do" + NICETOHAVE_LABEL="Release Nice to Have" +fi # Create badge JSONs using the shared function -BLOCKER_BADGE_JSON=$(create_badge_json "Release Blockers" "$BLOCKER_DONE" "$BLOCKER_TOTAL" "$BLOCKER_PERCENTAGE" "$BLOCKER_COLOR") -MUSTDO_BADGE_JSON=$(create_badge_json "Release Must Do" "$MUSTDO_DONE" "$MUSTDO_TOTAL" "$MUSTDO_PERCENTAGE" "$MUSTDO_COLOR") +BLOCKER_BADGE_JSON=$(create_badge_json "$BLOCKER_LABEL" "$BLOCKER_DONE" "$BLOCKER_TOTAL" "$BLOCKER_PERCENTAGE" "$BLOCKER_COLOR") +MUSTDO_BADGE_JSON=$(create_badge_json "$MUSTDO_LABEL" "$MUSTDO_DONE" "$MUSTDO_TOTAL" "$MUSTDO_PERCENTAGE" "$MUSTDO_COLOR") +NICETOHAVE_BADGE_JSON=$(create_badge_json "$NICETOHAVE_LABEL" "$NICETOHAVE_DONE" "$NICETOHAVE_TOTAL" "$NICETOHAVE_PERCENTAGE" "$NICETOHAVE_COLOR") # Validate JSONs were created successfully if [ -z "$BLOCKER_BADGE_JSON" ] || ! echo "$BLOCKER_BADGE_JSON" | jq empty 2>/dev/null; then @@ -119,18 +170,25 @@ if [ -z "$MUSTDO_BADGE_JSON" ] || ! echo "$MUSTDO_BADGE_JSON" | jq empty 2>/dev/ echo "::error::Failed to generate valid must-do badge JSON" exit 1 fi +if [ -z "$NICETOHAVE_BADGE_JSON" ] || ! echo "$NICETOHAVE_BADGE_JSON" | jq empty 2>/dev/null; then + echo "::error::Failed to generate valid nice-to-have badge JSON" + exit 1 +fi # The filenames in the Gist BLOCKER_GIST_NAME="release-blocker-${GITHUB_REPOSITORY##*/}.json" MUSTDO_GIST_NAME="release-mustdo-${GITHUB_REPOSITORY##*/}.json" -echo "::notice::Updating Gist files: $BLOCKER_GIST_NAME, $MUSTDO_GIST_NAME" +NICETOHAVE_GIST_NAME="release-nicetohave-${GITHUB_REPOSITORY##*/}.json" +echo "::notice::Updating Gist files: $BLOCKER_GIST_NAME, $MUSTDO_GIST_NAME, $NICETOHAVE_GIST_NAME" -# Create the request payload with both files +# Create the request payload with all three files REQUEST_PAYLOAD=$(jq -n \ --arg blocker_filename "$BLOCKER_GIST_NAME" \ --arg mustdo_filename "$MUSTDO_GIST_NAME" \ + --arg nicetohave_filename "$NICETOHAVE_GIST_NAME" \ --argjson blocker_content "$BLOCKER_BADGE_JSON" \ --argjson mustdo_content "$MUSTDO_BADGE_JSON" \ + --argjson nicetohave_content "$NICETOHAVE_BADGE_JSON" \ '{ "files": { ($blocker_filename): { @@ -138,6 +196,9 @@ REQUEST_PAYLOAD=$(jq -n \ }, ($mustdo_filename): { "content": ($mustdo_content | tostring) + }, + ($nicetohave_filename): { + "content": ($nicetohave_content | tostring) } } }') @@ -166,15 +227,18 @@ echo "::notice::Gist updated successfully" # Generate badge URLs for use in README BLOCKER_BADGE_URL="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/${GITHUB_REPOSITORY_OWNER}-Bot/${GIST_ID}/raw/${BLOCKER_GIST_NAME}" MUSTDO_BADGE_URL="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/${GITHUB_REPOSITORY_OWNER}-Bot/${GIST_ID}/raw/${MUSTDO_GIST_NAME}" +NICETOHAVE_BADGE_URL="https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/${GITHUB_REPOSITORY_OWNER}-Bot/${GIST_ID}/raw/${NICETOHAVE_GIST_NAME}" PROJECT_URL="https://github.com/${GITHUB_REPOSITORY}/projects/39" echo "::notice::Blocker Badge URL: $BLOCKER_BADGE_URL" echo "::notice::Must-Do Badge URL: $MUSTDO_BADGE_URL" +echo "::notice::Nice-to-Have Badge URL: $NICETOHAVE_BADGE_URL" # Output to GitHub Actions if GITHUB_OUTPUT is set if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "blocker_badge_url=$BLOCKER_BADGE_URL" >> "$GITHUB_OUTPUT" echo "mustdo_badge_url=$MUSTDO_BADGE_URL" >> "$GITHUB_OUTPUT" + echo "nicetohave_badge_url=$NICETOHAVE_BADGE_URL" >> "$GITHUB_OUTPUT" echo "project_url=$PROJECT_URL" >> "$GITHUB_OUTPUT" fi @@ -194,11 +258,16 @@ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then **Progress:** ${MUSTDO_DONE} of ${MUSTDO_TOTAL} completed (${MUSTDO_PERCENTAGE}%) **Badge Color:** ${MUSTDO_COLOR} +### 💡 Release Nice to Have +**Progress:** ${NICETOHAVE_DONE} of ${NICETOHAVE_TOTAL} completed (${NICETOHAVE_PERCENTAGE}%) +**Badge Color:** ${NICETOHAVE_COLOR} + **Gist ID:** ${GIST_ID} ### Badge URLs **Blocker Markdown:** \`[![Release Blocker Progress](${BLOCKER_BADGE_URL})](${PROJECT_URL})\` **Must-Do Markdown:** \`[![Release Must Do Progress](${MUSTDO_BADGE_URL})](${PROJECT_URL})\` +**Nice-to-Have Markdown:** \`[![Release Nice to Have Progress](${NICETOHAVE_BADGE_URL})](${PROJECT_URL})\` ### Badge JSON Preview \`\`\`json @@ -207,6 +276,9 @@ ${BLOCKER_BADGE_JSON} // Must-Do Badge ${MUSTDO_BADGE_JSON} + +// Nice-to-Have Badge +${NICETOHAVE_BADGE_JSON} \`\`\` EOF fi diff --git a/.github/workflows/update-progress.py b/.github/workflows/update-progress.py index b690deac1b0..cd3cbd7c95c 100644 --- a/.github/workflows/update-progress.py +++ b/.github/workflows/update-progress.py @@ -28,15 +28,20 @@ FIELD_STATUS = "Status" # Expected values for Release gating field VALUE_RELEASE_BLOCKER = "Release_Blocker" VALUE_RELEASE_MUST_DO = "Release_Must Do" +VALUE_RELEASE_NICE_TO_HAVE = "Release_Nice to Have" # 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 release blocker progress in GitHub projects.""" - def __init__(self, token: str, owner: str, project_number: int): + 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}", @@ -44,6 +49,7 @@ class GitHubProjectTracker: } 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.""" @@ -75,7 +81,12 @@ class GitHubProjectTracker: } } } - content { ... on Issue { id, title, url } } + content { + ... on Issue { + id, title, url + milestone { title } + } + } } } } @@ -115,16 +126,18 @@ class GitHubProjectTracker: def fetch_release_blocker_stats(self) -> Dict[str, int]: """ - Fetches release blocker and must-do statistics from the GitHub project. + Fetches release blocker, must-do, and nice-to-have statistics from the GitHub project. Returns: Dict with 'total', 'done', 'percentage', 'blocker_total', 'blocker_done', - 'mustdo_total', 'mustdo_done' keys + 'mustdo_total', 'mustdo_done', 'nicetohave_total', 'nicetohave_done' keys """ blocker_total = 0 blocker_done = 0 mustdo_total = 0 mustdo_done = 0 + nicetohave_total = 0 + nicetohave_done = 0 cursor = None # Track if we've seen the expected fields at least once @@ -162,6 +175,17 @@ class GitHubProjectTracker: 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 @@ -181,6 +205,10 @@ class GitHubProjectTracker: mustdo_total += 1 if status == VALUE_STATUS_DONE: mustdo_done += 1 + elif release_gating == VALUE_RELEASE_NICE_TO_HAVE: + nicetohave_total += 1 + if status == VALUE_STATUS_DONE: + nicetohave_done += 1 # Check for next page page_info = items.get("pageInfo", {}) @@ -219,16 +247,22 @@ class GitHubProjectTracker: # Validate that we found at least some items # If total is 0, either the project is empty or field matching failed if total == 0: - print("ERROR: No release blocker or must-do items found (total=0).", file=sys.stderr) - print("This likely indicates:", file=sys.stderr) - print(f" 1. The '{FIELD_RELEASE_GATING}' field values changed", file=sys.stderr) - print(f" Expected values: '{VALUE_RELEASE_BLOCKER}' or '{VALUE_RELEASE_MUST_DO}'", 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 release items found - refusing to report false completion status") - - percentage = round((done / total * 100), 1) + if self.milestone_filter: + print(f"INFO: No release blocker or must-do 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 release blocker or must-do items found (total=0).", file=sys.stderr) + print("This likely indicates:", file=sys.stderr) + print(f" 1. The '{FIELD_RELEASE_GATING}' field values changed", file=sys.stderr) + print(f" Expected values: '{VALUE_RELEASE_BLOCKER}' or '{VALUE_RELEASE_MUST_DO}'", 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 release items found - refusing to report false completion status") + else: + percentage = round((done / total * 100), 1) return { 'total': total, @@ -237,19 +271,68 @@ class GitHubProjectTracker: 'blocker_total': blocker_total, 'blocker_done': blocker_done, 'mustdo_total': mustdo_total, - 'mustdo_done': mustdo_done + 'mustdo_done': mustdo_done, + 'nicetohave_total': nicetohave_total, + 'nicetohave_done': nicetohave_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 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) + try: - tracker = GitHubProjectTracker(TOKEN, OWNER, PROJECT_NUMBER) + tracker = GitHubProjectTracker(TOKEN, OWNER, PROJECT_NUMBER, MILESTONE_FILTER) stats = tracker.fetch_release_blocker_stats() # Output for GitHub Actions @@ -263,6 +346,9 @@ def main(): 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"nicetohave_total={stats['nicetohave_total']}\n") + f.write(f"nicetohave_done={stats['nicetohave_done']}\n") + f.write(f"version={MILESTONE_FILTER or 'all'}\n") # Also output to stdout for local testing print(f"percentage={stats['percentage']}") @@ -270,10 +356,16 @@ def main(): print(f"blocker_total={stats['blocker_total']}") print(f"mustdo_done={stats['mustdo_done']}") print(f"mustdo_total={stats['mustdo_total']}") + print(f"nicetohave_done={stats['nicetohave_done']}") + print(f"nicetohave_total={stats['nicetohave_total']}") + print(f"version={MILESTONE_FILTER or 'all'}") print(f"Calculated progress: {stats['percentage']}%") print(f"Done / Total: {stats['done']} / {stats['total']}") print(f"Blockers: {stats['blocker_done']} / {stats['blocker_total']}") print(f"Must Do: {stats['mustdo_done']} / {stats['mustdo_total']}") + print(f"Nice to Have: {stats['nicetohave_done']} / {stats['nicetohave_total']}") + if MILESTONE_FILTER: + print(f"Milestone filter: {MILESTONE_FILTER}") except Exception as e: print(f"Error: {e}", file=sys.stderr) diff --git a/.github/workflows/update-progress.yml b/.github/workflows/update-progress.yml index fb1d99d8640..ef42833fbe2 100644 --- a/.github/workflows/update-progress.yml +++ b/.github/workflows/update-progress.yml @@ -115,11 +115,14 @@ jobs: exit 1 fi - # Extract blocker and must-do counts + # Extract blocker, must-do, and nice-to-have counts BLOCKER_DONE=$(grep "^blocker_done=" progress_output.txt | cut -d'=' -f2 | head -1) BLOCKER_TOTAL=$(grep "^blocker_total=" progress_output.txt | cut -d'=' -f2 | head -1) MUSTDO_DONE=$(grep "^mustdo_done=" progress_output.txt | cut -d'=' -f2 | head -1) MUSTDO_TOTAL=$(grep "^mustdo_total=" progress_output.txt | cut -d'=' -f2 | head -1) + NICETOHAVE_DONE=$(grep "^nicetohave_done=" progress_output.txt | cut -d'=' -f2 | head -1) + NICETOHAVE_TOTAL=$(grep "^nicetohave_total=" progress_output.txt | cut -d'=' -f2 | head -1) + VERSION=$(grep "^version=" progress_output.txt | cut -d'=' -f2 | head -1) # Set outputs for use in subsequent steps echo "percentage=$PERCENTAGE" >> $GITHUB_OUTPUT @@ -129,9 +132,12 @@ jobs: echo "blocker_total=$BLOCKER_TOTAL" >> $GITHUB_OUTPUT echo "mustdo_done=$MUSTDO_DONE" >> $GITHUB_OUTPUT echo "mustdo_total=$MUSTDO_TOTAL" >> $GITHUB_OUTPUT + echo "nicetohave_done=$NICETOHAVE_DONE" >> $GITHUB_OUTPUT + echo "nicetohave_total=$NICETOHAVE_TOTAL" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT echo "::notice::Progress calculation successful: ${PERCENTAGE}% (${DONE}/${TOTAL})" - echo "::notice:: Blockers: ${BLOCKER_DONE}/${BLOCKER_TOTAL}, Must Do: ${MUSTDO_DONE}/${MUSTDO_TOTAL}" + echo "::notice:: Version: ${VERSION}, Blockers: ${BLOCKER_DONE}/${BLOCKER_TOTAL}, Must Do: ${MUSTDO_DONE}/${MUSTDO_TOTAL}, Nice to Have: ${NICETOHAVE_DONE}/${NICETOHAVE_TOTAL}" # Clean up rm -f progress_output.txt @@ -152,6 +158,9 @@ jobs: BLOCKER_TOTAL: ${{ steps.progress.outputs.blocker_total }} MUSTDO_DONE: ${{ steps.progress.outputs.mustdo_done }} MUSTDO_TOTAL: ${{ steps.progress.outputs.mustdo_total }} + NICETOHAVE_DONE: ${{ steps.progress.outputs.nicetohave_done }} + NICETOHAVE_TOTAL: ${{ steps.progress.outputs.nicetohave_total }} + VERSION: ${{ steps.progress.outputs.version }} run: | # Execute dedicated badge generation script # This separates concerns: YAML orchestrates, scripts implement logic diff --git a/README.md b/README.md index 6bcd4135d67..4f85af0c84a 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,13 @@ least one annual release for each maintenance branch. ### Release Progress -[![Release Blockers](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/brtnfld/a4fbdde293677bf3f63f3cfd3aef6b44/raw/release-blockers.json)](https://github.com/orgs/HDFGroup/projects/39/views/24) +[![Release Blockers](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/HDFGroup-Bot/0ad2eabb63b28eb90d69f5e5b2c1496f/raw/release-blocker-hdf5.json)](https://github.com/orgs/HDFGroup/projects/39/views/24) -[![Release Must Do](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/brtnfld/8a077a919d048ce5dc9f56350bec10bc/raw/release-mustdo.json)](https://github.com/orgs/HDFGroup/projects/39/views/24) +[![Release Must Do](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/HDFGroup-Bot/0ad2eabb63b28eb90d69f5e5b2c1496f/raw/release-mustdo-hdf5.json)](https://github.com/orgs/HDFGroup/projects/39/views/24) -The badges above show the current progress of **release-blocking** and **must-do** issues with colors that reflect completion status: +[![Release Nice to Have](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/HDFGroup-Bot/0ad2eabb63b28eb90d69f5e5b2c1496f/raw/release-nicetohave-hdf5.json)](https://github.com/orgs/HDFGroup/projects/39/views/24) + +The badges above show the current progress of **release-blocking**, **must-do**, and **nice-to-have** issues with colors that reflect completion status: - 🟢 **Green (90%+)**: Readying for Deployment - most issues completed - 🟡 **Yellow (60-89%)**: Nearing Completion - on track for release