diff --git a/.github/scripts/issue-version-triage.cjs b/.github/scripts/issue-version-triage.cjs new file mode 100644 index 000000000..6ad4d80b6 --- /dev/null +++ b/.github/scripts/issue-version-triage.cjs @@ -0,0 +1,339 @@ +const VERSION_LABEL_PREFIX = "affects-"; +const NEEDS_VERSION_LABEL = "needs-version-info"; +const RETEST_LABEL = "needs-retest-on-latest"; +const RETEST_COMMENT_MARKER = ""; +const BUG_LABEL = "bug"; +const DOCS_LABEL = "documentation"; +const ENHANCEMENT_LABEL = "enhancement"; +const MAINTAINER_AUTHOR_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +function escapeRegExp(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function extractSectionValue(body, heading) { + if (!body) return null; + const pattern = new RegExp( + `^#+\\s*${escapeRegExp(heading)}\\s*$\\n+([\\s\\S]*?)(?=^#+\\s+|$)`, + "im" + ); + const match = body.match(pattern); + if (!match) return null; + const value = match[1].trim(); + return value || null; +} + +function normalizeVersion(value) { + if (!value) return null; + const match = String(value).match(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/); + return match ? match[1] : null; +} + +function extractPulseVersion(title, body) { + if (body) { + const lines = body.split(/\r?\n/); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] || ""; + if (/pulse\s*(\||-)?\s*version/i.test(line)) { + const inlineVersion = normalizeVersion(line); + if (inlineVersion) return inlineVersion; + + for (let j = i + 1; j < Math.min(i + 6, lines.length); j += 1) { + const nearby = (lines[j] || "").trim(); + if (!nearby) continue; + const nearbyVersion = normalizeVersion(nearby); + if (nearbyVersion) return nearbyVersion; + } + } + } + + const headingMatch = body.match( + /#+\s*Pulse version[\s\S]{0,80}?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i + ); + if (headingMatch) return normalizeVersion(headingMatch[1]); + + const legacyMatch = body.match( + /pulse\s*\|?\s*version[^\n]*?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i + ); + if (legacyMatch) return normalizeVersion(legacyMatch[1]); + } + + return normalizeVersion(title); +} + +function classifyV6FeedbackType(body) { + const feedbackType = extractSectionValue(body, "Feedback type"); + if (!feedbackType) return null; + + const normalized = feedbackType.toLowerCase(); + if ( + normalized.includes("bug") || + normalized.includes("regression") || + normalized.includes("upgrade / migration issue") || + normalized.includes("performance issue") + ) { + return BUG_LABEL; + } + if (normalized.includes("documentation issue")) { + return DOCS_LABEL; + } + if ( + normalized.includes("ux / workflow friction") || + normalized.includes("other actionable feedback") + ) { + return ENHANCEMENT_LABEL; + } + return null; +} + +function parseCore(version) { + const match = String(version || "").match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareCore(a, b) { + const av = parseCore(a); + const bv = parseCore(b); + if (!av || !bv) return null; + for (let i = 0; i < 3; i += 1) { + if (av[i] > bv[i]) return 1; + if (av[i] < bv[i]) return -1; + } + return 0; +} + +async function ensureLabel(github, context, name, color, description) { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color, + description, + }); + } +} + +async function hasRetestComment(github, context, issueNumber) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + return comments.some((comment) => (comment.body || "").includes(RETEST_COMMENT_MARKER)); +} + +async function getLatestStableVersion(github, context, core) { + try { + const latest = await github.rest.repos.getLatestRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + return normalizeVersion(latest.data.tag_name || latest.data.name || ""); + } catch (error) { + core.warning(`Could not determine latest release: ${error.message}`); + return null; + } +} + +function buildTriageState(issue, core, latestVersion) { + const labelNames = new Set((issue.labels || []).map((label) => label.name)); + const nextLabels = new Set(labelNames); + const v6FeedbackClass = classifyV6FeedbackType(issue.body); + if (v6FeedbackClass) { + core.info(`Detected v6 feedback issue class: ${v6FeedbackClass}`); + nextLabels.add(v6FeedbackClass); + } + + const reportedVersion = extractPulseVersion(issue.title, issue.body); + core.info(`Reported Pulse version: ${reportedVersion || "not found"}`); + core.info(`Latest stable release: ${latestVersion || "unknown"}`); + + return { + labelNames, + nextLabels, + reportedVersion, + v6FeedbackClass, + isBugLike: nextLabels.has(BUG_LABEL), + comparison: + reportedVersion && latestVersion ? compareCore(reportedVersion, latestVersion) : null, + }; +} + +function keepOnlyReportedVersionLabel(nextLabels, reportedVersion) { + const keep = `${VERSION_LABEL_PREFIX}${reportedVersion}`; + for (const label of [...nextLabels]) { + if ( + /^affects-\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(label) && + label !== keep + ) { + nextLabels.delete(label); + } + } +} + +function buildRetestCommentBody(reportedVersion, latestVersion) { + return [ + RETEST_COMMENT_MARKER, + "Thanks for the report.", + "", + `I can see this was reported on **v${reportedVersion}**, while the latest stable release is **v${latestVersion}**.`, + `Please retest on **v${latestVersion}** and comment with:`, + "", + "- whether the issue still reproduces", + "- updated logs/diagnostics", + "- exact running image tag or digest", + "", + "If there is no reporter follow-up after 7 days, this issue may be auto-closed until new confirmation is provided.", + "", + "If it still reproduces on the latest version, I will keep this open as an active regression.", + ].join("\n"); +} + +function canPostRetestComment(issue, action) { + const authorAssociation = String(issue.author_association || "").toUpperCase(); + return ( + (action === "opened" || action === "reopened") && + !MAINTAINER_AUTHOR_ASSOCIATIONS.has(authorAssociation) + ); +} + +async function syncLabels({ github, context, core }) { + const issue = context.payload.issue; + const latestVersion = await getLatestStableVersion(github, context, core); + const { + labelNames, + nextLabels, + reportedVersion, + v6FeedbackClass, + isBugLike, + comparison, + } = buildTriageState(issue, core, latestVersion); + + if (!isBugLike) { + core.info("Issue is not bug-like after classification. Skipping version triage."); + if (v6FeedbackClass && !labelNames.has(v6FeedbackClass)) { + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [...nextLabels].sort(), + }); + } + return; + } + + if (reportedVersion) { + await ensureLabel( + github, + context, + `${VERSION_LABEL_PREFIX}${reportedVersion}`, + "0e8a16", + `Bug reported against Pulse ${reportedVersion}` + ); + await ensureLabel( + github, + context, + RETEST_LABEL, + "d93f0b", + "Reporter should retest on current latest stable release" + ); + + keepOnlyReportedVersionLabel(nextLabels, reportedVersion); + nextLabels.add(`${VERSION_LABEL_PREFIX}${reportedVersion}`); + nextLabels.delete(NEEDS_VERSION_LABEL); + + if (comparison !== null && comparison < 0) { + nextLabels.add(RETEST_LABEL); + } else { + nextLabels.delete(RETEST_LABEL); + } + } else { + await ensureLabel( + github, + context, + NEEDS_VERSION_LABEL, + "fbca04", + "Issue is missing required Pulse version metadata" + ); + nextLabels.add(NEEDS_VERSION_LABEL); + nextLabels.delete(RETEST_LABEL); + } + + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [...nextLabels].sort(), + }); +} + +async function postRetestComment({ github, context, core }) { + const issue = context.payload.issue; + const action = context.payload.action || ""; + if (!canPostRetestComment(issue, action)) { + core.info("Public retest guidance is disabled for this issue event."); + return; + } + + const latestVersion = await getLatestStableVersion(github, context, core); + const { reportedVersion, isBugLike, comparison } = buildTriageState( + issue, + core, + latestVersion + ); + + if (!isBugLike) { + core.info("Issue is not bug-like after classification. Skipping public retest guidance."); + return; + } + if (!reportedVersion) { + core.info("Issue is missing Pulse version metadata. Skipping public retest guidance."); + return; + } + if (comparison === null || comparison >= 0) { + core.info("Issue is already on the latest stable core or newer. Skipping public retest guidance."); + return; + } + if (await hasRetestComment(github, context, issue.number)) { + core.info("Retest guidance comment already exists."); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: buildRetestCommentBody(reportedVersion, latestVersion), + }); +} + +module.exports = { + syncLabels, + postRetestComment, + internals: { + BUG_LABEL, + DOCS_LABEL, + ENHANCEMENT_LABEL, + NEEDS_VERSION_LABEL, + RETEST_COMMENT_MARKER, + RETEST_LABEL, + VERSION_LABEL_PREFIX, + buildRetestCommentBody, + buildTriageState, + canPostRetestComment, + classifyV6FeedbackType, + compareCore, + extractPulseVersion, + normalizeVersion, + }, +}; diff --git a/.github/scripts/issue-version-triage.test.cjs b/.github/scripts/issue-version-triage.test.cjs new file mode 100644 index 000000000..b659bcdbc --- /dev/null +++ b/.github/scripts/issue-version-triage.test.cjs @@ -0,0 +1,165 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const triage = require("./issue-version-triage.cjs"); + +function createGithub({ + latestVersion = "6.0.1", + existingLabels = new Set(), + existingComments = [], +} = {}) { + const calls = { + createComment: [], + createLabel: [], + getLabel: [], + getLatestRelease: [], + paginate: [], + setLabels: [], + }; + + const github = { + rest: { + issues: { + async getLabel({ name }) { + calls.getLabel.push(name); + if (existingLabels.has(name)) { + return { data: { name } }; + } + const error = new Error("Not Found"); + error.status = 404; + throw error; + }, + async createLabel(payload) { + calls.createLabel.push(payload); + existingLabels.add(payload.name); + return { data: payload }; + }, + async setLabels(payload) { + calls.setLabels.push(payload); + return { data: payload }; + }, + async createComment(payload) { + calls.createComment.push(payload); + return { data: payload }; + }, + listComments: Symbol("listComments"), + }, + repos: { + async getLatestRelease() { + calls.getLatestRelease.push(true); + return { data: { tag_name: `v${latestVersion}` } }; + }, + }, + }, + async paginate() { + calls.paginate.push(true); + return existingComments; + }, + }; + + return { github, calls }; +} + +function createContext({ action = "opened", issue }) { + return { + payload: { + action, + issue, + }, + repo: { + owner: "rcourtman", + repo: "Pulse", + }, + }; +} + +function createCore() { + return { + info() {}, + warning() {}, + }; +} + +test("syncLabels adds affects and retest labels for older bug reports", async () => { + const { github, calls } = createGithub({ latestVersion: "6.0.1" }); + const issue = { + number: 1402, + title: "Standalone hosts disappear after upgrade", + body: "## Feedback type\nBug / regression\n\n## Pulse version\n6.0.0-rc.1\n", + labels: [], + }; + + await triage.syncLabels({ + github, + context: createContext({ issue }), + core: createCore(), + }); + + assert.equal(calls.setLabels.length, 1); + assert.deepEqual(calls.setLabels[0].labels, [ + "affects-6.0.0-rc.1", + "bug", + "needs-retest-on-latest", + ]); +}); + +test("syncLabels only adds documentation classification for non-bug v6 feedback", async () => { + const { github, calls } = createGithub({ latestVersion: "6.0.1" }); + const issue = { + number: 1415, + title: "Docs path is wrong", + body: "## Feedback type\nDocumentation issue\n\n## Pulse version\n6.0.0-rc.1\n", + labels: [], + }; + + await triage.syncLabels({ + github, + context: createContext({ issue }), + core: createCore(), + }); + + assert.equal(calls.setLabels.length, 1); + assert.deepEqual(calls.setLabels[0].labels, ["documentation"]); +}); + +test("postRetestComment comments once for older non-maintainer bug reports", async () => { + const { github, calls } = createGithub({ latestVersion: "6.0.1" }); + const issue = { + number: 1200, + title: "Upgrade regression", + body: "## Feedback type\nRegression\n\n## Pulse version\n5.1.9\n", + labels: [], + author_association: "NONE", + }; + + await triage.postRetestComment({ + github, + context: createContext({ action: "opened", issue }), + core: createCore(), + }); + + assert.equal(calls.createComment.length, 1); + assert.match( + calls.createComment[0].body, + // + ); +}); + +test("postRetestComment skips maintainer-authored issues", async () => { + const { github, calls } = createGithub({ latestVersion: "6.0.1" }); + const issue = { + number: 1300, + title: "Maintainer split issue on 5.1.9", + body: "## Feedback type\nBug / regression\n\n## Pulse version\n5.1.9\n", + labels: [], + author_association: "OWNER", + }; + + await triage.postRetestComment({ + github, + context: createContext({ action: "opened", issue }), + core: createCore(), + }); + + assert.equal(calls.createComment.length, 0); +}); diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 4186d0a7e..013aa4ad1 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,14 +1,36 @@ # GitHub Actions Workflows +## Issue Triage Automation + +**Files**: +- `issue-version-label-sync.yml` +- `issue-version-retest-comment.yml` + +Issue intake is split deliberately: + +- `issue-version-label-sync.yml` is the silent metadata path. It runs on `opened`, `edited`, and `reopened` issue events so version labels, `needs-version-info`, and `needs-retest-on-latest` stay correct when maintainers tidy issue metadata. +- `issue-version-retest-comment.yml` is the public guidance path. It only runs on `opened` and `reopened`, and only posts reporter-facing retest guidance when the issue is an older-version bug report from a non-maintainer. +- Both workflows load the shared helper at `.github/scripts/issue-version-triage.cjs` so parsing and classification logic lives in one place instead of drifting across duplicated inline scripts. + ## Update Demo Server **File**: `update-demo-server.yml` -Updates the public demo server when the release pipeline dispatches a new stable release deployment, or when run manually. +Automatically updates the governed demo target after a release is published. +Stable releases update the stable public demo. Prerelease tags update the +separate v6 preview demo. ### Configuration Required -Add these secrets to your GitHub repository settings (`Settings` → `Secrets and variables` → `Actions`): +Create two GitHub Environments: + +1. `demo-stable` +2. `demo-preview-v6` + +Each environment must define the same secret names so the workflow can select +the target by environment instead of hardcoding separate workflows. + +Required environment secrets: 1. **DEMO_SERVER_SSH_KEY** - The private SSH key for accessing the demo server @@ -17,39 +39,111 @@ Add these secrets to your GitHub repository settings (`Settings` → `Secrets an 2. **DEMO_SERVER_HOST** - The hostname or IP of the demo server - - Value: `174.138.72.137` (or hostname if using DNS) 3. **DEMO_SERVER_USER** - - The SSH username for the demo server - - Value: `root` (or the appropriate user with sudo access) + - The SSH username for the demo server (e.g. `root` or a deploy user with sudo access) + +Required shared secret: + +1. **TS_AUTHKEY** + - Tailscale auth key used by the governed demo deploy/update workflows before SSH + - Allows GitHub-hosted runners to reach private demo targets such as the stable `pulse-relay` Tailscale host + - May be stored as a repository secret or repeated in the selected environment if desired + +Required environment variables: + +1. **DEMO_EXPECTED_HOSTNAME** + - The remote `hostname` value the selected environment is expected to report + - Stable example: `pulse-relay` + - Preview example: `pulse-v6-preview` + - This is a host-identity guard: the workflow fails closed if the SSH secret points at the wrong machine + +2. **DEMO_LOCAL_BASE_URL** + - Local URL used on the target host for version and mock-mode verification + - Example stable value: `http://localhost:7655` + - Example preview value: `http://localhost:8665` + +3. **DEMO_PUBLIC_HEALTH_URL** + - Public health endpoint for the selected demo target + - Example stable value: `https://demo.pulserelay.pro/api/health` + - Example preview value: `https://v6-demo.pulserelay.pro/api/health` + +Optional environment variables: + +1. **DEMO_SERVICE_NAME** + - Stable default: `pulse` + - Preview example: `pulse-v6-preview` + - When set, the server installer derives the instance-specific install dir, + config dir, update helper, and update timer from this service identity. + +2. **DEMO_AUTH_USER** / **DEMO_AUTH_PASS** + - Demo credentials used for post-update mock verification + - Defaults to `demo` / `demo` when omitted ### How It Works -1. **Trigger**: Dispatched by the release pipeline after a stable release is published, or run manually from the Actions tab -2. **Filter**: The release pipeline only dispatches this for stable releases -3. **Update**: SSHs to demo server and runs the install script -4. **Verify**: Checks that the new version is running and mock mode is active -5. **Cleanup**: Removes SSH key from runner +1. **Trigger**: Runs automatically when a GitHub release is published +2. **Target selection**: Stable tags deploy to `demo-stable`; prerelease tags deploy to `demo-preview-v6` +3. **Service identity guard**: Preview runs default to `pulse-v6-preview` and refuse to target the stable `pulse` service identity +4. **Governance check**: Validates the selected tag is reachable from the governed release branch for that version +5. **Latest check**: Refuses to update a target unless the published tag is the latest release for that target channel +6. **Network attach**: Joins Tailscale before any SSH step so governed demo targets can stay on private hostnames or Tailscale IPs +7. **Update**: SSHs to the selected demo host and runs the tag-matched root installer from that exact git tag +8. **Host identity check**: Verifies the SSH target reports the governed expected hostname before running installer or deploy steps +9. **Verify**: Checks that the new version is running, mock mode is active, and the public demo HTML serves the same frontend entry asset as the target service +10. **Browser smoke**: Uses the governed Playwright helper to prove the public demo still renders the login shell in a real browser +11. **Cleanup**: Removes SSH key from runner ### Testing To test without publishing a release: 1. Go to `Actions` tab in GitHub 2. Select `Update Demo Server` workflow -3. Click `Run workflow` (if manual trigger is enabled) +3. Provide a tag and choose `stable`, `preview-v6`, or `auto` ### Benefits -- ✅ Demo server always showcases latest stable release -- ✅ Validates install script works on real server -- ✅ Removes manual step from release process -- ✅ Free to run (public repos get unlimited GitHub Actions minutes) +- ✅ Stable and preview demos stay on separate governed targets +- ✅ Prereleases no longer require a stable demo overwrite or a manual skip +- ✅ Validates the real server installer path on the selected target +- ✅ Removes release-operator guesswork about which demo should move + +### Preview Bootstrap Note + +The preview environment must be bootstrapped once on the host before the update +workflow can keep it current. The supported path is a separate service identity +such as `pulse-v6-preview` plus a separate public route such as +`v6-demo.pulserelay.pro`; do not reuse the stable `pulse.service` instance. + +## Deploy Demo Server + +**File**: `deploy-demo-server.yml` + +Manually deploys the current branch build to either the stable or preview demo +environment without changing the governed release workflow. + +- Uses the same `demo-stable` / `demo-preview-v6` environment contract as the + release-driven updater +- Joins Tailscale before SSH so governed demo targets can stay on private + addresses instead of requiring public runner reachability +- Requires `DEMO_EXPECTED_HOSTNAME`, `DEMO_LOCAL_BASE_URL`, and `DEMO_PUBLIC_HEALTH_URL` +- Supports optional `DEMO_SERVICE_NAME`, `DEMO_INSTALL_DIR`, `DEMO_TEST_PORT`, + `DEMO_AUTH_USER`, and `DEMO_AUTH_PASS` +- Assumes the target service and install directory already exist on the host +- Defaults preview runs to `pulse-v6-preview` and refuses to target the stable + `pulse` service identity +- Verifies the SSH target reports the governed expected hostname before deploy +- Verifies that the public demo shell serves the same frontend entry asset that + was built and deployed +- Uses `scripts/run_demo_public_browser_smoke.sh` to prove the public demo + still renders the login shell in Chromium after deploy/update verification ## Helm CI **File**: `helm-ci.yml` -Runs `helm lint --strict` and renders the chart with common configuration combinations on every pull request that touches Helm content (and on pushes to `main` and `release/5.1`). This prevents regressions before they land. +Runs `helm lint --strict` and renders the chart with common configuration combinations on every pull request that touches Helm content (and on pushes to `main`). This prevents regressions before they land. + - Triggered by PRs/pushes touching `deploy/helm/**`, docs, or the workflow itself - Uses Helm v3.15.2 - Renders both the default deployment and an agent-enabled configuration to catch template issues @@ -58,8 +152,9 @@ Runs `helm lint --strict` and renders the chart with common configuration combin **File**: `publish-helm-chart.yml` -Packages the Helm chart and pushes it to the GitHub Container Registry (OCI) when dispatched by the release pipeline, or manually via workflow dispatch. Also makes the packaged `.tgz` available as both an Actions artifact and a release asset. The same behaviour can be triggered locally via `./scripts/package-helm-chart.sh [--push]`. -- Triggered by the release pipeline via workflow dispatch, or manually from the Actions tab +Packages the Helm chart and pushes it to the GitHub Container Registry (OCI) whenever a GitHub Release is published. Also makes the packaged `.tgz` available as both an Actions artifact and a release asset. The same behaviour can be triggered locally via `./scripts/package-helm-chart.sh [--push]`. + +- Triggered automatically on `release: published`, or manually via workflow dispatch (requires `chart_version` input) - Chart and app versions mirror the Pulse release tag (e.g., `v4.24.0` → `4.24.0`) - Publishes to `oci://ghcr.io//pulse-chart` - Requires no additional secrets—uses the built-in `GITHUB_TOKEN` with `packages: write` permission diff --git a/.github/workflows/issue-version-label-sync.yml b/.github/workflows/issue-version-label-sync.yml new file mode 100644 index 000000000..abc3e580b --- /dev/null +++ b/.github/workflows/issue-version-label-sync.yml @@ -0,0 +1,31 @@ +name: Issue Version Label Sync + +on: + issues: + types: + - opened + - edited + - reopened + +permissions: + contents: read + issues: write + +jobs: + sync: + if: ${{ github.event.issue.pull_request == null }} + runs-on: ubuntu-latest + steps: + - name: Check out triage helper + uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/scripts/issue-version-triage.cjs + sparse-checkout-cone-mode: false + + - name: Sync issue version metadata + uses: actions/github-script@v7 + with: + script: | + const triage = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-version-triage.cjs`); + await triage.syncLabels({ github, context, core }); diff --git a/.github/workflows/issue-version-retest-comment.yml b/.github/workflows/issue-version-retest-comment.yml new file mode 100644 index 000000000..fecd573c1 --- /dev/null +++ b/.github/workflows/issue-version-retest-comment.yml @@ -0,0 +1,30 @@ +name: Issue Version Retest Comment + +on: + issues: + types: + - opened + - reopened + +permissions: + contents: read + issues: write + +jobs: + comment: + if: ${{ github.event.issue.pull_request == null }} + runs-on: ubuntu-latest + steps: + - name: Check out triage helper + uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/scripts/issue-version-triage.cjs + sparse-checkout-cone-mode: false + + - name: Post retest guidance when needed + uses: actions/github-script@v7 + with: + script: | + const triage = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-version-triage.cjs`); + await triage.postRetestComment({ github, context, core }); diff --git a/.github/workflows/issue-version-triage.yml b/.github/workflows/issue-version-triage.yml deleted file mode 100644 index 188c76a48..000000000 --- a/.github/workflows/issue-version-triage.yml +++ /dev/null @@ -1,267 +0,0 @@ -name: Issue Version Triage - -on: - issues: - types: - - opened - - edited - - reopened - -permissions: - contents: read - issues: write - -jobs: - triage: - if: ${{ github.event.issue.pull_request == null }} - runs-on: ubuntu-latest - steps: - - name: Apply version labels and retest guidance - uses: actions/github-script@v7 - with: - script: | - const issue = context.payload.issue; - const labelNames = new Set((issue.labels || []).map((label) => label.name)); - const VERSION_LABEL_PREFIX = "affects-"; - const NEEDS_VERSION_LABEL = "needs-version-info"; - const RETEST_LABEL = "needs-retest-on-latest"; - const RETEST_COMMENT_MARKER = ""; - const BUG_LABEL = "bug"; - const DOCS_LABEL = "documentation"; - const ENHANCEMENT_LABEL = "enhancement"; - - function escapeRegExp(value) { - return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - } - - function extractSectionValue(body, heading) { - if (!body) return null; - const pattern = new RegExp( - `^#+\\s*${escapeRegExp(heading)}\\s*$\\n+([\\s\\S]*?)(?=^#+\\s+|$)`, - "im" - ); - const match = body.match(pattern); - if (!match) return null; - const value = match[1].trim(); - return value || null; - } - - function normalizeVersion(value) { - if (!value) return null; - const match = String(value).match(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/); - return match ? match[1] : null; - } - - function extractPulseVersion(title, body) { - if (body) { - const lines = body.split(/\r?\n/); - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i] || ""; - if (/pulse\s*(\||-)?\s*version/i.test(line)) { - const inlineVersion = normalizeVersion(line); - if (inlineVersion) return inlineVersion; - - // Check nearby lines because issue forms often put values on the next line. - for (let j = i + 1; j < Math.min(i + 6, lines.length); j += 1) { - const nearby = (lines[j] || "").trim(); - if (!nearby) continue; - const nearbyVersion = normalizeVersion(nearby); - if (nearbyVersion) return nearbyVersion; - } - } - } - - // Fallback: issue-form block headed by "Pulse version". - const headingMatch = body.match(/#+\s*Pulse version[\s\S]{0,80}?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i); - if (headingMatch) return normalizeVersion(headingMatch[1]); - - // Final fallback for older templates with "Pulse | Version: [6.0.0]". - const legacyMatch = body.match(/pulse\s*\|?\s*version[^\n]*?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i); - if (legacyMatch) return normalizeVersion(legacyMatch[1]); - } - - // Maintainer-created split issues often carry the exact version in the title. - return normalizeVersion(title); - } - - function classifyV6FeedbackType(body) { - const feedbackType = extractSectionValue(body, "Feedback type"); - if (!feedbackType) return null; - - const normalized = feedbackType.toLowerCase(); - if ( - normalized.includes("bug") || - normalized.includes("regression") || - normalized.includes("upgrade / migration issue") || - normalized.includes("performance issue") - ) { - return BUG_LABEL; - } - if (normalized.includes("documentation issue")) { - return DOCS_LABEL; - } - if ( - normalized.includes("ux / workflow friction") || - normalized.includes("other actionable feedback") - ) { - return ENHANCEMENT_LABEL; - } - return null; - } - - function parseCore(version) { - const match = String(version || "").match(/^(\d+)\.(\d+)\.(\d+)/); - if (!match) return null; - return [Number(match[1]), Number(match[2]), Number(match[3])]; - } - - function compareCore(a, b) { - const av = parseCore(a); - const bv = parseCore(b); - if (!av || !bv) return null; - for (let i = 0; i < 3; i += 1) { - if (av[i] > bv[i]) return 1; - if (av[i] < bv[i]) return -1; - } - return 0; - } - - async function ensureLabel(name, color, description) { - try { - await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name, - }); - } catch (error) { - if (error.status !== 404) throw error; - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name, - color, - description, - }); - } - } - - async function hasRetestComment(issueNumber) { - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - return comments.some((comment) => (comment.body || "").includes(RETEST_COMMENT_MARKER)); - } - - const action = context.payload.action || ""; - const authorAssociation = String(issue.author_association || "").toUpperCase(); - const reporterIsMaintainer = ["OWNER", "MEMBER", "COLLABORATOR"].includes(authorAssociation); - const allowRetestComment = (action === "opened" || action === "reopened") && !reporterIsMaintainer; - - const reportedVersion = extractPulseVersion(issue.title, issue.body); - core.info(`Reported Pulse version: ${reportedVersion || "not found"}`); - - let latestVersion = null; - try { - const latest = await github.rest.repos.getLatestRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - }); - latestVersion = normalizeVersion(latest.data.tag_name || latest.data.name || ""); - } catch (error) { - core.warning(`Could not determine latest release: ${error.message}`); - } - core.info(`Latest stable release: ${latestVersion || "unknown"}`); - - const nextLabels = new Set(labelNames); - const v6FeedbackClass = classifyV6FeedbackType(issue.body); - if (v6FeedbackClass) { - core.info(`Detected v6 feedback issue class: ${v6FeedbackClass}`); - nextLabels.add(v6FeedbackClass); - } - - const isBugLike = nextLabels.has(BUG_LABEL); - if (!isBugLike) { - core.info("Issue is not bug-like after classification. Skipping version triage."); - if (v6FeedbackClass && !labelNames.has(v6FeedbackClass)) { - await github.rest.issues.setLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [...nextLabels].sort(), - }); - } - return; - } - - if (reportedVersion) { - await ensureLabel( - `${VERSION_LABEL_PREFIX}${reportedVersion}`, - "0e8a16", - `Bug reported against Pulse ${reportedVersion}` - ); - await ensureLabel( - RETEST_LABEL, - "d93f0b", - "Reporter should retest on current latest stable release" - ); - - // Keep only one exact affects-X.Y.Z label for clarity. - for (const label of [...nextLabels]) { - if (/^affects-\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(label) && label !== `${VERSION_LABEL_PREFIX}${reportedVersion}`) { - nextLabels.delete(label); - } - } - - nextLabels.add(`${VERSION_LABEL_PREFIX}${reportedVersion}`); - nextLabels.delete(NEEDS_VERSION_LABEL); - - const comparison = latestVersion ? compareCore(reportedVersion, latestVersion) : null; - if (comparison !== null && comparison < 0) { - nextLabels.add(RETEST_LABEL); - - if (allowRetestComment && !(await hasRetestComment(issue.number))) { - const body = [ - RETEST_COMMENT_MARKER, - "Thanks for the report.", - "", - `I can see this was reported on **v${reportedVersion}**, while the latest stable release is **v${latestVersion}**.`, - `Please retest on **v${latestVersion}** and comment with:`, - "", - "- whether the issue still reproduces", - "- updated logs/diagnostics", - "- exact running image tag or digest", - "", - "If there is no reporter follow-up after 7 days, this issue may be auto-closed until new confirmation is provided.", - "", - "If it still reproduces on the latest version, I will keep this open as an active regression.", - ].join("\n"); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body, - }); - } - } else { - nextLabels.delete(RETEST_LABEL); - } - } else { - await ensureLabel( - NEEDS_VERSION_LABEL, - "fbca04", - "Issue is missing required Pulse version metadata" - ); - nextLabels.add(NEEDS_VERSION_LABEL); - nextLabels.delete(RETEST_LABEL); - } - - await github.rest.issues.setLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [...nextLabels].sort(), - });