diff --git a/.github/ISSUE_TEMPLATE/Helpdesk.yml b/.github/ISSUE_TEMPLATE/Helpdesk.yml index 8f1e9e1..e8f7856 100644 --- a/.github/ISSUE_TEMPLATE/Helpdesk.yml +++ b/.github/ISSUE_TEMPLATE/Helpdesk.yml @@ -2,7 +2,6 @@ name: Helpdesk Request description: Support request for build/config/runtime/performance/science questions title: "[Helpdesk] " labels: ["helpdesk"] -assignees: [gibbsp] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml index 4de2fed..9500334 100644 --- a/.github/ISSUE_TEMPLATE/config.yaml +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Submit a Helpdesk ticket + url: https://github.com/JCSDA-internal/.github/issues/new?template=Helpdesk.yml + about: Support requests (build, runtime, performance, science) — opens in the centralized helpdesk repo - name: JCSDA url: https://jcsda.org/ about: JCSDA web site diff --git a/.github/helpdesk/SETUP.md b/.github/helpdesk/SETUP.md new file mode 100644 index 0000000..7621bad --- /dev/null +++ b/.github/helpdesk/SETUP.md @@ -0,0 +1,211 @@ +# 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 new file mode 100644 index 0000000..38383fa --- /dev/null +++ b/.github/helpdesk/org_assignee_map.json @@ -0,0 +1,14 @@ +{ + "_readme": "Map partner organization names to their JCSDA GitHub liaison username. Keys are matched case-insensitively as substrings against the free-text 'Requesting Organization' field. Replace PLACEHOLDER values with real GitHub usernames before deploying.", + + "default_assignee": "gibbsp", + + "NOAA/NWS/EMC": "gibbsp", + "NOAA/GFDL": "gibbsp", + "NRL Monterey": "gibbsp", + "NASA/GMAO": "gibbsp", + "AFWA": "gibbsp", + "Navy": "gibbsp", + "Air Force": "gibbsp", + "University": "gibbsp" +} diff --git a/.github/scripts/helpdesk_sheet_sync.py b/.github/scripts/helpdesk_sheet_sync.py new file mode 100644 index 0000000..5f19c80 --- /dev/null +++ b/.github/scripts/helpdesk_sheet_sync.py @@ -0,0 +1,595 @@ +#!/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 yaml + +import gspread +import requests + +# ── Retry helpers ──────────────────────────────────────────────────────────── + +_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) + return m.group(1).strip() if m else "" + + +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) + return text.strip() if text is not None else "" + + +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) + + +# ── Issue template helpers ──────────────────────────────────────────────────── + +def load_field_placeholder(template_path: str, field_id: str) -> str: + """ + Return the stripped default `value` for a textarea field in a GitHub issue + form YAML template, identified by its `id`. Returns '' if not found. + """ + with open(template_path) as f: + template = yaml.safe_load(f) + for field in template.get("body", []): + if field.get("id") == field_id: + return field.get("attributes", {}).get("value", "").strip() + return "" + + +# ── Org → assignee lookup ───────────────────────────────────────────────────── + +_PLACEHOLDER_ORGS = {"na", "n/a", "none", "unknown", "n.a.", "not applicable", ""} + +def match_org(requesting_org: str, org_map: dict) -> str | None: + """ + Case-insensitive substring match: org_map key appears in requesting_org, + or requesting_org appears in the key. Falls back to org_map['default_assignee'] + when no specific match is found (including placeholder/unknown org values). + Returns the GitHub username or None. + """ + default = org_map.get("default_assignee") + org_lower = requesting_org.lower().strip() + if org_lower in _PLACEHOLDER_ORGS: + return default + for key, assignee in org_map.items(): + if key.startswith("_") or key == "default_assignee": + continue + if key.lower() in org_lower or org_lower in key.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 = requests.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) -> float: + a = datetime.datetime.fromisoformat(a_iso) + b = datetime.datetime.fromisoformat(b_iso) + return round((b - a).total_seconds() / 86400.0, 2) + + +# ── 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 + requests = [] + + # Merge row-1 cells within each section + for _, start_col, end_col in _SECTIONS: + 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 + 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 + 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 {} + 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": 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 = requests.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 = requests.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") + template_path = os.path.join(script_dir, "..", "ISSUE_TEMPLATE", "Helpdesk.yml") + 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") + resolution_placeholder = load_field_placeholder(template_path, "resolution") + if resolution_placeholder and resolution_description.strip() == resolution_placeholder: + resolution_description = "" + # ── Auto-assign on open/label/assign events when no assignee is set ────── + # Includes "labeled" because GitHub applies template labels near-simultaneously + # with "opened", and the labeled event can fire first and cancel the opened run. + # Excluding other events (edit, reopen, etc.) prevents silently undoing manual unassignment. + if not assignees and event_action in {"opened", "labeled"}: + liaison = match_org(requesting_org, org_map) + if liaison: + try: + gh_assign(repo_owner, repo_name, issue_number, [liaison], token) + assignees = [liaison] + print(f"Auto-assigned issue #{issue_number} to {liaison} " + f"(matched org: {requesting_org!r})") + except Exception as exc: + # Non-fatal: sheet sync continues even if assignment fails. + print(f"Warning: could not auto-assign issue #{issue_number}: {exc}") + else: + print(f"No org match found for {requesting_org!r}; skipping auto-assign.") + + # ── Computed fields ─────────────────────────────────────────────────────── + status = "Closed" if issue_state == "closed" else "Open" + time_to_close = (str(days_between(issue_created_at, issue_closed_at)) + if issue_closed_at else "") + + # ── Open the worksheet and locate any existing row ──────────────────────── + ws = open_worksheet(sheet_id, creds_dict) + all_rows = ws.get_all_values() + row_idx = find_issue_row(ws, repo, issue_number, all_rows) + + # ── Preserve manually-maintained columns from the existing row ──────────── + existing_notes = "" + if row_idx is not None: + notes_col_idx = COLUMNS.index("notes") + 1 # 1-based + existing_notes = ws.cell(row_idx, notes_col_idx).value or "" + + # Only fetch story points on open/close; the Projects Estimate field doesn't + # change on label/assign/edit events and the GraphQL call costs quota. + if event_action in {"opened", "closed", "edited"}: + story_points = get_estimate_from_project(repo_owner, repo_name, issue_number, token) + elif row_idx is not None: + sp_col_idx = COLUMNS.index("story_points") + 1 # 1-based + story_points = ws.cell(row_idx, sp_col_idx).value or "" + else: + story_points = "" + + # ── Build row (order matches COLUMNS exactly) ───────────────────────────── + # The url cell uses a HYPERLINK formula so it renders as a clickable link. + url_cell = f'=HYPERLINK("{issue_url}", "#{issue_number}")' + + row = [ + # Ticket Information (A–E) + str(issue_number), + repo, + issue_title, + url_cell, + ", ".join(label_names), + # Requester Information (F–L) + issue_author, + issue_created_at, + requesting_org, + category, + impact, + reproducibility, + platform, + # Work Tracking (M–Q) + ", ".join(assignees), + status, + issue_closed_at, + time_to_close, + story_points, + # Maintainer Notes (R–U) + triage_category, + root_cause, + resolution_description, + existing_notes, # preserved — never clobbered by automation + ] + + # ── Write to sheet ──────────────────────────────────────────────────────── + if row_idx is not None: + range_notation = f"A{row_idx}:{END_COL}{row_idx}" + # USER_ENTERED is required so the =HYPERLINK() formula is evaluated. + _sheet_write_with_retry( + ws.update, range_notation, [row], value_input_option="USER_ENTERED" + ) + _remove_bold(ws, range_notation) + print(f"Updated row {row_idx} for issue #{issue_number} in {repo} " + f"(event: {event_action}, status: {status})") + else: + _sheet_write_with_retry(ws.append_row, row, value_input_option="USER_ENTERED") + new_row_idx = find_issue_row(ws, repo, issue_number) + if new_row_idx is not None: + _remove_bold(ws, f"A{new_row_idx}:{END_COL}{new_row_idx}") + print(f"Appended new row for issue #{issue_number} in {repo} " + f"(event: {event_action})") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/helpdesk-sheet-sync.yml b/.github/workflows/helpdesk-sheet-sync.yml new file mode 100644 index 0000000..76959ac --- /dev/null +++ b/.github/workflows/helpdesk-sheet-sync.yml @@ -0,0 +1,55 @@ +name: Helpdesk → Google Sheet sync + +# ── Configuration ──────────────────────────────────────────────────────────── +env: + HELPDESK_SHEET_ID: "1ij5LQwjtjfsdywVfX5YepqeHIs-tufIMW9k8pc_69nc" # paste the Sheet ID from the Google Sheets URL here +# ───────────────────────────────────────────────────────────────────────────── + +# Fires on every issue lifecycle event; the job guard checks for the helpdesk label. +on: + issues: + types: [opened, edited, closed, reopened, assigned, labeled, unlabeled] + +permissions: + issues: write # needed to auto-assign the issue on open + contents: read # needed for actions/checkout to read org_assignee_map.json + repository-projects: read # needed to read the Estimate field from GitHub Projects + +# One sync per issue at a time; cancel stale runs so only the latest event wins. +# Safe because the script always overwrites the row with full current issue state. +concurrency: + group: helpdesk-sheet-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + sync: + if: contains(github.event.issue.labels.*.name, 'helpdesk') + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install "gspread<6" google-auth requests pyyaml + + - name: Sync issue to Google Sheet + env: + # GitHub auth — used to auto-assign the issue on open + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Google Sheets credentials (stored as repo/org secrets) + # GOOGLE_SERVICE_ACCOUNT_JSON : full contents of the service account key JSON file + GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }} + + # Issue context — passing as JSON is the safest way to handle + # arbitrary text (backticks, $, newlines, etc.) in the issue body. + ISSUE_JSON: ${{ toJSON(github.event.issue) }} + EVENT_ACTION: ${{ github.event.action }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + + run: python .github/scripts/helpdesk_sheet_sync.py