diff --git a/.github/helpdesk/SETUP.md b/.github/helpdesk/SETUP.md deleted file mode 100644 index 7621bad..0000000 --- a/.github/helpdesk/SETUP.md +++ /dev/null @@ -1,211 +0,0 @@ -# Helpdesk Sheet Sync — Setup & Test Guide - -## Overview - -This guide walks through setting up and testing the Google Sheets integration -for the JCSDA helpdesk system. On each helpdesk issue event (open, edit, close, -etc.) a GitHub Actions workflow syncs issue data to a shared Google Sheet. - ---- - -## Step 1 — Google Cloud: create the service account (~15 min) - -1. Go to [console.cloud.google.com](https://console.cloud.google.com) -2. Create a new project (e.g., `jcsda-helpdesk`) or select an existing one -3. **Enable APIs**: Navigation menu → APIs & Services → Library - - Search and enable **Google Sheets API** - - Search and enable **Google Drive API** -4. **Create service account**: IAM & Admin → Service Accounts → **+ Create Service Account** - - Name: `jcsda-helpdesk-sheets` - - Skip project role (click through) — permissions come from the Sheet itself - - Click **Done** -5. Click the new service account → **Keys** tab → **Add Key → Create new key → JSON** - - Save the downloaded `.json` file somewhere temporarily (you will paste it into GitHub next) - - Note the service account **email address** - (e.g., `jcsda-helpdesk-sheets@your-project.iam.gserviceaccount.com`) - ---- - -## Step 2 — Create the Google Sheet (~5 min) - -1. Go to [sheets.google.com](https://sheets.google.com) → create a **Blank** spreadsheet -2. Name it `JCSDA Helpdesk Tracker` -3. **Share it with the service account**: Share button → paste the service account - email → set to **Editor** → Send -4. Copy the **Sheet ID** from the URL bar: - ``` - https://docs.google.com/spreadsheets/d/THIS_LONG_STRING_IS_THE_ID/edit - ``` - ---- - -## Step 3 — Configure the Sheet ID and add the GitHub Secret - -**Sheet ID** — open `.github/workflows/helpdesk-sheet-sync.yml` and paste your -Sheet ID into the `HELPDESK_SHEET_ID` variable at the top of the file: - -```yaml -env: - HELPDESK_SHEET_ID: "YOUR_SHEET_ID_HERE" -``` - -**Secret** — go to `github.com/JCSDA-internal/.github/settings/secrets/actions` -and add one secret: - -| Name | Value | -|---|---| -| `GOOGLE_SERVICE_ACCOUNT_JSON` | Paste the **entire contents** of the downloaded `.json` key file | - ---- - -## Step 4 — Add a test entry to the org map - -In [org_assignee_map.json](org_assignee_map.json), add at least one real -org/username pair so you can verify auto-assignment works. Leave the rest as -placeholders for now. - -```json -{ - "NOAA/NWS/EMC": "real-github-username", - ... -} -``` - ---- - -## Step 5 — Open the PR - -Push the branch containing the following files and open a PR in `JCSDA-internal/.github`: - -``` -.github/workflows/helpdesk-sheet-sync.yml ← new -.github/workflows/helpdesk-triage-labels.yml ← bug fix -.github/scripts/helpdesk_sheet_sync.py ← new -.github/helpdesk/org_assignee_map.json ← new -.github/helpdesk/SETUP.md ← this file -.github/ISSUE_TEMPLATE/config.yaml ← updated -``` - -> **Note:** `issues` event workflows only run from the **default branch**. -> The Actions tab will show the workflow files during PR review, but the -> triggers will not fire until the PR is merged. This is expected behavior. - ---- - -## Step 6 — Merge and create the `helpdesk` label - -After merging, confirm the `helpdesk` label exists in the repo (the issue -template references it). Check `github.com/JCSDA-internal/.github/labels` — -if it is not there, create it manually with any color. - ---- - -## Step 7 — Open a test issue - -Go to `github.com/JCSDA-internal/.github/issues/new/choose` → select -**Helpdesk Request** and fill in every required field. Use a **Requesting -Organization** value that matches your test entry in `org_assignee_map.json` -to verify auto-assignment. - ---- - -## Step 8 — Verify - -**GitHub Actions** (`github.com/JCSDA-internal/.github/actions`): -- `Helpdesk → Google Sheet sync` should show a completed run - - Log should contain: `Appended new row for issue #N in JCSDA-internal/.github` -- `Helpdesk triage → labels` should also show a completed run - -**GitHub issue:** -- The configured liaison should appear as assignee -- Labels `helpdesk` and any `triage:*` labels should be applied - -**Google Sheet:** -- Row 1 should be a frozen, locked section-header row (auto-created on first run), - with four merged, labelled bands: **Ticket Information** | **Requester Information** | - **Work Tracking** | **Maintainer Notes** -- Row 2 should be a frozen, locked column-header row -- Row 3 should have all issue fields populated -- Column D (`url`) should display `#N` as a clickable hyperlink - -**Close the test issue** and re-check the sheet: -- `status` → `Closed` -- `closed_at` → timestamp -- `time_to_close_days` → a decimal number - ---- - -## Sheet columns reference - -Row 1 carries merged section banners (locked). Row 2 carries the column headers -(locked). Data begins at row 3. - -### Ticket Information (A–E) - -| Col | Name | Source | -|-----|------|--------| -| A | `issue_number` | Issue number — composite key with B | -| B | `repo` | `owner/repo` — composite key with A | -| C | `title` | Issue title | -| D | `url` | `=HYPERLINK()` formula — renders as clickable `#N` | -| E | `labels` | Comma-separated label names | - -### Requester Information (F–L) - -| Col | Name | Source | -|-----|------|--------| -| F | `opened_by` | Issue author login | -| G | `opened_at` | ISO timestamp | -| H | `requesting_org` | Form field: *Requesting Organization* | -| I | `category` | Form field: *Issue category* | -| J | `impact` | Form field: *Impact / priority* | -| K | `reproducibility` | Form field: *Reproducibility* | -| L | `platform` | Form field: *Platform / system* | - -### Work Tracking (M–Q) - -| Col | Name | Source | -|-----|------|--------| -| M | `assignees` | Comma-separated assignee logins | -| N | `status` | `Open` or `Closed` | -| O | `closed_at` | ISO timestamp, blank if open | -| P | `time_to_close_days` | Decimal days from open to close, blank if open | -| Q | `story_points` | Estimate field from GitHub Projects v2 | - -### Maintainer Notes (R–U) - -| Col | Name | Source | -|-----|------|--------| -| R | `triage_category` | Checked items under *Triage Category / Maintainer Classification* | -| S | `root_cause` | Checked items under *Root Cause* | -| T | `resolution_description` | Form field: *Resolution Description* | -| U | `notes` | **Manually maintained — never overwritten by automation** | - ---- - -## Credential security notes - -- The service account has no GCP project roles — its only access is the single - Sheet it was shared on -- The `drive.file` OAuth scope used by the script limits it to files the service - account created or was explicitly shared on; it cannot browse Drive -- Rotate the service account key periodically via GCP IAM & Admin → Service Accounts -- For production use, replace the JSON key with **Workload Identity Federation** - (no long-lived credentials) — see Google's - [GitHub Actions OIDC guide](https://cloud.google.com/blog/products/identity-security/enabling-keyless-authentication-from-github-actions) - ---- - -## Expanding to org-wide coverage (Option B) - -Currently all helpdesk tickets must be filed in `JCSDA-internal/.github` -because GitHub Actions workflows only fire for events in the repo they live in. -Partners in other repos are directed here via the `contact_links` entry in -`ISSUE_TEMPLATE/config.yaml`. - -When moving to full operational use, the plan is to add an **org-level -webhook** routing `issues` events to an AWS Lambda (following the existing -pattern in `github-admin/webhooks/`). This will allow tickets to be filed in -any JCSDA-Internal repo while still syncing to the same sheet. The `repo` -column already in the spreadsheet schema handles the multi-repo case without -any schema changes. diff --git a/.github/helpdesk/org_assignee_map.json b/.github/helpdesk/org_assignee_map.json deleted file mode 100644 index 6aafd6b..0000000 --- a/.github/helpdesk/org_assignee_map.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "_readme": "Map partner organization names to their JCSDA GitHub liaison username. Keys are matched case-insensitively and exactly against the drop-down 'Requesting Organization' field. Unmatched values fall back to default_assignee.", - - "default_assignee": "gibbsp", - - "CADRE": "xian22", - "NRL-FALCON": "fcvdb", - "NRL-Space Weather": "fcvdb", - "OAR-RRFS": "ncrossette", - "OAR-EPIC": "gibbsp", - "MMM": "BenjaminRuston", - "NASA": "ytremolet", - "NESDIS": "BenjaminRuston", - "NWS": "ytremolet", - "UKMO": "ytremolet", - "USAF": "BenjaminRuston", - "Other": "gibbsp" -} diff --git a/.github/scripts/helpdesk_sheet_sync.py b/.github/scripts/helpdesk_sheet_sync.py deleted file mode 100644 index 5efa267..0000000 --- a/.github/scripts/helpdesk_sheet_sync.py +++ /dev/null @@ -1,599 +0,0 @@ -#!/usr/bin/env python3 -""" -helpdesk_sheet_sync.py -====================== -Syncs a GitHub helpdesk issue to a Google Sheet row. - -Triggered by helpdesk-sheet-sync.yml on issue open / edit / close / reopen / -assign / label events. Each issue occupies exactly one row, keyed by the -composite (repo, issue_number) because the same number can exist in multiple -repos. On every event the row is overwritten with current state except for -the 'notes' column, which is manually maintained by the team and is always -preserved. - -On the 'opened' event the script also auto-assigns the issue to the JCSDA -liaison for the partner organisation (looked up via org_assignee_map.json). - -Required env vars (all set by the workflow): - GH_TOKEN GitHub token with issues:write - GOOGLE_SERVICE_ACCOUNT_JSON Service account JSON key (full file contents) - HELPDESK_SHEET_ID Google Sheet ID from its URL - ISSUE_JSON toJSON(github.event.issue) payload - EVENT_ACTION github.event.action - REPO_OWNER github.repository_owner - REPO_NAME github.event.repository.name -""" - -import json -import os -import re -import time -import random -import datetime - -import gspread -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -# ── Retry helpers ──────────────────────────────────────────────────────────── - -def _make_gh_session() -> requests.Session: - session = requests.Session() - retry = Retry( - total=3, - backoff_factor=0.5, - status_forcelist={500, 502, 503, 504}, - allowed_methods={"GET", "POST"}, - ) - session.mount("https://", HTTPAdapter(max_retries=retry)) - return session - -_GH_SESSION = _make_gh_session() - - - -_RETRY_ATTEMPTS = 5 -_RETRY_BASE = 2.0 # seconds; doubles each attempt + jitter - - -def _sheet_write_with_retry(fn, *args, **kwargs): - """ - Call fn(*args, **kwargs) with exponential-backoff retry on gspread API errors. - - Needed because multiple repos can push helpdesk events simultaneously to the - same Google Sheet, and the Sheets API returns 429 / 503 under write contention. - """ - for attempt in range(_RETRY_ATTEMPTS): - try: - return fn(*args, **kwargs) - except gspread.exceptions.APIError as exc: - status = getattr(exc.response, "status_code", None) - if attempt == _RETRY_ATTEMPTS - 1 or status not in (429, 500, 503): - raise - delay = _RETRY_BASE * (2 ** attempt) + random.uniform(0, 1) - print(f"Sheet API error {status} on attempt {attempt + 1}; " - f"retrying in {delay:.1f}s …") - time.sleep(delay) - - -# ── Sheet config ────────────────────────────────────────────────────────────── - -SHEET_TAB = "Helpdesk Tickets" - -# Column order in the spreadsheet. Must stay in sync with the row list built -# in main() below. -COLUMNS = [ - # ── Ticket Information ──────────────────────────────────────────────────── - "issue_number", # A ┐ composite key — - "repo", # B ┘ both columns together uniquely identify a row - "title", # C - "url", # D written as =HYPERLINK() for clickability - "labels", # E - # ── Requester Information ───────────────────────────────────────────────── - "opened_by", # F - "opened_at", # G - "requesting_org", # H - "category", # I - "impact", # J - "reproducibility", # K - "platform", # L - # ── Work Tracking ───────────────────────────────────────────────────────── - "assignees", # M - "status", # N - "closed_at", # O - "time_to_close_days", # P - "story_points", # Q parsed from GitHub Projects v2 Estimate field - # ── Maintainer Notes ────────────────────────────────────────────────────── - "triage_category", # R - "root_cause", # S - "resolution_description", # T - "notes", # U ← manually maintained; never overwritten by automation -] - -# Row-1 section headers: (label, first_col_1based, last_col_1based inclusive) -_SECTIONS = [ - ("Ticket Information", 1, 5), # A–E - ("Requester Information", 6, 12), # F–L - ("Work Tracking", 13, 17), # M–Q - ("Maintainer Notes", 18, 21), # R–U -] - -GOOGLE_SCOPES = [ - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/drive.file", -] - -# ── Form-field parsing ──────────────────────────────────────────────────────── - -def extract_field(body: str, section_title: str) -> str: - """ - Pull the first non-blank line after a GitHub issue form section header. - - GitHub renders form fields as: - ### Section Title - - Value text - """ - pattern = rf'^###\s+{re.escape(section_title)}\s*\n+([^\n]+)' - m = re.search(pattern, body or "", re.MULTILINE) - if not m: - return "" - return m.group(1).strip() - - -def _section_body(body: str, section_title: str) -> str | None: - m = re.search(rf'^###\s+{re.escape(section_title)}\s*\n(.*?)(?=^###|\Z)', - body or "", re.MULTILINE | re.DOTALL) - return m.group(1) if m else None - - -def extract_section(body: str, section_title: str) -> str: - """ - Return all text under a GitHub issue form section header, up to the next - '###' header or end of body, with leading/trailing whitespace stripped. - """ - text = _section_body(body, section_title) - if not text: - return "" - return text.strip() - - -def extract_checked_items(body: str, section_title: str) -> str: - """ - Return a comma-separated string of checked checkbox labels under a section. - - Matches lines of the form '- [x] Label' (case-insensitive) that appear - after '### Section Title' and before the next '###' header or end of body. - """ - text = _section_body(body, section_title) - if text is None: - return "" - checked = re.findall(r'^\s*-\s*\[x\]\s*(.+)', text, re.MULTILINE | re.IGNORECASE) - return ", ".join(item.strip() for item in checked) - - -# ── Org → assignee lookup ───────────────────────────────────────────────────── - -_NO_ORG_FIELD_VALUES = {"na", "n/a", "none", "unknown", "n.a.", "not applicable", ""} - -def match_org(requesting_org: str, org_map: dict) -> str | None: - """ - Exact match against org_map keys (case-insensitive). Falls back to - org_map['default_assignee'] when no match is found. - Returns the GitHub username or None. - """ - default = org_map.get("default_assignee") - org_lower = requesting_org.lower().strip() - if org_lower in _NO_ORG_FIELD_VALUES: - return default - for key, assignee in org_map.items(): - if key.startswith("_") or key == "default_assignee": # skip metadata keys (e.g. _readme) - continue - if key.lower() == org_lower: - return assignee - return default - - -# ── GitHub API helpers ──────────────────────────────────────────────────────── - -def gh_assign(owner: str, repo: str, issue_number: int, - assignees: list, token: str) -> None: - """Add assignees to a GitHub issue.""" - url = (f"https://api.github.com/repos/{owner}/{repo}" - f"/issues/{issue_number}/assignees") - resp = _GH_SESSION.post( - url, - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - json={"assignees": assignees}, - timeout=30, - ) - resp.raise_for_status() - - -# ── Date helpers ────────────────────────────────────────────────────────────── - -def days_between(a_iso: str, b_iso: str) -> str: - try: - a = datetime.datetime.fromisoformat(a_iso) - b = datetime.datetime.fromisoformat(b_iso) - return str(round((b - a).total_seconds() / 86400.0, 2)) - except ValueError as exc: - print(f"Warning: could not compute time_to_close ({a_iso!r}, {b_iso!r}): {exc}") - return "" - - -# ── Google Sheets helpers ───────────────────────────────────────────────────── - -def col_letter(n: int) -> str: - """Convert 1-based column index to a spreadsheet letter (A, B, … Z, AA …).""" - result = "" - while n > 0: - n, rem = divmod(n - 1, 26) - result = chr(65 + rem) + result - return result - - -END_COL = col_letter(len(COLUMNS)) # "U" for 21 columns - - -def _remove_bold(ws: "gspread.Worksheet", range_notation: str) -> None: - """Remove bold using a narrow fields mask so the HYPERLINK formula is preserved.""" - body = { - "requests": [ - { - "repeatCell": { - "range": gspread.utils.a1_range_to_grid_range(range_notation, ws.id), - "cell": {"userEnteredFormat": {"textFormat": {"bold": False}}}, - "fields": "userEnteredFormat.textFormat.bold", - } - } - ] - } - ws.spreadsheet.batch_update(body) - - -def _setup_header_rows(ws: "gspread.Worksheet", sa_email: str = "") -> None: - """ - Build the two locked header rows on a fresh (or just-inserted) sheet. - - Row 1 — merged section banners: Ticket Information | Requester Information | - Work Tracking | Maintainer Notes - Row 2 — individual column names (COLUMNS list) - - Both rows are frozen and protected; the service-account email (sa_email) - is added as an editor so automation can still reinitialise if needed. - """ - sh = ws.spreadsheet - batch_requests = [] - - # Merge row-1 cells within each section - for _, start_col, end_col in _SECTIONS: - batch_requests.append({ - "mergeCells": { - "range": { - "sheetId": ws.id, - "startRowIndex": 0, "endRowIndex": 1, - "startColumnIndex": start_col - 1, "endColumnIndex": end_col, - }, - "mergeType": "MERGE_ALL", - } - }) - - # Format row 1: bold, centred, light-blue background - batch_requests.append({ - "repeatCell": { - "range": { - "sheetId": ws.id, - "startRowIndex": 0, "endRowIndex": 1, - "startColumnIndex": 0, "endColumnIndex": len(COLUMNS), - }, - "cell": {"userEnteredFormat": { - "textFormat": {"bold": True}, - "horizontalAlignment": "CENTER", - "backgroundColor": {"red": 0.78, "green": 0.87, "blue": 0.95}, - }}, - "fields": "userEnteredFormat(textFormat.bold,horizontalAlignment,backgroundColor)", - } - }) - - # Format row 2: bold, light-grey background - batch_requests.append({ - "repeatCell": { - "range": { - "sheetId": ws.id, - "startRowIndex": 1, "endRowIndex": 2, - "startColumnIndex": 0, "endColumnIndex": len(COLUMNS), - }, - "cell": {"userEnteredFormat": { - "textFormat": {"bold": True}, - "backgroundColor": {"red": 0.9, "green": 0.9, "blue": 0.9}, - }}, - "fields": "userEnteredFormat(textFormat.bold,backgroundColor)", - } - }) - - # Lock rows 1–2; service account retains edit rights, everyone else sees a hard lock - editors_payload = {"users": [sa_email]} if sa_email else {} - batch_requests.append({ - "addProtectedRange": { - "protectedRange": { - "range": {"sheetId": ws.id, "startRowIndex": 0, "endRowIndex": 2}, - "description": "Header rows — do not edit", - "warningOnly": False, - "editors": editors_payload, - } - } - }) - - sh.batch_update({"requests": batch_requests}) - - # Write section labels after merging so they land in the first cell of each region - for label, start_col, _ in _SECTIONS: - ws.update_cell(1, start_col, label) - ws.update(f"A2:{END_COL}2", [COLUMNS]) - ws.freeze(rows=2) - print("Created section and column header rows.") - - -def open_worksheet(sheet_id: str, creds_dict: dict) -> gspread.Worksheet: - """Authenticate and return the helpdesk worksheet, creating it if needed.""" - client = gspread.service_account_from_dict(creds_dict, scopes=GOOGLE_SCOPES) - sh = client.open_by_key(sheet_id) - - try: - ws = sh.worksheet(SHEET_TAB) - except gspread.WorksheetNotFound: - ws = sh.add_worksheet(title=SHEET_TAB, rows=2000, cols=len(COLUMNS)) - - # Row 2 holds the column headers in the new two-row layout. - second_row = ws.row_values(2) - if not second_row or second_row[0] != "issue_number": - # Handle sheets that were initialised with the old single-header format: - # insert a blank row above so existing column headers shift to row 2. - first_row = ws.row_values(1) - if first_row and first_row[0] == "issue_number": - ws.insert_row([""] * len(COLUMNS), index=1) - _setup_header_rows(ws, creds_dict.get("client_email", "")) - - return ws - - -def find_issue_row( - ws: gspread.Worksheet, - repo: str, - issue_number: int, - all_rows: list[list[str]] | None = None, -) -> int | None: - """ - Return the 1-based row index matching (repo, issue_number), or None. - Both columns are checked because the same issue number can appear in - multiple repositories. Data starts at row 3 (rows 1–2 are headers). - - Pass a pre-fetched all_rows to avoid a redundant get_all_values() call. - """ - if all_rows is None: - all_rows = ws.get_all_values() - for i, row in enumerate(all_rows[2:], start=3): - if len(row) >= 2 and row[0] == str(issue_number) and row[1] == repo: - return i - return None - - -# ── GitHub Projects helpers ─────────────────────────────────────────────────── - -def get_estimate_from_project(owner: str, repo: str, issue_number: int, token: str) -> str: - """Return the Estimate field value (as a string) from GitHub Projects v2, or ''.""" - query = """ - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - projectItems(first: 10) { - nodes { - fieldValues(first: 20) { - nodes { - ... on ProjectV2ItemFieldNumberValue { - number - field { ... on ProjectV2FieldCommon { name } } - } - } - } - } - } - } - } - } - """ - resp = _GH_SESSION.post( - "https://api.github.com/graphql", - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - }, - json={"query": query, "variables": {"owner": owner, "repo": repo, "number": issue_number}}, - timeout=30, - ) - resp.raise_for_status() - data = resp.json() - - items = (data.get("data", {}) - .get("repository", {}) - .get("issue", {}) - .get("projectItems", {}) - .get("nodes", [])) - - for item in items: - for fv in item.get("fieldValues", {}).get("nodes", []): - if (fv.get("field", {}).get("name", "").lower() == "estimate" - and fv.get("number") is not None): - val = fv["number"] - return str(int(val)) if val == int(val) else str(val) - return "" - - -# ── Main ────────────────────────────────────────────────────────────────────── - -def main() -> None: - # Load env - token = os.environ["GH_TOKEN"] - sheet_id = os.environ["HELPDESK_SHEET_ID"] - creds_dict = json.loads(os.environ["GOOGLE_SERVICE_ACCOUNT_JSON"]) - event_action = os.environ["EVENT_ACTION"] - repo_owner = os.environ["REPO_OWNER"] - repo_name = os.environ["REPO_NAME"] - issue = json.loads(os.environ["ISSUE_JSON"]) - - # Unpack issue fields - issue_number = issue["number"] - issue_title = issue["title"] - issue_url = issue["html_url"] - issue_author = issue["user"]["login"] - issue_created_at = issue["created_at"] - - # Refresh state/closed_at from the live API: the event payload is a - # snapshot from when the event fired, so a 'labeled' event generated - # while the issue was still open can arrive after the 'closed' event and - # (via cancel-in-progress) overwrite the sheet with stale "Open" state. - try: - live = _GH_SESSION.get( - f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues/{issue_number}", - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - timeout=30, - ) - live.raise_for_status() - live_data = live.json() - issue["state"] = live_data["state"] - issue["closed_at"] = live_data.get("closed_at") - except Exception as exc: - print(f"Warning: could not refresh issue state from API, using event payload: {exc}") - - issue_closed_at = issue.get("closed_at") or "" - issue_state = issue["state"] - issue_body = issue.get("body") or "" - assignees = [a["login"] for a in issue.get("assignees", [])] - label_names = [lb["name"] for lb in issue.get("labels", [])] - repo = f"{repo_owner}/{repo_name}" - - # Load org → assignee map - script_dir = os.path.dirname(os.path.abspath(__file__)) - map_path = os.path.join(script_dir, "..", "helpdesk", "org_assignee_map.json") - with open(map_path) as f: - org_map = json.load(f) - - # Parse structured form fields from the issue body - requesting_org = extract_field(issue_body, "Requesting Organization") - category = extract_field(issue_body, "Issue category (required for stats)") - impact = extract_field(issue_body, "Impact / priority") - reproducibility = extract_field(issue_body, "Reproducibility") - platform = extract_field(issue_body, "Platform / system (select all that apply)") - - # Extract checkbox fields from the maintainer closure section of the issue body - triage_category = extract_checked_items(issue_body, "Triage Category / Maintainer Classification") - root_cause = extract_checked_items(issue_body, "Root Cause") - resolution_description = extract_section(issue_body, "Resolution Description") - if resolution_description.strip().startswith("