diff --git a/.github/ISSUE_TEMPLATE/team-ops.yml b/.github/ISSUE_TEMPLATE/team-ops.yml new file mode 100644 index 0000000..ec57632 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/team-ops.yml @@ -0,0 +1,52 @@ +name: 👥 Team Repo Access +description: Add or remove a team's access to a repository +title: "Team Repo Access" +labels: ["team-ops"] +body: + - type: dropdown + id: action + attributes: + label: Action + description: Do you want to add or remove a team's access to a repository? + options: + - add + - remove + validations: + required: true + - type: input + id: team_slug + attributes: + label: Team Slug + description: "The slug of the team (e.g. `my-team`). This is the URL-friendly name of the team." + placeholder: my-team + validations: + required: true + - type: input + id: repository + attributes: + label: Repository + description: "The name of the repository (repo name only, not the full org/repo)" + placeholder: my-repo + validations: + required: true + - type: dropdown + id: permission + attributes: + label: Permission + description: "The permission level to grant the team (only applies when adding a team; ignored when removing)" + options: + - pull + - triage + - push + - maintain + - admin + validations: + required: true + - type: textarea + id: reason + attributes: + label: Reason + description: Why is this team access change needed? + placeholder: Describe the reason for this change... + validations: + required: true diff --git a/.github/workflows/team-ops-execute.yml b/.github/workflows/team-ops-execute.yml new file mode 100644 index 0000000..d6b8fc2 --- /dev/null +++ b/.github/workflows/team-ops-execute.yml @@ -0,0 +1,237 @@ +name: team-ops-execute + +run-name: 'Team Ops Execute: Issue #${{ github.event.issue.number }} by @${{ github.actor }}' + +on: + issue_comment: + types: [created] + +jobs: + team-ops-execute: + runs-on: ubuntu-latest + if: github.event_name == 'issue_comment' && + github.event.issue.pull_request == null && + (startsWith(github.event.comment.body, '/execute-team-ops') && + contains(github.event.issue.labels.*.name, 'team-ops')) + permissions: + contents: read + issues: write + + steps: + - uses: actions/checkout@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v6 + + - uses: actions/create-github-app-token@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v3 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Parse Issue + id: parser + uses: issue-ops/parser@cb7e2e4e5da701aad0e23e718bc4c91d442ca8ba # v5.0.0 + with: + body: ${{ github.event.issue.body }} + issue-form-template: team-ops.yml + + - name: Set env vars + env: + PARSED_JSON: ${{ steps.parser.outputs.json }} + run: | + ACTION=$(echo "$PARSED_JSON" | jq -r '.action') + TEAM_SLUG=$(echo "$PARSED_JSON" | jq -r '.team_slug') + REPOSITORY=$(echo "$PARSED_JSON" | jq -r '.repository') + PERMISSION=$(echo "$PARSED_JSON" | jq -r '.permission') + + # Validate action + if [[ ! "$ACTION" =~ ^(add|remove)$ ]]; then + echo "::error::Invalid action: must be 'add' or 'remove'." + exit 1 + fi + + # Validate team slug: letters, numbers, hyphens (GitHub team slug format) + if [[ -z "$TEAM_SLUG" ]] || [[ ! "$TEAM_SLUG" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]*$ ]]; then + echo "::error::Invalid team_slug: must contain only letters, numbers, and hyphens, and start with a letter or number." + exit 1 + fi + + # Validate repository: letters, numbers, dots, underscores, hyphens + if [[ -z "$REPOSITORY" ]] || [[ ! "$REPOSITORY" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error::Invalid repository: must contain only letters, numbers, dots, underscores, and hyphens." + exit 1 + fi + + # Validate permission + if [[ ! "$PERMISSION" =~ ^(pull|triage|push|maintain|admin)$ ]]; then + echo "::error::Invalid permission." + exit 1 + fi + + echo "ACTION=$ACTION" >> $GITHUB_ENV + echo "TEAM_SLUG=$TEAM_SLUG" >> $GITHUB_ENV + echo "REPOSITORY=$REPOSITORY" >> $GITHUB_ENV + echo "PERMISSION=$PERMISSION" >> $GITHUB_ENV + + - name: Verify fields match prepare snapshot + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const app = await github.rest.apps.getAuthenticated(); + const expectedBotLogin = `${app.data.slug}[bot]`; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const snapshotComments = comments + .filter(c => c.user.type === 'Bot' && c.user.login === expectedBotLogin && c.body.includes('/s); + if (!match) { + core.setFailed('Could not parse prepare snapshot JSON.'); + return; + } + + const snapshot = JSON.parse(match[1]); + const mismatches = []; + if (snapshot.action !== process.env.ACTION) mismatches.push(`action: approved \`${snapshot.action}\`, current \`${process.env.ACTION}\``); + if (snapshot.team_slug !== process.env.TEAM_SLUG) mismatches.push(`team_slug: approved \`${snapshot.team_slug}\`, current \`${process.env.TEAM_SLUG}\``); + if (snapshot.repository !== process.env.REPOSITORY) mismatches.push(`repository: approved \`${snapshot.repository}\`, current \`${process.env.REPOSITORY}\``); + if (snapshot.permission !== process.env.PERMISSION) mismatches.push(`permission: approved \`${snapshot.permission}\`, current \`${process.env.PERMISSION}\``); + + if (mismatches.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `❌ The issue body was modified after the original request was submitted. The following fields no longer match the approved values:\n\n${mismatches.map(m => `- ${m}`).join('\n')}\n\nCannot proceed. Please open a new issue with the correct details.`, + }); + core.setFailed('Issue body was modified after submission.'); + return; + } + + core.info('Fields match prepare snapshot — proceeding.'); + + - name: Rename issue + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const action = process.env.ACTION === 'add' ? 'Add' : 'Remove'; + const suffix = process.env.ACTION === 'add' + ? ` (${process.env.PERMISSION})` + : ''; + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + title: `Team Ops: ${action} ${process.env.TEAM_SLUG} → ${process.env.GITHUB_REPOSITORY_OWNER}/${process.env.REPOSITORY}${suffix}` + }) + + - name: ApproveOps + uses: joshjohanning/approveops@a52491aecc83cbdb8b1f5ffb534537bdad113a2e # v4.0.0 + id: check-approval + with: + token: ${{ steps.app-token.outputs.token }} + approve-command: /approve + team-name: approver-team + fail-if-approval-not-found: true + post-successful-approval-comment: false + + - name: Add team to repository + if: env.ACTION == 'add' + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + await github.rest.teams.addOrUpdateRepoPermissionsInOrg({ + org: process.env.GITHUB_REPOSITORY_OWNER, + team_slug: process.env.TEAM_SLUG, + owner: process.env.GITHUB_REPOSITORY_OWNER, + repo: process.env.REPOSITORY, + permission: process.env.PERMISSION, + }) + + - name: Remove team from repository + if: env.ACTION == 'remove' + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + await github.rest.teams.removeRepoInOrg({ + org: process.env.GITHUB_REPOSITORY_OWNER, + team_slug: process.env.TEAM_SLUG, + owner: process.env.GITHUB_REPOSITORY_OWNER, + repo: process.env.REPOSITORY, + }) + + - name: Add completed label and close issue + if: ${{ success() }} + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const doneLabel = process.env.ACTION === 'add' ? 'team-added' : 'team-removed'; + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ["team-ops", doneLabel], + state: "closed" + }) + + - name: Post successful message + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + if: ${{ success() }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const actionPast = process.env.ACTION === 'add' ? 'added to' : 'removed from'; + const emoji = process.env.ACTION === 'add' ? '✅' : '🗑️'; + const permissionLine = process.env.ACTION === 'add' + ? `\n- **Permission:** \`${process.env.PERMISSION}\`` + : ''; + + let commentBody + commentBody = `${emoji} Team has been successfully ${actionPast} the repository! + + - **Team:** \`${process.env.TEAM_SLUG}\` + - **Repository:** \`${process.env.GITHUB_REPOSITORY_OWNER}/${process.env.REPOSITORY}\`${permissionLine} + ` + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: commentBody.replace(/ +/g, '') + }) + + - name: Post failure message + if: ${{ failure() && steps.check-approval.outputs.approved == 'true' }} + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + let commentBody + commentBody = `😢 The team access change could not be completed. Please review the [action logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more information.` + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: commentBody.replace(/ +/g, '') + }) diff --git a/.github/workflows/team-ops-prepare.yml b/.github/workflows/team-ops-prepare.yml new file mode 100644 index 0000000..daa55cc --- /dev/null +++ b/.github/workflows/team-ops-prepare.yml @@ -0,0 +1,156 @@ +name: team-ops-prepare + +run-name: 'Team Ops: Issue #${{ github.event.issue.number }} by @${{ github.actor }}' + +on: + issues: + types: [opened] + +jobs: + team-ops-prepare: + name: Prepare Team Ops + runs-on: ubuntu-latest + if: github.event_name == 'issues' && + (github.event.action == 'opened') && + contains(github.event.issue.labels.*.name, 'team-ops') + permissions: + contents: read + issues: write + + steps: + - uses: actions/checkout@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v6 + + - uses: actions/create-github-app-token@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v3 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Parse Issue + id: parser + uses: issue-ops/parser@cb7e2e4e5da701aad0e23e718bc4c91d442ca8ba # v5.0.0 + with: + body: ${{ github.event.issue.body }} + issue-form-template: team-ops.yml + + - name: Set env vars + env: + PARSED_JSON: ${{ steps.parser.outputs.json }} + run: | + ACTION=$(echo "$PARSED_JSON" | jq -r '.action') + TEAM_SLUG=$(echo "$PARSED_JSON" | jq -r '.team_slug') + REPOSITORY=$(echo "$PARSED_JSON" | jq -r '.repository') + PERMISSION=$(echo "$PARSED_JSON" | jq -r '.permission') + + # Validate action + if [[ ! "$ACTION" =~ ^(add|remove)$ ]]; then + echo "::error::Invalid action: must be 'add' or 'remove'." + exit 1 + fi + + # Validate team slug: letters, numbers, hyphens (GitHub team slug format) + if [[ -z "$TEAM_SLUG" ]] || [[ ! "$TEAM_SLUG" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]*$ ]]; then + echo "::error::Invalid team_slug: must contain only letters, numbers, and hyphens, and start with a letter or number." + exit 1 + fi + + # Validate repository: letters, numbers, dots, underscores, hyphens + if [[ -z "$REPOSITORY" ]] || [[ ! "$REPOSITORY" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error::Invalid repository: must contain only letters, numbers, dots, underscores, and hyphens." + exit 1 + fi + + # Validate permission + if [[ ! "$PERMISSION" =~ ^(pull|triage|push|maintain|admin)$ ]]; then + echo "::error::Invalid permission." + exit 1 + fi + + echo "ACTION=$ACTION" >> $GITHUB_ENV + echo "TEAM_SLUG=$TEAM_SLUG" >> $GITHUB_ENV + echo "REPOSITORY=$REPOSITORY" >> $GITHUB_ENV + echo "PERMISSION=$PERMISSION" >> $GITHUB_ENV + + - name: Rename issue + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const action = process.env.ACTION === 'add' ? 'Add' : 'Remove'; + const suffix = process.env.ACTION === 'add' + ? ` (${process.env.PERMISSION})` + : ''; + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + title: `Team Ops: ${action} ${process.env.TEAM_SLUG} → ${process.env.GITHUB_REPOSITORY_OWNER}/${process.env.REPOSITORY}${suffix}` + }) + + - name: Post prepare message + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const snapshot = JSON.stringify({ + action: process.env.ACTION, + team_slug: process.env.TEAM_SLUG, + repository: process.env.REPOSITORY, + permission: process.env.PERMISSION, + }); + + const actionLabel = process.env.ACTION === 'add' ? 'added to' : 'removed from'; + const permissionLine = process.env.ACTION === 'add' + ? `\n- **Permission:** \`${process.env.PERMISSION}\`` + : ''; + + let commentBody + commentBody = `👋 Thank you for opening this team access request, @${{ github.actor }}. + + The following has been parsed from your issue body: + - **Action:** \`${process.env.ACTION}\` + - **Team:** \`${process.env.TEAM_SLUG}\` + - **Repository:** \`${process.env.GITHUB_REPOSITORY_OWNER}/${process.env.REPOSITORY}\`${permissionLine} + + The team **${process.env.TEAM_SLUG}** will be ${actionLabel} the repository **${process.env.GITHUB_REPOSITORY_OWNER}/${process.env.REPOSITORY}**. + + ## 1. Approval + + Someone from the @${process.env.GITHUB_REPOSITORY_OWNER}/approver-team team must run the following command to approve: + + \`\`\` + /approve + \`\`\` + + ## 2. Execute + + After approval, add the following comment to execute the change: + + \`\`\` + /execute-team-ops + \`\`\` + + + ` + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: commentBody.replace(/ +/g, '') + }) + + - name: Post failure message + if: ${{ failure() }} + uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 + with: + script: | + let commentBody + commentBody = `😢 The issue body could not be parsed, @${{ github.actor }}. Please open a new issue using the issue template and ensure your formatting and line spacing remains untouched.\n\nReview the [action logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more information.` + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: commentBody.replace(/ +/g, '') + }) diff --git a/README.md b/README.md index b9cd0cf..b281cc0 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ - 🆕 Create Repo (IssueOps) - 🪓 Delete Repo (IssueOps) - 🔑 JIT Collaborator Access (IssueOps) +- 👥 Team Repo Access (IssueOps) - 🏷️ Labelops - ✨ [Reusable Workflow repo onboarder](https://github.com/joshjohanning-org/reusable-workflow-issueops-onboarder) (IssueOps) (separate repository) @@ -32,7 +33,7 @@ - `issues`: read/write (for updating/closing issues) - `workflows`: read/write (for pushing workflow files) - Organization: - - `members`: read (for ApproveOps team membership checks) + - `members`: read/write (for ApproveOps team membership checks and team-repo operations) ### Labels @@ -50,6 +51,10 @@ gh label create access-granted gh label create access-expired gh label create access-removed gh label create access-needs-attention + +gh label create team-ops +gh label create team-added +gh label create team-removed ``` > [!TIP] @@ -72,6 +77,15 @@ Grants just-in-time (JIT) collaborator access to a repository for a limited dura 5. A **daily scheduled cleanup** job checks all open `access-granted` issues, calculates whether access has expired based on when the `access-granted` label was added and the requested duration, then removes the collaborator and closes the issue. 6. To manually revoke access early, comment `/remove-access` on the issue. +## Team Repo Access + +Manages team access to repositories via IssueOps. Supports both adding and removing a team's access: + +1. A user opens an issue using the **👥 Team Repo Access** template, specifying the action (add/remove), team slug, target repository, permission level, and reason. +2. The **prepare** workflow parses the issue, validates inputs, and posts instructions with a tamper-proof snapshot. +3. An admin from the `approver-team` team comments `/approve` on the issue. +4. A user comments `/execute-team-ops` to execute the change. The **execute** workflow verifies the snapshot, checks approval via [ApproveOps](https://github.com/joshjohanning/approveops), then adds or removes the team and closes the issue. + ## Notes - This often assumes the org that the IssueOps repo is hosted in is the org you are using for API calls (ie: to create/delete repos). Update the owner as appropriate or modify the issue template to allow that as an input.