|
| 1 | +# @ai-generated: true |
| 2 | +# @ai-tool: Copilot |
| 3 | +name: AI Governance Agent (ChatOps) |
| 4 | + |
| 5 | +on: |
| 6 | + issue_comment: |
| 7 | + types: [created] |
| 8 | + |
| 9 | +permissions: |
| 10 | + contents: read |
| 11 | + issues: write |
| 12 | + pull-requests: write |
| 13 | + |
| 14 | +jobs: |
| 15 | + respond: |
| 16 | + name: Respond to /gov commands on PRs |
| 17 | + if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, '/gov') }} |
| 18 | + runs-on: ubuntu-latest |
| 19 | + steps: |
| 20 | + - name: Handle /gov command |
| 21 | + uses: actions/github-script@v7 |
| 22 | + with: |
| 23 | + github-token: ${{ secrets.GITHUB_TOKEN }} |
| 24 | + script: | |
| 25 | + const body = context.payload.comment.body.trim(); |
| 26 | + const owner = context.repo.owner; |
| 27 | + const repo = context.repo.repo; |
| 28 | + const issue_number = context.payload.issue.number; |
| 29 | +
|
| 30 | + const helpText = |
| 31 | + 'AI Governance Agent commands:\\n\\n\n' + |
| 32 | + `- /gov help — show this help\n` + |
| 33 | + `- /gov check — scan this PR for missing governance checklist items and summarize changes\n` + |
| 34 | + `- /gov copilot — ask GitHub Copilot to review this PR\n` + |
| 35 | + `- /gov links — preview suggested links (governance/test runs) for the PR template\n` + |
| 36 | + `- /gov autofill apply — auto-fill safe N/A defaults and add run links into the PR body\n` + |
| 37 | + `- /gov — run default check, trigger Copilot review, preview links, and auto-apply autofill\n`; |
| 38 | +
|
| 39 | + const isHelp = body.match(/^\/gov\s+help\b/i); |
| 40 | + const isCheck = body.match(/^\/gov\s+check\b/i); |
| 41 | + const isBare = body.match(/^\/gov\s*$/i); |
| 42 | +
|
| 43 | + if (!isHelp && !isCheck && !isBare) { |
| 44 | + return; // Ignore other /gov variants for now |
| 45 | + } |
| 46 | +
|
| 47 | + if (isHelp) { |
| 48 | + await github.rest.issues.createComment({ owner, repo, issue_number, body: helpText }); |
| 49 | + return; |
| 50 | + } |
| 51 | +
|
| 52 | + // Fetch PR details and changed files (treat bare /gov as /gov check) |
| 53 | + const doCheck = isCheck || isBare; |
| 54 | + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number }); |
| 55 | + const prBody = (pr.body || '').toString(); |
| 56 | + const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: issue_number, per_page: 100 }); |
| 57 | +
|
| 58 | + // Heuristics for change types |
| 59 | + const changedPaths = files.map(f => f.filename); |
| 60 | + const rx = { |
| 61 | + userUI: /(^|\/)(ui|web|frontend|public|templates)(\/|$)|(^|\/)src\/.*\.(html|tsx?|vue)$/i, |
| 62 | + sensitive: /(^|\/)(auth|authn|authz|login|acl|permissions?|access[_-]?control|secrets?|tokens?|jwt|oauth)(\/|$)|\.(policy|rego)$/i, |
| 63 | + infra: /(^|\/)(k8s|kubernetes|helm|charts|deploy|ops|infra|infrastructure|manifests|terraform|ansible)(\/|$)|(^|\/)dockerfile$|docker-compose\.ya?ml$|Chart\.ya?ml$/i, |
| 64 | + backend: /(^|\/)(src|api|server|backend|app)(\/|\/.*)([^\/]+)\.(js|ts|py|rb|go|java|cs)$/i, |
| 65 | + media: /\.(png|jpe?g|gif|webp|svg|mp4|mp3|wav|pdf)$/i, |
| 66 | + data: /(^|\/)(data|datasets|training|notebooks|scripts)(\/|$)/i |
| 67 | + }; |
| 68 | + const has = (re) => changedPaths.some(p => re.test(p)); |
| 69 | + const flags = { |
| 70 | + userUI: has(rx.userUI), |
| 71 | + sensitive: has(rx.sensitive), |
| 72 | + infra: has(rx.infra), |
| 73 | + backend: has(rx.backend), |
| 74 | + media: has(rx.media), |
| 75 | + data: has(rx.data) |
| 76 | + }; |
| 77 | +
|
| 78 | + // Simple PR body checks mirroring the reusable workflow |
| 79 | + const missing = []; |
| 80 | + const need = (label, ok) => { if (!ok) missing.push(label); }; |
| 81 | +
|
| 82 | + need('Prompt', /Prompt/i.test(prBody)); |
| 83 | + need('Model', /Model/i.test(prBody)); |
| 84 | + need('Date', /Date/i.test(prBody)); |
| 85 | + need('Author', /Author/i.test(prBody)); |
| 86 | + need('[x] No secrets/PII', /\[x\].*no\s+secrets\/?pii|no\s+pii\/?secrets/i.test(prBody)); |
| 87 | + need('Risk classification: limited|high', /Risk\s*classification:\s*(limited|high)/i.test(prBody)); |
| 88 | + need('Personal data: yes|no', /Personal\s*data:\s*(yes|no)/i.test(prBody)); |
| 89 | + need('Automated decision-making: yes|no', /Automated\s*decision-?making:\s*(yes|no)/i.test(prBody)); |
| 90 | + need('Agent mode used: yes|no', /Agent\s*mode\s*used:\s*(yes|no)/i.test(prBody)); |
| 91 | + need('Role: provider|deployer', /Role:\s*(provider|deployer)/i.test(prBody)); |
| 92 | +
|
| 93 | + if (flags.userUI) { |
| 94 | + need('[x] Transparency notice updated', /\[x\].*transparency\s+notice/i.test(prBody)); |
| 95 | + need('Accessibility statement: <link or N/A>', /Accessibility\s*statement:\s*(https?:\/\/|N\/?A)/i.test(prBody)); |
| 96 | + } |
| 97 | + if (flags.media) { |
| 98 | + need('[x] AI content labeled', /\[x\].*ai\s*content\s*labeled/i.test(prBody)); |
| 99 | + need('C2PA: <link or N/A>', /C2PA:\s*(https?:\/\/|N\/?A)/i.test(prBody)); |
| 100 | + } |
| 101 | + if (flags.infra) { |
| 102 | + need('Privacy notice: <link>', /Privacy\s*notice:\s*(https?:\/\/)/i.test(prBody)); |
| 103 | + need('Lawful basis: <text or N/A>', /Lawful\s*basis:\s*([A-Za-z]+|N\/?A)/i.test(prBody)); |
| 104 | + need('Retention schedule: <link or N/A>', /Retention\s*schedule:\s*(https?:\/\/|N\/?A)/i.test(prBody)); |
| 105 | + } |
| 106 | + if (flags.backend) { |
| 107 | + need('[x] OWASP ASVS review or ASVS: <link>', /\[x\].*owasp\s*asvs|ASVS:\s*(https?:\/\/)/i.test(prBody)); |
| 108 | + } |
| 109 | +
|
| 110 | + // Build a concise response |
| 111 | + const bullet = (b) => `- ${b}`; |
| 112 | + const filesList = changedPaths.slice(0, 50).map(bullet).join('\n'); |
| 113 | + const missingList = missing.length ? missing.map(bullet).join('\n') : '- None (looks good)'; |
| 114 | + const flagsList = Object.entries(flags).filter(([,v]) => v).map(([k]) => ` |
| 115 | + - ${k}`).join('') || '\n - none detected'; |
| 116 | +
|
| 117 | + const reply = |
| 118 | + '### Governance Agent Report\\n\\n\n' + |
| 119 | + `PR: #${issue_number} by @${pr.user.login}\n\n` + |
| 120 | + `Changed files (${changedPaths.length}):\n${filesList}\n\n` + |
| 121 | + `Detected change types:${flagsList}\n\n` + |
| 122 | + `Missing or incomplete items:\n${missingList}\n\n` + |
| 123 | + `Tip: Use the PR template fields to satisfy these checks.\n\n` + |
| 124 | + `Run /gov help for commands. Also try: /gov links and /gov autofill apply.`; |
| 125 | +
|
| 126 | + if (doCheck) { |
| 127 | + await github.rest.issues.createComment({ owner, repo, issue_number, body: reply }); |
| 128 | + } |
| 129 | +
|
| 130 | + // If the command was bare /gov, also trigger Copilot review, links preview, and auto-apply autofill |
| 131 | + if (isBare) { |
| 132 | + await github.rest.issues.createComment({ owner, repo, issue_number, body: '/gov copilot' }); |
| 133 | + // And trigger auto-links preview so contributors can quickly fill PR fields |
| 134 | + await github.rest.issues.createComment({ owner, repo, issue_number, body: '/gov links' }); |
| 135 | + // Finally, auto-apply link autofill (safe defaults + run links) |
| 136 | + await github.rest.issues.createComment({ owner, repo, issue_number, body: '/gov autofill apply' }); |
| 137 | + } |
0 commit comments