From 90f80771654d4ab92f656b04e76f607c302ec752 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Apr 2026 13:05:24 +0900 Subject: [PATCH 1/4] Automated issue triage workflow --- .github/workflows/issue-triage.yml | 178 +++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 .github/workflows/issue-triage.yml diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000000..bc6ef35758 --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,178 @@ +name: Issue Triage + +on: + issues: + types: + - opened + workflow_dispatch: + inputs: + issue_number: + description: Issue number to triage + required: true + type: string + +permissions: + contents: read + issues: write + +concurrency: + group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: true + +env: + DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }} + DEVFLOW_REF: main + TARGET_REPO_PATH: ${{ github.workspace }}/target-repo + DEVFLOW_PATH: ${{ github.workspace }}/devflow + +jobs: + team_check: + runs-on: ubuntu-latest + outputs: + is_team_member: ${{ steps.check.outputs.is_team_member }} + issue_number: ${{ steps.issue.outputs.issue_number }} + repo: ${{ steps.issue.outputs.repo }} + steps: + - name: Resolve issue metadata + id: issue + shell: bash + env: + ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }} + ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }} + run: | + set -euo pipefail + + if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then + issue_number="${ISSUE_NUMBER_EVENT}" + else + issue_number="${ISSUE_NUMBER_INPUT}" + fi + + if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then + echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2 + exit 1 + fi + + echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT" + echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT" + + - name: Check issue author team membership + id: check + uses: actions/github-script@v8 + env: + TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }} + ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }} + with: + github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} + script: | + let author = context.payload.issue?.user?.login; + if (!author) { + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.ISSUE_NUMBER), + }); + author = issue.user.login; + } + + let isTeamMember = false; + try { + const teamMembership = await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: process.env.TEAM_NAME, + username: author, + }); + isTeamMember = teamMembership.data.state === 'active'; + } catch (error) { + console.log(`Team membership lookup failed for ${author}: ${error.message}`); + isTeamMember = false; + } + + core.setOutput('is_team_member', isTeamMember ? 'true' : 'false'); + if (isTeamMember) { + core.info(`Author ${author} is a team member; skipping auto-triage.`); + } else { + core.info(`Author ${author} is not a team member; proceeding with triage.`); + } + + triage: + runs-on: ubuntu-latest + needs: team_check + if: ${{ needs.team_check.outputs.is_team_member == 'false' }} + timeout-minutes: 60 + + steps: + # Safe checkout: base repo only. + - name: Checkout target repo base + uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + path: target-repo + + # Private DevFlow (maf-dashboard) checkout. + - name: Checkout DevFlow + uses: actions/checkout@v5 + with: + repository: ${{ env.DEVFLOW_REPOSITORY }} + ref: ${{ env.DEVFLOW_REF }} + token: ${{ secrets.DEVFLOW_TOKEN }} + fetch-depth: 1 + persist-credentials: false + path: devflow + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.5.x" + enable-cache: true + + - name: Install DevFlow dependencies + working-directory: ${{ env.DEVFLOW_PATH }} + run: uv sync --frozen + + - name: Classify issue relevance + id: spam + working-directory: ${{ env.DEVFLOW_PATH }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + ISSUE_REPO: ${{ needs.team_check.outputs.repo }} + ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }} + run: | + uv run python scripts/classify_issue_spam.py \ + --repo "$ISSUE_REPO" \ + --issue-number "$ISSUE_NUMBER" \ + --repo-path "${TARGET_REPO_PATH}" \ + --apply-labels + + - name: Stop after spam gate + if: ${{ steps.spam.outputs.decision != 'allow' }} + shell: bash + env: + SPAM_DECISION: ${{ steps.spam.outputs.decision }} + run: | + echo "Skipping reproduction because spam gate decided: ${SPAM_DECISION}" + + - name: Reproduce reported issue + if: ${{ steps.spam.outputs.decision == 'allow' }} + id: repro + working-directory: ${{ env.DEVFLOW_PATH }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }} + SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + ISSUE_REPO: ${{ needs.team_check.outputs.repo }} + ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }} + run: | + uv run python scripts/trigger_issue_repro.py \ + --repo "$ISSUE_REPO" \ + --issue-number "$ISSUE_NUMBER" \ + --github-username "$GITHUB_ACTOR" From 436524260268773ad4f1a1308f27edd504671f47 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Apr 2026 13:26:18 +0900 Subject: [PATCH 2/4] Bump dependencies --- .github/workflows/issue-triage.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index bc6ef35758..eb7e9cf443 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -104,7 +104,7 @@ jobs: steps: # Safe checkout: base repo only. - name: Checkout target repo base - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false @@ -112,7 +112,7 @@ jobs: # Private DevFlow (maf-dashboard) checkout. - name: Checkout DevFlow - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: ${{ env.DEVFLOW_REPOSITORY }} ref: ${{ env.DEVFLOW_REF }} @@ -127,9 +127,9 @@ jobs: python-version: "3.13" - name: Set up uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: - version: "0.5.x" + version: "0.11.x" enable-cache: true - name: Install DevFlow dependencies From bf32fd0214843072f8fd03ec69f6c25300b26070 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 22 Apr 2026 23:01:54 +0000 Subject: [PATCH 3/4] Fix issue-triage workflow: security, reliability, and testability Address six review comments on the issue-triage workflow: 1. Change trigger from issues:opened to issues:labeled so the secret-backed triage flow is only triggered by a maintainer- controlled signal. 2. Include inputs.issue_number in the concurrency group so workflow_dispatch runs for the same issue are properly de-duplicated. 3. Improve team membership error handling to fail closed: verify the team exists before checking membership, and only treat a 404 as 'not a member' (all other errors fail the job). 4. Use optional chaining (issue.user?.login) for the API-fetched issue to handle deleted GitHub accounts without crashing. 5. Extract the inline github-script into a testable module at .github/scripts/check_team_membership.js with 10 tests in .github/tests/test_check_team_membership.js covering all code paths (payload/API author resolution, deleted accounts, team lookup failure, 404 vs non-404 membership errors). 6. Make the spam gate actually stop the job by exiting non-zero instead of just logging, so future steps cannot accidentally run for spam issues. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/check_team_membership.js | 61 +++++++ .github/tests/test_check_team_membership.js | 178 ++++++++++++++++++++ .github/workflows/issue-triage.yml | 45 +++-- 3 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 .github/scripts/check_team_membership.js create mode 100644 .github/tests/test_check_team_membership.js diff --git a/.github/scripts/check_team_membership.js b/.github/scripts/check_team_membership.js new file mode 100644 index 0000000000..ca8e75f1d1 --- /dev/null +++ b/.github/scripts/check_team_membership.js @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +/** + * Resolve the issue author and check their team membership. + * + * @param {object} opts + * @param {object} opts.github - Octokit REST client from actions/github-script + * @param {object} opts.context - GitHub Actions context + * @param {object} opts.core - GitHub Actions core toolkit + * @param {string} opts.teamSlug - Team slug to check membership against + * @param {string|number} opts.issueNumber - Issue number to resolve author for + * @returns {Promise<{author: string|null, isTeamMember: boolean}>} + */ +async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) { + let author = context.payload.issue?.user?.login; + if (!author) { + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(issueNumber), + }); + author = issue.user?.login; + } + + if (!author) { + core.setFailed('Could not determine issue author (user may be deleted).'); + return { author: null, isTeamMember: false }; + } + + try { + await github.rest.teams.getByName({ + org: context.repo.owner, + team_slug: teamSlug, + }); + } catch (error) { + core.setFailed(`Team lookup failed for ${teamSlug}: ${error.message}`); + throw error; + } + + let isTeamMember = false; + try { + const teamMembership = await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: teamSlug, + username: author, + }); + isTeamMember = teamMembership.data.state === 'active'; + } catch (error) { + if (error.status === 404) { + core.info(`Author ${author} is not a member of team ${teamSlug}.`); + isTeamMember = false; + } else { + core.setFailed(`Team membership lookup failed for ${author}: ${error.message}`); + throw error; + } + } + + return { author, isTeamMember }; +} + +module.exports = checkTeamMembership; diff --git a/.github/tests/test_check_team_membership.js b/.github/tests/test_check_team_membership.js new file mode 100644 index 0000000000..6fbec9ff60 --- /dev/null +++ b/.github/tests/test_check_team_membership.js @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft. All rights reserved. + +/** + * Tests for check_team_membership.js. + * + * Run with: node --test .github/tests/test_check_team_membership.js + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const checkTeamMembership = require('../scripts/check_team_membership.js'); + + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) { + const core = { + _infoMessages: [], + _failedMessages: [], + info(msg) { this._infoMessages.push(msg); }, + setFailed(msg) { this._failedMessages.push(msg); }, + }; + + const context = { + payload: { issue: payloadIssue }, + repo: { owner: 'test-org', repo: 'test-repo' }, + }; + + const github = { + rest: { + issues: { + get: async () => ({ + data: { user: apiUser ? { login: apiUser } : null }, + }), + }, + teams: { + getByName: async () => ({}), + getMembershipForUserInOrg: async () => ({ + data: { state: teamState }, + }), + }, + }, + }; + + return { core, context, github }; +} + +const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' }; + + +// --------------------------------------------------------------------------- +// Author resolution +// --------------------------------------------------------------------------- + +describe('author resolution', () => { + it('resolves author from event payload', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'payload-user' } }, + }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.author, 'payload-user'); + }); + + it('resolves author via API when payload issue is absent', async () => { + const { github, context, core } = createMocks({ apiUser: 'api-user' }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.author, 'api-user'); + }); + + it('resolves author via API when payload issue user is null (deleted account)', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: null }, + apiUser: 'fetched-user', + }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.author, 'fetched-user'); + }); + + it('handles deleted account when API also returns null user', async () => { + const { github, context, core } = createMocks({ apiUser: null }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.author, null); + assert.equal(result.isTeamMember, false); + assert.ok(core._failedMessages.some(m => m.includes('deleted'))); + }); +}); + + +// --------------------------------------------------------------------------- +// Team lookup +// --------------------------------------------------------------------------- + +describe('team lookup', () => { + it('fails the job when team lookup errors', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'user1' } }, + }); + const error = new Error('Bad credentials'); + github.rest.teams.getByName = async () => { throw error; }; + + await assert.rejects( + () => checkTeamMembership({ github, context, core, ...BASE_OPTS }), + (err) => err === error, + ); + assert.ok(core._failedMessages.some(m => m.includes('Team lookup failed'))); + }); +}); + + +// --------------------------------------------------------------------------- +// Team membership +// --------------------------------------------------------------------------- + +describe('team membership', () => { + it('returns true for active team member', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'member' } }, + teamState: 'active', + }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.isTeamMember, true); + }); + + it('returns false for pending team member', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'pending-user' } }, + teamState: 'pending', + }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.isTeamMember, false); + }); + + it('treats 404 membership response as non-member without failing', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'outsider' } }, + }); + const notFoundError = new Error('Not Found'); + notFoundError.status = 404; + github.rest.teams.getMembershipForUserInOrg = async () => { throw notFoundError; }; + + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.isTeamMember, false); + assert.equal(core._failedMessages.length, 0); + assert.ok(core._infoMessages.some(m => m.includes('not a member'))); + }); + + it('fails the job on non-404 membership errors', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'user1' } }, + }); + const serverError = new Error('Internal Server Error'); + serverError.status = 500; + github.rest.teams.getMembershipForUserInOrg = async () => { throw serverError; }; + + await assert.rejects( + () => checkTeamMembership({ github, context, core, ...BASE_OPTS }), + (err) => err === serverError, + ); + assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed'))); + }); + + it('fails the job on membership errors without status code', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'user1' } }, + }); + const networkError = new Error('ECONNREFUSED'); + github.rest.teams.getMembershipForUserInOrg = async () => { throw networkError; }; + + await assert.rejects( + () => checkTeamMembership({ github, context, core, ...BASE_OPTS }), + (err) => err === networkError, + ); + assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed'))); + }); +}); diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index eb7e9cf443..118f511635 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -3,7 +3,7 @@ name: Issue Triage on: issues: types: - - opened + - labeled workflow_dispatch: inputs: issue_number: @@ -16,7 +16,7 @@ permissions: issues: write concurrency: - group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || github.run_id }} + group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }} cancel-in-progress: true env: @@ -56,6 +56,13 @@ jobs: echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT" echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT" + - name: Checkout scripts + uses: actions/checkout@v6 + with: + sparse-checkout: .github/scripts + fetch-depth: 1 + persist-credentials: false + - name: Check issue author team membership id: check uses: actions/github-script@v8 @@ -65,29 +72,14 @@ jobs: with: github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} script: | - let author = context.payload.issue?.user?.login; - if (!author) { - const { data: issue } = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: Number(process.env.ISSUE_NUMBER), - }); - author = issue.user.login; - } - - let isTeamMember = false; - try { - const teamMembership = await github.rest.teams.getMembershipForUserInOrg({ - org: context.repo.owner, - team_slug: process.env.TEAM_NAME, - username: author, - }); - isTeamMember = teamMembership.data.state === 'active'; - } catch (error) { - console.log(`Team membership lookup failed for ${author}: ${error.message}`); - isTeamMember = false; - } - + const checkTeamMembership = require('./.github/scripts/check_team_membership.js'); + const { author, isTeamMember } = await checkTeamMembership({ + github, + context, + core, + teamSlug: process.env.TEAM_NAME, + issueNumber: process.env.ISSUE_NUMBER, + }); core.setOutput('is_team_member', isTeamMember ? 'true' : 'false'); if (isTeamMember) { core.info(`Author ${author} is a team member; skipping auto-triage.`); @@ -158,7 +150,8 @@ jobs: env: SPAM_DECISION: ${{ steps.spam.outputs.decision }} run: | - echo "Skipping reproduction because spam gate decided: ${SPAM_DECISION}" + echo "Stopping: spam gate decided: ${SPAM_DECISION}" + exit 1 - name: Reproduce reported issue if: ${{ steps.spam.outputs.decision == 'allow' }} From 4a729e30fe59ea4b46a9bc29a0107dc7de0a263c Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 23 Apr 2026 09:09:01 +0000 Subject: [PATCH 4/4] Make issue-triage workflow manually triggered only for initial testing Remove the 'issues' event trigger, keeping only 'workflow_dispatch' so the workflow can be tested manually before enabling automatic triggers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/issue-triage.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 118f511635..813ef8a219 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -1,9 +1,6 @@ name: Issue Triage on: - issues: - types: - - labeled workflow_dispatch: inputs: issue_number: