├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ ├── feature_request.yml │ └── request_admin_permission.yml ├── codeql │ └── codeql-config.yml ├── dependabot.yml ├── validator │ ├── config.yml │ ├── duration.js │ ├── failure.mustache │ ├── organization.js │ └── success.mustache ├── workflow-examples │ ├── check-workflow.yml │ ├── demotion-workflow.yml │ └── promotion-workflow.yml └── workflows │ ├── check-dist.yml │ ├── continuous-delivery.yml │ ├── continuous-integration.yml │ └── linter.yml ├── .gitignore ├── .markdown-lint.yml ├── .node-version ├── .prettierignore ├── .prettierrc.yml ├── .vscode └── launch.json ├── .yaml-lint.yml ├── CODEOWNERS ├── CODE_OF_CONDUCT ├── CONTRIBUTING ├── LICENSE ├── README.md ├── SECURITY ├── admin-support-cli ├── .env.example ├── __fixtures__ │ ├── @actions │ │ ├── core.ts │ │ └── github.ts │ ├── @github │ │ └── issue-parser.ts │ ├── @octokit │ │ └── rest.ts │ ├── commands │ │ └── actions │ │ │ ├── check-auto-demotion-action.ts │ │ │ ├── demotion-report-action.ts │ │ │ └── promote-demote-action.ts │ ├── fs.ts │ └── inputs.ts ├── __tests__ │ ├── commands │ │ └── actions │ │ │ ├── check-auto-demotion-action.test.ts │ │ │ ├── demotion-report-action.test.ts │ │ │ └── promote-demote-action.test.ts │ ├── inputs.test.ts │ └── main.test.ts ├── action.yml ├── dist │ ├── commands │ │ ├── actions │ │ │ ├── check-auto-demotion-action.d.ts │ │ │ ├── demotion-report-action.d.ts │ │ │ └── promote-demote-action.d.ts │ │ └── command.d.ts │ ├── enums.d.ts │ ├── exceptions │ │ ├── CommandDoesNotExistError.d.ts │ │ └── ParameterRequiredError.d.ts │ ├── index.d.ts │ ├── index.js │ ├── index.js.map │ ├── inputs.d.ts │ ├── main.d.ts │ └── types.d.ts ├── eslint.config.mjs ├── issue_payload.example.json ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.ts ├── src │ ├── commands │ │ ├── actions │ │ │ ├── check-auto-demotion-action.ts │ │ │ ├── demotion-report-action.ts │ │ │ └── promote-demote-action.ts │ │ └── command.ts │ ├── enums.ts │ ├── exceptions │ │ ├── CommandDoesNotExistError.ts │ │ └── ParameterRequiredError.ts │ ├── index.ts │ ├── inputs.ts │ ├── main.ts │ └── types.ts ├── tsconfig.base.json ├── tsconfig.eslint.json └── tsconfig.json └── badges └── coverage.svg /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | dist/** -diff linguist-generated=true 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a bug report to help improve this project 3 | title: Bug Report 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Description 9 | description: Describe the bug 10 | placeholder: A clear and concise description of what the bug is 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: reproduce 15 | attributes: 16 | label: Reproduction 17 | description: Steps to reproduce the behavior 18 | placeholder: Steps to reproduce the behavior 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: expected 23 | attributes: 24 | label: Expected Behavior 25 | description: Describe the expected behavior 26 | placeholder: 27 | A clear and concise description of what you expected to happen 28 | validations: 29 | required: false 30 | - type: textarea 31 | id: context 32 | attributes: 33 | label: Additional Context 34 | description: Add any other context or screenshots about the bug here 35 | validations: 36 | required: false 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: GitHub Community Support 4 | url: https://github.com/orgs/community/discussions 5 | about: Please ask and answer questions here. 6 | - name: GitHub Security Bug Bounty 7 | url: https://bounty.github.com/ 8 | about: Please report security vulnerabilities here. 9 | - name: GitHub Expert Services 10 | url: https://github.com/services#services-contact 11 | about: Contact GitHub Expert Services 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: Feature Request 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: Problem 9 | description: Please describe the problem your feature request would solve 10 | placeholder: 11 | A clear and concise description of what the problem is. E.g. _I'm always 12 | frustrated when [...]_ 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: solution 17 | attributes: 18 | label: Solution 19 | description: Describe the solution you'd like 20 | placeholder: A clear and concise description of what you want to happen 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: alternatives 25 | attributes: 26 | label: Alternatives 27 | description: Describe alternatives you've considered 28 | placeholder: A clear and concise description of any alternative solutions 29 | validations: 30 | required: false 31 | - type: textarea 32 | id: context 33 | attributes: 34 | label: Additional Context 35 | description: 36 | Add any other context or screenshots about the feature request here 37 | validations: 38 | required: false 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request_admin_permission.yml: -------------------------------------------------------------------------------- 1 | name: Request Administrative Access 2 | description: 3 | Allows the support team to request a temporary admin permission in an 4 | organization 5 | title: Administrative Access Request 6 | body: 7 | - type: input 8 | id: organization 9 | attributes: 10 | label: Organization 11 | description: Organization where you want to be promoted 12 | placeholder: octo-org 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: description 17 | attributes: 18 | label: Description 19 | description: 20 | Explanation of why this request is being submitted and what task(s) will 21 | be performed 22 | validations: 23 | required: true 24 | - type: input 25 | id: ticket 26 | attributes: 27 | label: Ticket 28 | description: ID of a related ticket in your support system 29 | validations: 30 | required: true 31 | - type: dropdown 32 | id: duration 33 | attributes: 34 | label: Duration 35 | description: Duration in hours that you need the permission 36 | multiple: false 37 | options: 38 | - '1' 39 | - '2' 40 | - '3' 41 | - '4' 42 | - '5' 43 | - '6' 44 | - '7' 45 | - '8' 46 | validations: 47 | required: true 48 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL Configuration 2 | 3 | paths-ignore: 4 | - node_modules 5 | - dist 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | groups: 8 | actions-minor: 9 | update-types: 10 | - minor 11 | - patch 12 | 13 | - package-ecosystem: npm 14 | directory: / 15 | schedule: 16 | interval: weekly 17 | ignore: 18 | - dependency-name: '@types/node' 19 | update-types: 20 | - 'version-update:semver-major' 21 | groups: 22 | npm-development: 23 | dependency-type: development 24 | update-types: 25 | - minor 26 | - patch 27 | npm-production: 28 | dependency-type: production 29 | update-types: 30 | - patch 31 | -------------------------------------------------------------------------------- /.github/validator/config.yml: -------------------------------------------------------------------------------- 1 | validators: 2 | - field: organization 3 | script: organization.js 4 | - field: duration 5 | script: duration.js 6 | -------------------------------------------------------------------------------- /.github/validator/duration.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Validates if the duration input is a number between 1 and 8. 3 | * 4 | * @param {string | string[] | {label: string; required: boolean }} field The input field. 5 | * @returns {Promise} An error message or `'success'` 6 | */ 7 | export default async (field) => { 8 | if (typeof field !== 'string') return 'Field type is invalid' 9 | 10 | // Check if the input is a number. 11 | if (isNaN(field)) return 'Duration must be a number' 12 | 13 | // Check if the number is between 1 and 8. 14 | if (parseInt(field) < 1 || parseInt(field) > 8) 15 | return 'Duration must be between 1 and 8' 16 | return 'success' 17 | } 18 | -------------------------------------------------------------------------------- /.github/validator/failure.mustache: -------------------------------------------------------------------------------- 1 | # :no_entry: **Administrative Access Request Rejected** 2 | 3 | Your request did not pass validation! Please check the following errors and fix 4 | them by editing your original issue. 5 | 6 | 7 | 8 | 9 | {{#each errors}} 10 | - {{this}} 11 | {{/each}} 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/validator/organization.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Validates if the organization input is in the list of supported 3 | * organizations. 4 | * 5 | * @param {string | string[] | {label: string; required: boolean }} field The input field. 6 | * @returns {Promise} An error message or `'success'` 7 | */ 8 | export default async (field) => { 9 | if (typeof field !== 'string') return 'Field type is invalid' 10 | 11 | // The list of supported organizations is defined in the `ALLOWED_ORGS` 12 | // environment variable in the `promotion-workflow.yml` workflow file. 13 | const ALLOWED_ORGS = process.env.ALLOWED_ORGS?.split(/,\s?/) ?? [] 14 | 15 | if (ALLOWED_ORGS.includes(field)) return 'success' 16 | else return `Organization '${field}' is not supported` 17 | } 18 | -------------------------------------------------------------------------------- /.github/validator/success.mustache: -------------------------------------------------------------------------------- 1 | # :tada: **Administrative Access Request Validated** 2 | 3 | Your request has been validated! The following details were parsed from your 4 | request. 5 | 6 | 7 | 8 | 9 | | Key | Value | 10 | | ---------- | ---------- | 11 | {{#each issue}} 12 | | `{{@key}}` | {{{newlines this}}} | 13 | {{/each}} 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/workflow-examples/check-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Check for Users to Demote 2 | 3 | on: 4 | schedule: 5 | - cron: '0 * * * *' # Run once every hour. 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | issues: write 11 | 12 | env: 13 | # TODO: Change this to the organization(s) that you support. 14 | ALLOWED_ORGS: org1,org2 15 | 16 | jobs: 17 | check: 18 | name: Close Expired Issues 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | # Checkout the repository. This is required to access the issue form 23 | # template and any custom validation logic. 24 | - name: Checkout 25 | id: checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Check for Users to Demote 29 | id: check-demote 30 | uses: ActionsDesk/admin-support-issueops-actions/admin-support-cli@v2 31 | with: 32 | action: check_auto_demotion 33 | admin_token: ${{ secrets.PAT }} 34 | allowed_orgs: ${{ env.ALLOWED_ORGS }} 35 | -------------------------------------------------------------------------------- /.github/workflow-examples/demotion-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Demote a User 2 | 3 | on: 4 | issues: 5 | types: 6 | - closed 7 | 8 | permissions: 9 | contents: write 10 | issues: write 11 | 12 | env: 13 | # TODO: Change this to the organization(s) that you support. 14 | ALLOWED_ORGS: org1,org2 15 | # TODO: Change this to the team you would like to notify if there is an error. 16 | DEMOTION_ERROR_NOTIFY: '@org/team' 17 | 18 | jobs: 19 | demote: 20 | name: Promote @${{ github.event.issue.user.login }} to Member 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | # Checkout the repository. This is required to access the issue form 25 | # template and any custom validation logic. 26 | - name: Checkout 27 | id: checkout 28 | uses: actions/checkout@v4 29 | 30 | # Add a label to indicate that the automation is running. 31 | - name: Add Running Label 32 | id: add-running-label 33 | uses: issue-ops/labeler@v2 34 | with: 35 | action: add 36 | issue_number: ${{ github.event.issue.number }} 37 | labels: | 38 | automation-running 39 | 40 | # Parse the issue body into JSON. 41 | - name: Parse Issue 42 | id: parser 43 | uses: issue-ops/parser@v4 44 | with: 45 | body: ${{ github.event.issue.body }} 46 | issue-form-template: request_admin_permissions.yml 47 | workspace: ${{ github.workspace }} 48 | 49 | - name: Revoke Admin Access 50 | id: revoke 51 | uses: ActionsDesk/admin-support-issueops-actions/admin-support-cli@v2 52 | with: 53 | action: promote_demote 54 | admin_token: ${{ secrets.PAT }} 55 | allowed_orgs: ${{ env.ALLOWED_ORGS }} 56 | parsed_issue: ${{ steps.parser.outputs.json }} 57 | role: member 58 | username: ${{ github.event.issue.user.login }} 59 | 60 | - name: Set Demotion Date 61 | id: set-demotion-date 62 | run: | 63 | DEMOTION_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 64 | echo "::set-output name=demotion_date::$DEMOTION_DATE" 65 | 66 | # This is required to allow the audit logs to catch up. 67 | - name: Sleep 68 | id: sleep 69 | run: | 70 | sleep 30 71 | 72 | - name: Generate Report 73 | id: generate-report 74 | uses: ActionsDesk/admin-support-issueops-actions/admin-support-cli@v2 75 | with: 76 | action: demotion_report 77 | admin_token: ${{ secrets.PAT }} 78 | allowed_orgs: ${{ env.ALLOWED_ORGS }} 79 | demotion_date: ${{ steps.create_demotion_date.outputs.demotion_date }} 80 | issue_number: ${{ github.event.issue.number }} 81 | parsed_issue: ${{ steps.parser.outputs.json }} 82 | promotion_date: ${{ github.event.issue.created_at }} 83 | report_path: reports 84 | username: ${{ github.event.issue.user.login }} 85 | 86 | - name: Persist the audit of the demotion 87 | uses: EndBug/add-and-commit@v7 88 | with: 89 | add: reports 90 | message: 91 | 'Demotion Report - Issue #${{ github.event.issue.number }} ${{ 92 | github.event.issue.user.login }}' 93 | push: true 94 | 95 | ################################################################## 96 | # The following steps should be run if the action was successful. 97 | ################################################################## 98 | 99 | - if: ${{ success() }} 100 | name: Add a Comment 101 | id: add-comment 102 | uses: actions/github-script@v7 103 | with: 104 | github-token: ${{ github.token }} 105 | script: | 106 | await github.rest.issues.createComment({ 107 | issue_number: context.issue.number, 108 | owner: context.repo.owner, 109 | repo: context.repo.repo, 110 | body: `### :white_check_mark: Request Complete 111 | 112 | The user **@${{github.event.issue.user.login}}**'s temporary admin access has been revoked has been demoted from ${{ steps.revoke.outputs.organization }}. 113 | 114 | This issue will be locked to avoid new interactions. 115 | 116 | 117 | Find details of the automation here. 118 | 119 | ` 120 | }) 121 | 122 | await github.rest.issues.lock({ 123 | issue_number: context.issue.number, 124 | owner: context.repo.owner, 125 | repo: context.repo.repo 126 | }) 127 | 128 | # Add a label to indicate that the user has been demoted. 129 | - if: ${{ success() }} 130 | name: Add Promoted Label 131 | id: add-promoted-label 132 | uses: issue-ops/labeler@v2 133 | with: 134 | action: add 135 | issue_number: ${{ github.event.issue.number }} 136 | labels: | 137 | user-demoted 138 | 139 | # Remove the user-promoted label. 140 | - if: ${{ success() }} 141 | name: Remove Promotion Label 142 | id: remove-promotion-label 143 | uses: issue-ops/labeler@v2 144 | with: 145 | action: remove 146 | issue_number: ${{ github.event.issue.number }} 147 | labels: | 148 | user-promoted 149 | automation-running 150 | 151 | ################################################################## 152 | # The following steps should be run if the action failed. 153 | ################################################################## 154 | 155 | - if: ${{ failure() }} 156 | name: Notify the Team 157 | uses: actions/github-script@v7 158 | with: 159 | github-token: ${{ github.token }} 160 | script: | 161 | await github.rest.issues.createComment({ 162 | issue_number: context.issue.number, 163 | owner: context.repo.owner, 164 | repo: context.repo.repo, 165 | body: `### :x: Demotion Failed 166 | 167 | CC: ${{ env.DEMOTION_ERROR_NOTIFY }} 168 | 169 | Please investigate to ensure the user's access has been correctly revoked. 170 | 171 | 172 | Find details of the automation here. 173 | 174 | ` 175 | }) 176 | 177 | ################################################################## 178 | # The following steps should always run. 179 | ################################################################## 180 | 181 | # Remove the automation label. 182 | - if: ${{ always() }} 183 | name: Remove Automation Label 184 | id: remove-automation-label 185 | uses: issue-ops/labeler@v2 186 | with: 187 | action: remove 188 | issue_number: ${{ github.event.issue.number }} 189 | labels: | 190 | automation-running 191 | -------------------------------------------------------------------------------- /.github/workflow-examples/promotion-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Promotion Workflow 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - edited 8 | 9 | permissions: 10 | contents: write 11 | issues: write 12 | 13 | env: 14 | # TODO: Change this to the organization(s) that you support. 15 | ALLOWED_ORGS: org1,org2 16 | 17 | jobs: 18 | promote: 19 | name: Promote @${{ github.event.issue.user.login }} to Admin 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | # Checkout the repository. This is required to access the issue form 24 | # template and any custom validation logic. 25 | - name: Checkout 26 | id: checkout 27 | uses: actions/checkout@v4 28 | 29 | # Add a label to indicate that the automation is running. 30 | - name: Add Running Label 31 | id: add-running-label 32 | uses: issue-ops/labeler@v2 33 | with: 34 | action: add 35 | issue_number: ${{ github.event.issue.number }} 36 | labels: | 37 | automation-running 38 | 39 | # Parse the issue body into JSON. 40 | - name: Parse Issue 41 | id: parser 42 | uses: issue-ops/parser@v4 43 | with: 44 | body: ${{ github.event.issue.body }} 45 | issue-form-template: request_admin_permissions.yml 46 | workspace: ${{ github.workspace }} 47 | 48 | # Validate the parsed issue body against the issue form template. 49 | - name: Validate Issue 50 | id: validate 51 | uses: issue-ops/validator@v3 52 | with: 53 | issue-form-template: example-request.yml 54 | parsed-issue-body: ${{ steps.parse.outputs.json }} 55 | workspace: ${{ github.workspace }} 56 | 57 | - name: Output Validation Results 58 | id: output 59 | run: | 60 | echo "Validation Result: ${{ steps.validate.outputs.result }}" 61 | echo "Validation Errors: ${{ steps.validate.outputs.errors }}" 62 | 63 | ############################################################### 64 | # Only run the following steps if the validation is successful. 65 | ############################################################### 66 | 67 | # Grand administrative access by calling this action. 68 | - if: ${{ steps.validate.output.result == 'success' }} 69 | name: Grant Admin Access 70 | id: grant 71 | uses: ActionsDesk/admin-support-issueops-actions/admin-support-cli@v2 72 | with: 73 | action: promote_demote 74 | allowed_orgs: ${{ env.ALLOWED_ORGS }} 75 | admin_token: ${{ secrets.PAT }} 76 | parsed_issue: ${{ steps.parser.outputs.json }} 77 | role: admin 78 | username: ${{ github.event.issue.user.login }} 79 | 80 | - if: ${{ steps.validate.output.result == 'success' }} 81 | name: Add a Comment 82 | id: add-comment 83 | uses: actions/github-script@v7 84 | with: 85 | github-token: ${{ github.token }} 86 | script: | 87 | await github.rest.issues.createComment({ 88 | issue_number: context.issue.number, 89 | owner: context.repo.owner, 90 | repo: context.repo.repo, 91 | body: `### :white_check_mark: Request Complete 92 | 93 | The user **@${{ github.event.issue.user.login }}** has been granted temporary admin access to ${{ steps.grant.outputs.organization }}. When you finish your administrative tasks, please close this issue to demote your permissions. 94 | 95 | 96 | Find details of the automation here. 97 | 98 | ` 99 | }) 100 | 101 | # Add a label to indicate that the user has been promoted. 102 | - if: ${{ steps.validate.output.result == 'success' }} 103 | name: Add Promoted Label 104 | id: add-promoted-label 105 | uses: issue-ops/labeler@v2 106 | with: 107 | action: add 108 | issue_number: ${{ github.event.issue.number }} 109 | labels: | 110 | user-promoted 111 | 112 | ############################################################### 113 | # The following steps should only be run if the action fails. 114 | ############################################################### 115 | 116 | # Add a label to indicate that the promotion failed. 117 | - if: ${{ failure() }} 118 | name: Add Error Label 119 | id: add-error-label 120 | uses: issue-ops/labeler@v2 121 | with: 122 | action: add 123 | issue_number: ${{ github.event.issue.number }} 124 | labels: | 125 | promotion-error 126 | 127 | ############################################################### 128 | # The following steps should always run. 129 | ############################################################### 130 | 131 | # Remove the label added previously. 132 | - if: ${{ always() }} 133 | name: Remove Running Label 134 | id: remove-running-label 135 | uses: issue-ops/labeler@v2 136 | with: 137 | action: remove 138 | issue_number: ${{ github.event.issue.number }} 139 | labels: | 140 | automation-running 141 | -------------------------------------------------------------------------------- /.github/workflows/check-dist.yml: -------------------------------------------------------------------------------- 1 | name: Check dist/ 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - admin-support-cli/**/* 9 | pull_request: 10 | branches: 11 | - main 12 | paths: 13 | - admin-support-cli/**/* 14 | 15 | defaults: 16 | run: 17 | working-directory: admin-support-cli 18 | 19 | permissions: 20 | contents: read 21 | 22 | jobs: 23 | check-dist: 24 | name: Check dist/ 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Checkout 29 | id: checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup Node.js 33 | id: setup-node 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version-file: .node-version 37 | cache: npm 38 | cache-dependency-path: admin-support-cli/package-lock.json 39 | 40 | - name: Install Dependencies 41 | id: install 42 | run: npm ci 43 | 44 | - name: Build dist/ Directory 45 | id: build 46 | run: npm run bundle 47 | 48 | - name: Compare Expected and Actual Directories 49 | id: diff 50 | run: | 51 | if [ "$(git diff --ignore-space-at-eol --text dist/ | wc -l)" -gt "0" ]; then 52 | echo "Detected uncommitted changes after build. See status below:" 53 | git diff --ignore-space-at-eol --text dist/ 54 | exit 1 55 | fi 56 | 57 | - if: ${{ failure() && steps.diff.conclusion == 'failure' }} 58 | name: Upload Artifact 59 | id: upload 60 | uses: actions/upload-artifact@v4 61 | with: 62 | name: dist 63 | path: admin-support-cli/dist/ 64 | -------------------------------------------------------------------------------- /.github/workflows/continuous-delivery.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Delivery 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | release: 16 | name: Release Version 17 | runs-on: ubuntu-latest 18 | 19 | if: | 20 | github.event_name == 'workflow_dispatch' || 21 | (github.event.pull_request.merged == true && 22 | startsWith(github.head_ref, 'dependabot/') == false) 23 | 24 | steps: 25 | - name: Checkout 26 | id: checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-tags: true 30 | 31 | - name: Tag 32 | id: tag 33 | uses: issue-ops/semver@v2 34 | with: 35 | manifest-path: admin-support-cli/package.json 36 | workspace: ${{ github.workspace }} 37 | ref: main 38 | 39 | - name: Create Release 40 | id: release 41 | uses: issue-ops/releaser@v2 42 | with: 43 | tag: v${{ steps.tag.outputs.version }} 44 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | defaults: 12 | run: 13 | working-directory: admin-support-cli 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | test-typescript: 20 | name: TypeScript Tests 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Checkout 25 | id: checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup Node.js 29 | id: setup-node 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version-file: .node-version 33 | cache: npm 34 | cache-dependency-path: admin-support-cli/package-lock.json 35 | 36 | - name: Install Dependencies 37 | id: npm-ci 38 | run: npm ci 39 | 40 | - name: Check Format 41 | id: npm-format-check 42 | run: npm run format:check 43 | 44 | - name: Lint 45 | id: npm-lint 46 | run: npm run lint 47 | 48 | - name: Test 49 | id: npm-ci-test 50 | run: npm run ci-test 51 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Lint Codebase 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | defaults: 12 | run: 13 | working-directory: admin-support-cli 14 | 15 | permissions: 16 | contents: read 17 | packages: read 18 | statuses: write 19 | 20 | jobs: 21 | lint: 22 | name: Lint Codebase 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | id: checkout 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 31 | 32 | - name: Setup Node.js 33 | id: setup-node 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version-file: .node-version 37 | cache: npm 38 | cache-dependency-path: admin-support-cli/package-lock.json 39 | 40 | - name: Install Dependencies 41 | id: install 42 | run: npm ci 43 | 44 | - name: Lint Codebase 45 | id: super-linter 46 | uses: super-linter/super-linter/slim@v7 47 | env: 48 | DEFAULT_BRANCH: main 49 | FILTER_REGEX_EXCLUDE: '**/dist/**/*' 50 | GITHUB_TOKEN: ${{ github.token }} 51 | LINTER_RULES_PATH: ${{ github.workspace }} 52 | VALIDATE_ALL_CODEBASE: true 53 | VALIDATE_JAVASCRIPT_ES: false 54 | VALIDATE_JAVASCRIPT_STANDARD: false 55 | VALIDATE_JSCPD: false 56 | VALIDATE_TYPESCRIPT_ES: false 57 | VALIDATE_TYPESCRIPT_STANDARD: false 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/node 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # TypeScript v1 declaration files 50 | typings/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variables file 80 | .env 81 | .env.test 82 | .env*.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | 112 | # Stores VSCode versions used for testing VSCode extensions 113 | .vscode-test 114 | 115 | # End of https://www.toptal.com/developers/gitignore/api/node 116 | .env 117 | -------------------------------------------------------------------------------- /.markdown-lint.yml: -------------------------------------------------------------------------------- 1 | # See: https://github.com/DavidAnson/markdownlint 2 | 3 | # Unordered list style 4 | MD004: 5 | style: dash 6 | 7 | # Disable line length for tables 8 | MD013: 9 | tables: false 10 | 11 | # Ordered list item prefix 12 | MD029: 13 | style: one 14 | 15 | # Spaces after list markers 16 | MD030: 17 | ul_single: 1 18 | ol_single: 1 19 | ul_multi: 1 20 | ol_multi: 1 21 | 22 | # Code block style 23 | MD046: 24 | style: fenced 25 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.9.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | **/dist/ 3 | **/node_modules/ 4 | **/coverage/ 5 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # See: https://prettier.io/docs/en/configuration 2 | 3 | printWidth: 80 4 | tabWidth: 2 5 | useTabs: false 6 | semi: false 7 | singleQuote: true 8 | quoteProps: as-needed 9 | jsxSingleQuote: false 10 | trailingComma: none 11 | bracketSpacing: true 12 | bracketSameLine: true 13 | arrowParens: always 14 | proseWrap: always 15 | htmlWhitespaceSensitivity: css 16 | endOfLine: lf 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Action", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "npx", 9 | "cwd": "${workspaceRoot}", 10 | "args": ["local-action", "./admin-support-cli", "src/main.ts", ".env"], 11 | "console": "integratedTerminal", 12 | "skipFiles": ["/**", "node_modules/**"] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.yaml-lint.yml: -------------------------------------------------------------------------------- 1 | # See: https://yamllint.readthedocs.io/en/stable/ 2 | 3 | rules: 4 | document-end: disable 5 | document-start: 6 | level: warning 7 | present: false 8 | line-length: 9 | level: warning 10 | max: 80 11 | allow-non-breakable-words: true 12 | allow-non-breakable-inline-mappings: true 13 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | ############################################################################ 2 | # Repository CODEOWNERS # 3 | # Order is important! The last matching pattern takes the most precedence. # 4 | ############################################################################ 5 | 6 | # Default owners, unless a later match takes precedence. 7 | * @ActionsDesk/services-delivery 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and 9 | expression, level of experience, education, socio-economic status, nationality, 10 | personal appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, and other contributions that are 42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at 59 | [opensource@github.com](mailto:opensource@github.com). All complaints will be 60 | reviewed and investigated and will result in a response that is deemed necessary 61 | and appropriate to the circumstances. The project team is obligated to maintain 62 | confidentiality with regard to the reporter of an incident. Further details of 63 | specific enforcement policies may be posted separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the 72 | [Contributor Covenant](https://www.contributor-covenant.org), version 1.4, 73 | available at 74 | [https://www.contributor-covenant.org/version/1/4/code-of-conduct.html](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html) 75 | 76 | For answers to common questions about this code of conduct, see 77 | [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). 78 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | :wave: Hi there! We're thrilled that you'd like to contribute to this project. 4 | Your help is essential for keeping it great. 5 | 6 | ## Submitting a Pull Request 7 | 8 | Pull requests (PRs) are used for adding new playbooks, roles, and documents to 9 | the repository, or editing the existing ones. 10 | 11 | ### With Write Access 12 | 13 | 1. Clone the repository 14 | 1. Create a new branch 15 | 16 | ```bash 17 | git checkout -b my-branch-name 18 | ``` 19 | 20 | 1. Commit your changes 21 | 1. Push your branch 22 | 1. Open a PR 23 | 1. Pat yourself on the back and wait for your pull request to be reviewed and 24 | merged! 25 | 26 | ### Without Write Access 27 | 28 | 1. Fork and clone the repository 29 | 1. Create a new branch 30 | 31 | ```bash 32 | git checkout -b my-branch-name 33 | ``` 34 | 35 | 1. Commit your changes 36 | 1. Push your branch to your fork 37 | 1. Navigate to this repository on GitHub.com 38 | 1. Open a PR 39 | 1. Pat your self on the back and wait for your pull request to be reviewed and 40 | merged! 41 | 42 | Here are a few things you can do that will increase the likelihood of your pull 43 | request being accepted: 44 | 45 | - Keep your change as focused as possible. If there are multiple changes you 46 | would like to make that are not dependent upon each other, consider submitting 47 | them as separate pull requests. 48 | - Write 49 | [good commit messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 50 | 51 | _Work in Progress_ pull requests are also welcome to get feedback early on, or 52 | if there is something blocking you. 53 | 54 | - Create a branch with a name that identifies the user and nature of the changes 55 | (similar to `user/branch-purpose`) 56 | - Open a pull request and request a review from the 57 | `@ActionsDesk/services-delivery` team 58 | 59 | ## Releasing 60 | 61 | If you are the current maintainer of this action: 62 | 63 | 1. Create a 64 | [Tag](https://stackoverflow.com/questions/18216991/create-a-tag-in-a-github-repository) 65 | 2. Draft 66 | [Release](https://help.github.com/en/github/administering-a-repository/managing-releases-in-a-repository) 67 | document explaining details of Release 68 | 3. Look for approval from 69 | [CODEOWNERS](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners) 70 | 71 | ## Resources 72 | 73 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 74 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 75 | - [GitHub Help](https://help.github.com) 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Administration Support IssueOps 2 | 3 | This repository contains automation that allows the support team of an 4 | organization to use [IssueOps](https://issue-ops.github.io/docs/) to request 5 | temporary elevation of their access to perform tasks that require administrative 6 | permission. All the operations done during the process are reported as part of 7 | the audit log of the user. Closing the issue removes the permission. 8 | 9 | [![Code Coverage](./badges/coverage.svg)](./badges/coverage.svg) 10 | 11 | ## v2 Migration 12 | 13 | There are a number of major changes in the v2 release of this action. 14 | Specifically: 15 | 16 | - The CLI component has been removed in favor of using the 17 | [`@github/local-action`](https://github.com/github/local-action) utility 18 | - The action is now using Node.js v20 19 | - The action inputs have been updated to not require multiple runs to parse and 20 | then invoke the correct command 21 | 22 | When migrating, please refer to the 23 | [example workflows](./.github/workflow-examples) for the correct usage of the 24 | action. 25 | 26 | ## Setup 27 | 28 | To use this action in your own organization(s), follow the below steps: 29 | 30 | 1. Create a 31 | [Personal Access Token (PAT)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) 32 | with `admin:org` and `repo` write permissions 33 | 34 | > [!NOTE] 35 | > 36 | > It is **highly** recommended to use a machine user for this purpose, not a 37 | > personal account. 38 | 39 | 1. Clone this repository into your organization 40 | 1. In your cloned repository, create a 41 | [GitHub Actions secret](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository) 42 | named `PAT` using the token you created previously 43 | 1. Move the following workflow files from the `.github/workflow-examples/` 44 | directory to the `.github/workflows/` directory: 45 | - [`check-workflow.yml`](./.github/workflows/check-workflow.yml) 46 | - [`demotion-workflow.yml`](./.github/workflows/demotion-workflow.yml) 47 | - [`promotion-workflow.yml`](./.github/workflows/promotion-workflow.yml) 48 | 1. Update the `DEMOTION_ERROR_NOTIFY` environment variable in the following 49 | workflow files: 50 | - [`demotion-workflow.yml`](./.github/workflows/demotion-workflow.yml) 51 | 1. Update the `ALLOWED_ORGS` environment variable in the following workflow 52 | files: 53 | 54 | - [`check-workflow.yml`](./.github/workflows/check-workflow.yml) 55 | - [`demotion-workflow.yml`](./.github/workflows/demotion-workflow.yml) 56 | - [`promotion-workflow.yml`](./.github/workflows/promotion-workflow.yml) 57 | 58 | This should be see to a comma-separated list of the organizations where you 59 | want to allow to use this automation (and the `PAT` you created can acess) 60 | 61 | ```yaml 62 | env: 63 | ALLOWED_ORGS: 'octo-org,octo-org2' 64 | ``` 65 | 66 | 1. Commit and push the changes to your repository 67 | 1. [Enable GitHub Actions](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository) 68 | in the repository 69 | 70 | As this automation provides admin access to organizations, you may only want 71 | certain teams to be able to fill issues in. 72 | 73 | 1. Enable 74 | [repositorty rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) 75 | so only certain users can access the repository 76 | 1. Grant `read` permission to any users or teams who will need to be able to 77 | create issues in the repository 78 | 1. Set the repository visibility to `private`, not `internal` 79 | 80 | ## Automation 81 | 82 | To request the permission: 83 | 84 | 1. Open an issue using the 85 | [template](https://github.com/ActionsDesk/admin-support-issueops-actions/issues/new?template=request_admin_permission.yml) 86 | provided in this repository 87 | 88 | | Field | Description | 89 | | ------------ | ------------------------------------------------- | 90 | | Organization | Organization where you want to be promoted | 91 | | Description | Expanation of why this request is being submitted | 92 | | Ticket | ID of a related ticket in your support system | 93 | | Duration | Duration in hours that you need the permission | 94 | 95 | The completed form will look like the following: 96 | 97 | ```markdown 98 | ### Organization 99 | 100 | octo-org 101 | 102 | ### Description 103 | 104 | A user requires to be added to a team and nobody else can give him access 105 | 106 | ### Ticket 107 | 108 | 1234 109 | 110 | ### Duration 111 | 112 | 1 113 | ``` 114 | 115 | 1. Once the issue is created, a GitHub Actions workflow will trigger providing 116 | you with temporary access to perform your task(s) 117 | 1. Once you have completed your task(s), close the issue to revoke your access 118 | automatically 119 | 1. All the actions performed as an admin will be audited and added to the 120 | repository, so be cautious of the changes done in the organization 121 | 122 | > [!IMPORTANT] 123 | > 124 | > The duration requested will be approximate and has a ~1h error. We recommend 125 | > to close the issue when the task is completed. 126 | 127 | ## Development 128 | 129 | ### CLI Usage 130 | 131 | The [`@github/local-action`](https://github.com/github/local-action) utility can 132 | be used to test your action locally. It is a simple command-line tool that 133 | "stubs" (or simulates) the GitHub Actions Toolkit. This way, you can run your 134 | action locally without having to commit and push your changes to a repository. 135 | 136 | The `local-action` utility can be run in the following ways: 137 | 138 | - Visual Studio Code Debugger 139 | 140 | Make sure to review and, if needed, update 141 | [`.vscode/launch.json`](./.vscode/launch.json) 142 | 143 | - Terminal/Command Prompt 144 | 145 | ```bash 146 | cd admin-support-cli 147 | 148 | # npx local-action 149 | npx local-action . src/main.ts .env 150 | ``` 151 | 152 | You can provide a `.env` file to the `local-action` CLI to set environment 153 | variables used by the GitHub Actions Toolkit. For example, setting inputs and 154 | event payload data used by your action. For more information, see the example 155 | file, [`.env.example`](./admin-support-cli/.env.example), and the 156 | [GitHub Actions Documentation](https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables). 157 | 158 | Additionally, this `local-action` CLI can make use of mock webhook payloads. You 159 | can provide a JSON file path for the `GITHUB_EVENT_PATH` environment variable in 160 | the `.env` file. For a minimal example that can be used with this action, see 161 | [`issue_payload.example.json`](./admin-support-cli/issue_payload.example.json). 162 | -------------------------------------------------------------------------------- /SECURITY: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you discover a security issue in this repo, please submit it to 4 | [GitHub Expert Services](https://github.com/services#services-contact). 5 | 6 | Thanks for helping make GitHub Actions safe for everyone. 7 | -------------------------------------------------------------------------------- /admin-support-cli/.env.example: -------------------------------------------------------------------------------- 1 | # dotenv-linter:off IncorrectDelimiter 2 | # dotenv-linter:off QuoteCharacter 3 | 4 | # Do not commit your actual .env file to Git! This may contain secrets or other 5 | # private information. 6 | 7 | # Enable/disable step debug logging (default: `false`). For local debugging, it 8 | # may be useful to set it to `true`. 9 | ACTIONS_STEP_DEBUG=true 10 | 11 | # GitHub Actions inputs should follow `INPUT_` format (case-sensitive). 12 | # Hyphens should not be converted to underscores! 13 | 14 | INPUT_ACTION="promote_demote" # check_auto_demotion | demotion_report | promote_demote 15 | INPUT_ADMIN_TOKEN="EXAMPLE_TOKEN" 16 | INPUT_ALLOWED_ORGS="octo-org,octo-org2" 17 | INPUT_DEMOTION_DATE="2025-01-01T00:00:00Z" 18 | INPUT_ISSUE_NUMBER="1234" 19 | INPUT_PARSED_ISSUE='{"organization": "octo-org","description": "I need admin access to the octo-org organization.","ticket": "1234","duration": "1"}' 20 | INPUT_PROMOTION_DATE="2025-01-01T00:00:00Z" 21 | INPUT_REPORT_PATH="reports" 22 | INPUT_ROLE="admin" 23 | INPUT_USERNAME="mona" 24 | 25 | # GitHub Actions default environment variables. These are set for every run of a 26 | # workflow and can be used in your actions. Setting the value here will override 27 | # any value set by the local-action tool. 28 | # https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables 29 | 30 | # CI="true" 31 | # GITHUB_ACTION="" 32 | # GITHUB_ACTION_PATH="" 33 | # GITHUB_ACTION_REPOSITORY="" 34 | # GITHUB_ACTIONS="" 35 | # GITHUB_ACTOR="" 36 | # GITHUB_ACTOR_ID="" 37 | # GITHUB_API_URL="" 38 | # GITHUB_BASE_REF="" 39 | # GITHUB_ENV="" 40 | # GITHUB_EVENT_NAME="" 41 | # GITHUB_EVENT_PATH="" 42 | # GITHUB_GRAPHQL_URL="" 43 | # GITHUB_HEAD_REF="" 44 | # GITHUB_JOB="" 45 | # GITHUB_OUTPUT="" 46 | # GITHUB_PATH="" 47 | # GITHUB_REF="" 48 | # GITHUB_REF_NAME="" 49 | # GITHUB_REF_PROTECTED="" 50 | # GITHUB_REF_TYPE="" 51 | # GITHUB_REPOSITORY="" 52 | # GITHUB_REPOSITORY_ID="" 53 | # GITHUB_REPOSITORY_OWNER="" 54 | # GITHUB_REPOSITORY_OWNER_ID="" 55 | # GITHUB_RETENTION_DAYS="" 56 | # GITHUB_RUN_ATTEMPT="" 57 | # GITHUB_RUN_ID="" 58 | # GITHUB_RUN_NUMBER="" 59 | # GITHUB_SERVER_URL="" 60 | # GITHUB_SHA="" 61 | # GITHUB_STEP_SUMMARY="" 62 | # GITHUB_TRIGGERING_ACTOR="" 63 | # GITHUB_WORKFLOW="" 64 | # GITHUB_WORKFLOW_REF="" 65 | # GITHUB_WORKFLOW_SHA="" 66 | # GITHUB_WORKSPACE="" 67 | # RUNNER_ARCH="" 68 | # RUNNER_DEBUG="" 69 | # RUNNER_NAME="" 70 | # RUNNER_OS="" 71 | # RUNNER_TEMP="" 72 | # RUNNER_TOOL_CACHE="" 73 | -------------------------------------------------------------------------------- /admin-support-cli/__fixtures__/@actions/core.ts: -------------------------------------------------------------------------------- 1 | import type * as core from '@actions/core' 2 | import { jest } from '@jest/globals' 3 | 4 | export const debug = jest.fn() 5 | export const error = jest.fn() 6 | export const info = jest.fn() 7 | export const getInput = jest.fn() 8 | export const setOutput = jest.fn() 9 | export const setFailed = jest.fn() 10 | export const warning = jest.fn() 11 | -------------------------------------------------------------------------------- /admin-support-cli/__fixtures__/@actions/github.ts: -------------------------------------------------------------------------------- 1 | import * as octokit from '../@octokit/rest.js' 2 | 3 | export const getOctokit = () => octokit 4 | 5 | export const context = { 6 | repo: { 7 | owner: 'ActionsDesk', 8 | repo: 'admin-support-issueops-actions' 9 | }, 10 | payload: { 11 | action: 'opened', 12 | issue: { 13 | assignee: { 14 | login: 'ncalteen' 15 | }, 16 | body: '### Organization\n\nocto-org\n\nDescription\n\nI need admin access to the octo-org organization\n\nTicket\n\n1234\n\nDuration\n\n1', 17 | closed_at: null, 18 | created_at: '2025-01-24T12:00:00Z', 19 | number: 1, 20 | state: 'open', 21 | state_reason: null, 22 | title: 'Administrative Access Request', 23 | updated_at: '2025-01-24T12:00:00Z', 24 | user: { 25 | login: 'ncalteen' 26 | } 27 | }, 28 | organization: { 29 | login: 'ActionsDesk' 30 | }, 31 | repository: { 32 | full_name: 'ActionsDesk/admin-support-issueops-actions', 33 | name: 'admin-support-issueops-actions', 34 | owner: { 35 | login: 'ActionsDesk' 36 | }, 37 | url: 'https://api.github.com/repos/ActionsDesk/admin-support-issueops-actions' 38 | } 39 | }, 40 | eventName: 'issues', 41 | action: 'opened' 42 | } 43 | -------------------------------------------------------------------------------- /admin-support-cli/__fixtures__/@github/issue-parser.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | export const parseIssue = jest.fn() 4 | -------------------------------------------------------------------------------- /admin-support-cli/__fixtures__/@octokit/rest.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import { Endpoints } from '@octokit/types' 3 | 4 | export const graphql = jest.fn() 5 | export const paginate = jest.fn() 6 | export const rest = { 7 | issues: { 8 | createComment: 9 | jest.fn< 10 | () => Promise< 11 | Endpoints['POST /repos/{owner}/{repo}/issues/{issue_number}/comments']['response'] 12 | > 13 | >(), 14 | listForRepo: 15 | jest.fn< 16 | () => Promise 17 | >(), 18 | update: 19 | jest.fn< 20 | () => Promise< 21 | Endpoints['PATCH /repos/{owner}/{repo}/issues/{issue_number}']['response'] 22 | > 23 | >() 24 | }, 25 | orgs: { 26 | setMembershipForUser: 27 | jest.fn< 28 | () => Promise< 29 | Endpoints['PUT /orgs/{org}/memberships/{username}']['response'] 30 | > 31 | >() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /admin-support-cli/__fixtures__/commands/actions/check-auto-demotion-action.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import { getInputsMock } from '../../../__fixtures__/inputs.js' 3 | import type { Inputs } from '../../../src/types.js' 4 | 5 | export const api = jest.fn() 6 | export const constructor = jest.fn() 7 | export const validate = jest.fn() 8 | export const execute = jest.fn() 9 | 10 | export class CheckAutoDemotionAction { 11 | api = api 12 | params: Inputs = getInputsMock() 13 | 14 | constructor() { 15 | constructor() 16 | } 17 | 18 | async validate() { 19 | validate() 20 | } 21 | 22 | async execute() { 23 | return execute() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /admin-support-cli/__fixtures__/commands/actions/demotion-report-action.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import { getInputsMock } from '../../../__fixtures__/inputs.js' 3 | import type { Inputs } from '../../../src/types.js' 4 | 5 | export const api = jest.fn() 6 | export const constructor = jest.fn() 7 | export const validate = jest.fn() 8 | export const execute = jest.fn() 9 | 10 | export class DemotionReportAction { 11 | api = api 12 | params: Inputs = getInputsMock() 13 | 14 | constructor() { 15 | constructor() 16 | } 17 | 18 | async validate() { 19 | validate() 20 | } 21 | 22 | async execute() { 23 | return execute() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /admin-support-cli/__fixtures__/commands/actions/promote-demote-action.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import { getInputsMock } from '../../../__fixtures__/inputs.js' 3 | import type { Inputs } from '../../../src/types.js' 4 | 5 | export const api = jest.fn() 6 | export const constructor = jest.fn() 7 | export const validate = jest.fn() 8 | export const execute = jest.fn() 9 | 10 | export class PromoteDemoteAction { 11 | api = api 12 | params: Inputs = getInputsMock() 13 | 14 | constructor() { 15 | constructor() 16 | } 17 | 18 | async validate() { 19 | validate() 20 | } 21 | 22 | async execute() { 23 | return execute() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /admin-support-cli/__fixtures__/fs.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | export const existsSync = jest.fn() 4 | export const mkdirSync = jest.fn() 5 | export const readdirSync = jest.fn() 6 | export const readFileSync = jest.fn() 7 | export const realpathSync = jest.fn() 8 | export const statSync = jest.fn() 9 | export const writeFileSync = jest.fn() 10 | 11 | export default { 12 | existsSync, 13 | mkdirSync, 14 | readdirSync, 15 | readFileSync, 16 | realpathSync, 17 | statSync, 18 | writeFileSync 19 | } 20 | -------------------------------------------------------------------------------- /admin-support-cli/__fixtures__/inputs.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import type { getInputs } from '../src/inputs.js' 4 | 5 | export const getInputsMock = jest.fn() 6 | -------------------------------------------------------------------------------- /admin-support-cli/__tests__/commands/actions/check-auto-demotion-action.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import * as core from '../../../__fixtures__/@actions/core.js' 3 | import * as github from '../../../__fixtures__/@actions/github.js' 4 | import * as issueParser from '../../../__fixtures__/@github/issue-parser.js' 5 | import * as octokit from '../../../__fixtures__/@octokit/rest.js' 6 | import { getInputsMock } from '../../../__fixtures__/inputs.js' 7 | import { Action } from '../../../src/enums.js' 8 | import type { Inputs } from '../../../src/types.js' 9 | 10 | // Mocks should be declared before the module being tested is imported. 11 | jest.unstable_mockModule('@actions/core', () => core) 12 | jest.unstable_mockModule('@actions/github', () => github) 13 | jest.unstable_mockModule('@github/issue-parser', () => issueParser) 14 | jest.unstable_mockModule('@octokit/rest', async () => { 15 | class Octokit { 16 | constructor() { 17 | return octokit 18 | } 19 | } 20 | 21 | return { 22 | Octokit 23 | } 24 | }) 25 | 26 | jest.unstable_mockModule('../../../src/inputs.js', () => ({ 27 | getInputs: getInputsMock 28 | })) 29 | 30 | // The module being tested should be imported dynamically. This ensures that the 31 | // mocks are used in place of any actual dependencies. 32 | const { CheckAutoDemotionAction } = await import( 33 | '../../../src/commands/actions/check-auto-demotion-action.js' 34 | ) 35 | 36 | const { Octokit } = await import('@octokit/rest') 37 | const mocktokit = jest.mocked(new Octokit()) 38 | 39 | describe('CheckAutoDemotionAction', () => { 40 | beforeEach(() => { 41 | getInputsMock.mockReset().mockReturnValueOnce({ 42 | action: Action.CHECK_AUTO_DEMOTION 43 | } as Inputs) 44 | }) 45 | 46 | afterEach(() => { 47 | jest.resetAllMocks() 48 | }) 49 | 50 | describe('constructor', () => { 51 | it('Sets the params and api properties', () => { 52 | const check = new CheckAutoDemotionAction() 53 | 54 | expect(check.params).toEqual({ 55 | action: Action.CHECK_AUTO_DEMOTION 56 | }) 57 | expect(check.api).toEqual(mocktokit) 58 | }) 59 | }) 60 | 61 | describe('validate', () => { 62 | it('Does nothing', async () => { 63 | const check = new CheckAutoDemotionAction() 64 | 65 | await check.validate() 66 | 67 | expect(core.info).toHaveBeenCalled() 68 | }) 69 | }) 70 | 71 | describe('execute', () => { 72 | it('Processes the issue list', async () => { 73 | mocktokit.paginate.mockResolvedValueOnce([ 74 | { 75 | body: 'body', 76 | created_at: '2025-01-01T00:00:00Z', 77 | number: 1 78 | } 79 | ]) 80 | issueParser.parseIssue.mockReturnValueOnce({ 81 | duration: '1' 82 | }) 83 | 84 | const check = new CheckAutoDemotionAction() 85 | 86 | await check.execute() 87 | 88 | expect(mocktokit.paginate).toHaveBeenCalled() 89 | expect(issueParser.parseIssue).toHaveBeenCalledTimes(1) 90 | expect(mocktokit.rest.issues.update).toHaveBeenCalled() 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /admin-support-cli/__tests__/commands/actions/demotion-report-action.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import * as core from '../../../__fixtures__/@actions/core.js' 3 | import * as github from '../../../__fixtures__/@actions/github.js' 4 | import * as octokit from '../../../__fixtures__/@octokit/rest.js' 5 | import * as fs from '../../../__fixtures__/fs.js' 6 | import { getInputsMock } from '../../../__fixtures__/inputs.js' 7 | import { Action } from '../../../src/enums.js' 8 | import type { AuditLogEntry, Inputs } from '../../../src/types.js' 9 | 10 | // Mocks should be declared before the module being tested is imported. 11 | jest.unstable_mockModule('@actions/core', () => core) 12 | jest.unstable_mockModule('@actions/github', () => github) 13 | jest.unstable_mockModule('@octokit/rest', async () => { 14 | class Octokit { 15 | constructor() { 16 | return octokit 17 | } 18 | } 19 | 20 | return { 21 | Octokit 22 | } 23 | }) 24 | jest.unstable_mockModule('fs', () => fs) 25 | 26 | jest.unstable_mockModule('../../../src/inputs.js', () => ({ 27 | getInputs: getInputsMock 28 | })) 29 | 30 | // The module being tested should be imported dynamically. This ensures that the 31 | // mocks are used in place of any actual dependencies. 32 | const { DemotionReportAction } = await import( 33 | '../../../src/commands/actions/demotion-report-action.js' 34 | ) 35 | 36 | const { Octokit } = await import('@octokit/rest') 37 | const mocktokit = jest.mocked(new Octokit()) 38 | 39 | describe('DemotionReportAction', () => { 40 | beforeEach(() => { 41 | getInputsMock.mockReset().mockReturnValueOnce({ 42 | action: Action.DEMOTION_REPORT 43 | } as Inputs) 44 | }) 45 | 46 | afterEach(() => { 47 | jest.resetAllMocks() 48 | }) 49 | 50 | describe('constructor', () => { 51 | it('Sets the params and api properties', () => { 52 | const report = new DemotionReportAction() 53 | 54 | expect(report.params).toEqual({ 55 | action: Action.DEMOTION_REPORT 56 | }) 57 | expect(report.api).toEqual(mocktokit) 58 | }) 59 | }) 60 | 61 | describe('validate', () => { 62 | it('Fails if no demotion date is provided', async () => { 63 | getInputsMock.mockReset().mockReturnValueOnce({} as Inputs) 64 | 65 | const report = new DemotionReportAction() 66 | 67 | await expect(report.validate()).rejects.toThrow() 68 | }) 69 | 70 | it('Fails if no issue number is provided', async () => { 71 | getInputsMock.mockReset().mockReturnValueOnce({ 72 | demotionDate: new Date() 73 | } as Inputs) 74 | 75 | const report = new DemotionReportAction() 76 | 77 | await expect(report.validate()).rejects.toThrow() 78 | }) 79 | 80 | it('Fails if no parsed issue is provided', async () => { 81 | getInputsMock.mockReset().mockReturnValueOnce({ 82 | demotionDate: new Date(), 83 | issueNumber: 1 84 | } as Inputs) 85 | 86 | const report = new DemotionReportAction() 87 | 88 | await expect(report.validate()).rejects.toThrow() 89 | }) 90 | 91 | it('Fails if no promotion date is provided', async () => { 92 | getInputsMock.mockReset().mockReturnValueOnce({ 93 | demotionDate: new Date(), 94 | issueNumber: 1, 95 | parsedIssue: {} 96 | } as Inputs) 97 | 98 | const report = new DemotionReportAction() 99 | 100 | await expect(report.validate()).rejects.toThrow() 101 | }) 102 | 103 | it('Fails if no report path is provided', async () => { 104 | getInputsMock.mockReset().mockReturnValueOnce({ 105 | demotionDate: new Date(), 106 | issueNumber: 1, 107 | parsedIssue: {}, 108 | promotionDate: new Date() 109 | } as Inputs) 110 | 111 | const report = new DemotionReportAction() 112 | 113 | await expect(report.validate()).rejects.toThrow() 114 | }) 115 | 116 | it('Fails if no username is provided', async () => { 117 | getInputsMock.mockReset().mockReturnValueOnce({ 118 | demotionDate: new Date(), 119 | issueNumber: 1, 120 | parsedIssue: {}, 121 | promotionDate: new Date(), 122 | reportPath: 'reports' 123 | } as Inputs) 124 | 125 | const report = new DemotionReportAction() 126 | 127 | await expect(report.validate()).rejects.toThrow() 128 | }) 129 | 130 | it('Fails if an invalid org is provided', async () => { 131 | getInputsMock.mockReset().mockReturnValueOnce({ 132 | demotionDate: new Date(), 133 | issueNumber: 1, 134 | parsedIssue: { 135 | organization: 'invalid' 136 | }, 137 | promotionDate: new Date(), 138 | reportPath: 'reports', 139 | username: 'mona', 140 | allowedOrgs: ['valid'] 141 | } as Inputs) 142 | 143 | const report = new DemotionReportAction() 144 | 145 | await expect(report.validate()).rejects.toThrow() 146 | }) 147 | }) 148 | 149 | describe('execute', () => { 150 | beforeEach(() => { 151 | process.env.GITHUB_WORKSPACE = '/path/to/workspace' 152 | }) 153 | 154 | it('Processes the report', async () => { 155 | fs.existsSync.mockImplementation(() => false) 156 | getInputsMock.mockReset().mockReturnValueOnce({ 157 | demotionDate: new Date(), 158 | issueNumber: 1, 159 | parsedIssue: { 160 | organization: 'valid' 161 | }, 162 | promotionDate: new Date(), 163 | reportPath: 'reports', 164 | username: 'mona', 165 | allowedOrgs: ['valid'] 166 | } as Inputs) 167 | 168 | mocktokit.paginate.mockResolvedValue([ 169 | { 170 | user: 'mona', 171 | actor: 'mona' 172 | } as AuditLogEntry 173 | ]) 174 | 175 | const report = new DemotionReportAction() 176 | 177 | await report.execute() 178 | 179 | expect(fs.mkdirSync).toHaveBeenCalled() 180 | expect(fs.writeFileSync).toHaveBeenCalled() 181 | }) 182 | 183 | it('Processes a failure', async () => { 184 | fs.existsSync.mockRejectedValue({} as never) 185 | getInputsMock.mockReset().mockReturnValueOnce({ 186 | demotionDate: new Date(), 187 | issueNumber: 1, 188 | parsedIssue: { 189 | organization: 'valid' 190 | }, 191 | promotionDate: new Date(), 192 | reportPath: 'reports', 193 | username: 'mona', 194 | allowedOrgs: ['valid'] 195 | } as Inputs) 196 | 197 | mocktokit.paginate.mockRejectedValue({ 198 | message: 'An error occurred' 199 | }) 200 | 201 | const report = new DemotionReportAction() 202 | 203 | await report.execute() 204 | 205 | expect(core.setFailed).toHaveBeenCalled() 206 | }) 207 | }) 208 | }) 209 | -------------------------------------------------------------------------------- /admin-support-cli/__tests__/commands/actions/promote-demote-action.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import * as core from '../../../__fixtures__/@actions/core.js' 3 | import * as github from '../../../__fixtures__/@actions/github.js' 4 | import * as octokit from '../../../__fixtures__/@octokit/rest.js' 5 | import { getInputsMock } from '../../../__fixtures__/inputs.js' 6 | import { Action } from '../../../src/enums.js' 7 | import type { Inputs } from '../../../src/types.js' 8 | 9 | // Mocks should be declared before the module being tested is imported. 10 | jest.unstable_mockModule('@actions/core', () => core) 11 | jest.unstable_mockModule('@actions/github', () => github) 12 | jest.unstable_mockModule('@octokit/rest', async () => { 13 | class Octokit { 14 | constructor() { 15 | return octokit 16 | } 17 | } 18 | 19 | return { 20 | Octokit 21 | } 22 | }) 23 | 24 | jest.unstable_mockModule('../../../src/inputs.js', () => ({ 25 | getInputs: getInputsMock 26 | })) 27 | 28 | // The module being tested should be imported dynamically. This ensures that the 29 | // mocks are used in place of any actual dependencies. 30 | const { PromoteDemoteAction } = await import( 31 | '../../../src/commands/actions/promote-demote-action.js' 32 | ) 33 | 34 | const { Octokit } = await import('@octokit/rest') 35 | const mocktokit = jest.mocked(new Octokit()) 36 | 37 | describe('PromoteDemoteAction', () => { 38 | beforeEach(() => { 39 | getInputsMock.mockReset().mockReturnValueOnce({ 40 | action: Action.PROMOTE_DEMOTE 41 | } as Inputs) 42 | }) 43 | 44 | afterEach(() => { 45 | jest.resetAllMocks() 46 | }) 47 | 48 | describe('constructor', () => { 49 | it('Sets the params and api properties', () => { 50 | const promote = new PromoteDemoteAction() 51 | 52 | expect(promote.params).toEqual({ 53 | action: Action.PROMOTE_DEMOTE 54 | }) 55 | expect(promote.api).toEqual(mocktokit) 56 | }) 57 | }) 58 | 59 | describe('validate', () => { 60 | it('Fails if no issue is provided', async () => { 61 | getInputsMock.mockReset().mockReturnValueOnce({} as Inputs) 62 | 63 | const promote = new PromoteDemoteAction() 64 | 65 | await expect(promote.validate()).rejects.toThrow() 66 | }) 67 | 68 | it('Fails if no username is provided', async () => { 69 | getInputsMock.mockReset().mockReturnValueOnce({ 70 | parsedIssue: {} 71 | } as Inputs) 72 | 73 | const promote = new PromoteDemoteAction() 74 | 75 | await expect(promote.validate()).rejects.toThrow() 76 | }) 77 | 78 | it('Fails if no role is provided', async () => { 79 | getInputsMock.mockReset().mockReturnValueOnce({ 80 | parsedIssue: {}, 81 | username: 'mona' 82 | } as Inputs) 83 | 84 | const promote = new PromoteDemoteAction() 85 | 86 | await expect(promote.validate()).rejects.toThrow() 87 | }) 88 | 89 | it('Fails if an invalid org is provided', async () => { 90 | getInputsMock.mockReset().mockReturnValueOnce({ 91 | parsedIssue: { 92 | organization: 'invalid' 93 | }, 94 | username: 'mona', 95 | role: 'admin', 96 | allowedOrgs: ['valid'] 97 | } as Inputs) 98 | 99 | const promote = new PromoteDemoteAction() 100 | 101 | await expect(promote.validate()).rejects.toThrow() 102 | }) 103 | }) 104 | 105 | describe('execute', () => { 106 | it('Processes the role change', async () => { 107 | getInputsMock.mockReset().mockReturnValueOnce({ 108 | parsedIssue: { 109 | organization: 'valid' 110 | }, 111 | username: 'mona', 112 | role: 'admin', 113 | allowedOrgs: ['valid'] 114 | } as Inputs) 115 | 116 | const promote = new PromoteDemoteAction() 117 | 118 | await promote.execute() 119 | 120 | expect(mocktokit.rest.orgs.setMembershipForUser).toHaveBeenCalled() 121 | }) 122 | 123 | it('Processes an API failure', async () => { 124 | getInputsMock.mockReset().mockReturnValueOnce({ 125 | parsedIssue: { 126 | organization: 'valid' 127 | }, 128 | username: 'mona', 129 | role: 'admin', 130 | allowedOrgs: ['valid'] 131 | } as Inputs) 132 | 133 | mocktokit.rest.orgs.setMembershipForUser.mockRejectedValue({ 134 | status: 500, 135 | message: 'API error' 136 | }) 137 | 138 | const promote = new PromoteDemoteAction() 139 | 140 | const result = await promote.execute() 141 | 142 | expect(result).toMatchObject({ 143 | status: 'error', 144 | output: 'API error' 145 | }) 146 | }) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /admin-support-cli/__tests__/inputs.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import * as core from '../__fixtures__/@actions/core.js' 3 | 4 | // Mocks should be declared before the module being tested is imported. 5 | jest.unstable_mockModule('@actions/core', () => core) 6 | 7 | // The module being tested should be imported dynamically. This ensures that the 8 | // mocks are used in place of any actual dependencies. 9 | const { getInputs } = await import('../src/inputs.js') 10 | 11 | describe('inputs.ts', () => { 12 | beforeEach(() => { 13 | core.getInput 14 | .mockReset() 15 | .mockReturnValueOnce('check_auto_demotion') // action 16 | .mockReturnValueOnce('MY_ADMIN_TOKEN') // admin_token 17 | .mockReturnValueOnce('org1,org2') // allowed_orgs 18 | .mockReturnValueOnce('') // demotion_date 19 | .mockReturnValueOnce('') // issue_number 20 | .mockReturnValueOnce('') // parsed_issue 21 | .mockReturnValueOnce('') // promotion_date 22 | .mockReturnValueOnce('') // report_path 23 | .mockReturnValueOnce('') // role 24 | .mockReturnValueOnce('') // username 25 | }) 26 | 27 | afterEach(() => { 28 | jest.resetAllMocks() 29 | }) 30 | 31 | it('Fails if the action is invalid', () => { 32 | core.getInput 33 | .mockReset() 34 | .mockReturnValueOnce('invalid') // action 35 | .mockReturnValueOnce('MY_ADMIN_TOKEN') // admin_token 36 | .mockReturnValueOnce('org1,org2') // allowed_orgs 37 | .mockReturnValueOnce('') // demotion_date 38 | .mockReturnValueOnce('') // issue_number 39 | .mockReturnValueOnce('') // parsed_issue 40 | .mockReturnValueOnce('') // promotion_date 41 | .mockReturnValueOnce('') // report_path 42 | .mockReturnValueOnce('') // role 43 | .mockReturnValueOnce('') // username 44 | 45 | expect(() => getInputs()).toThrow() 46 | }) 47 | 48 | it('Fails if demotion date is invalid', () => { 49 | core.getInput 50 | .mockReset() 51 | .mockReturnValueOnce('check_auto_demotion') // action 52 | .mockReturnValueOnce('MY_ADMIN_TOKEN') // admin_token 53 | .mockReturnValueOnce('org1,org2') // allowed_orgs 54 | .mockReturnValueOnce('invalid') // demotion_date 55 | .mockReturnValueOnce('') // issue_number 56 | .mockReturnValueOnce('') // parsed_issue 57 | .mockReturnValueOnce('') // promotion_date 58 | .mockReturnValueOnce('') // report_path 59 | .mockReturnValueOnce('') // role 60 | .mockReturnValueOnce('') // username 61 | 62 | expect(() => getInputs()).toThrow() 63 | }) 64 | 65 | it('Fails if issue number is invalid', () => { 66 | core.getInput 67 | .mockReset() 68 | .mockReturnValueOnce('check_auto_demotion') // action 69 | .mockReturnValueOnce('MY_ADMIN_TOKEN') // admin_token 70 | .mockReturnValueOnce('org1,org2') // allowed_orgs 71 | .mockReturnValueOnce('') // demotion_date 72 | .mockReturnValueOnce('invalid') // issue_number 73 | .mockReturnValueOnce('') // parsed_issue 74 | .mockReturnValueOnce('') // promotion_date 75 | .mockReturnValueOnce('') // report_path 76 | .mockReturnValueOnce('') // role 77 | .mockReturnValueOnce('') // username 78 | 79 | expect(() => getInputs()).toThrow() 80 | }) 81 | 82 | it('Fails if promotion date is invalid', () => { 83 | core.getInput 84 | .mockReset() 85 | .mockReturnValueOnce('check_auto_demotion') // action 86 | .mockReturnValueOnce('MY_ADMIN_TOKEN') // admin_token 87 | .mockReturnValueOnce('org1,org2') // allowed_orgs 88 | .mockReturnValueOnce('') // demotion_date 89 | .mockReturnValueOnce('') // issue_number 90 | .mockReturnValueOnce('') // parsed_issue 91 | .mockReturnValueOnce('invalid') // promotion_date 92 | .mockReturnValueOnce('') // report_path 93 | .mockReturnValueOnce('') // role 94 | .mockReturnValueOnce('') // username 95 | 96 | expect(() => getInputs()).toThrow() 97 | }) 98 | 99 | it('Fails if role is invalid', () => { 100 | core.getInput 101 | .mockReset() 102 | .mockReturnValueOnce('check_auto_demotion') // action 103 | .mockReturnValueOnce('MY_ADMIN_TOKEN') // admin_token 104 | .mockReturnValueOnce('org1,org2') // allowed_orgs 105 | .mockReturnValueOnce('') // demotion_date 106 | .mockReturnValueOnce('') // issue_number 107 | .mockReturnValueOnce('') // parsed_issue 108 | .mockReturnValueOnce('') // promotion_date 109 | .mockReturnValueOnce('') // report_path 110 | .mockReturnValueOnce('invalid') // role 111 | .mockReturnValueOnce('') // username 112 | 113 | expect(() => getInputs()).toThrow() 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /admin-support-cli/__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import * as core from '../__fixtures__/@actions/core.js' 3 | import * as github from '../__fixtures__/@actions/github.js' 4 | import * as octokit from '../__fixtures__/@octokit/rest.js' 5 | import * as check from '../__fixtures__/commands/actions/check-auto-demotion-action.js' 6 | import * as report from '../__fixtures__/commands/actions/demotion-report-action.js' 7 | import * as promote from '../__fixtures__/commands/actions/promote-demote-action.js' 8 | import { getInputsMock } from '../__fixtures__/inputs.js' 9 | import { Action } from '../src/enums.js' 10 | import type { Inputs } from '../src/types.js' 11 | 12 | // Mocks should be declared before the module being tested is imported. 13 | jest.unstable_mockModule('@actions/core', () => core) 14 | jest.unstable_mockModule('@actions/github', () => github) 15 | jest.unstable_mockModule('@octokit/rest', async () => { 16 | class Octokit { 17 | constructor() { 18 | return octokit 19 | } 20 | } 21 | 22 | return { 23 | Octokit 24 | } 25 | }) 26 | 27 | jest.unstable_mockModule('../src/inputs.js', () => ({ 28 | getInputs: getInputsMock 29 | })) 30 | jest.unstable_mockModule( 31 | '../src/commands/actions/check-auto-demotion-action.js', 32 | () => ({ 33 | CheckAutoDemotionAction: check.CheckAutoDemotionAction 34 | }) 35 | ) 36 | jest.unstable_mockModule( 37 | '../src/commands/actions/demotion-report-action.js', 38 | () => ({ 39 | DemotionReportAction: report.DemotionReportAction 40 | }) 41 | ) 42 | jest.unstable_mockModule( 43 | '../src/commands/actions/promote-demote-action.js', 44 | () => ({ 45 | PromoteDemoteAction: promote.PromoteDemoteAction 46 | }) 47 | ) 48 | 49 | // The module being tested should be imported dynamically. This ensures that the 50 | // mocks are used in place of any actual dependencies. 51 | const { run } = await import('../src/main.js') 52 | 53 | const { Octokit } = await import('@octokit/rest') 54 | const mocktokit = jest.mocked(new Octokit()) 55 | 56 | describe('main.ts', () => { 57 | beforeEach(() => { 58 | check.execute.mockResolvedValue({ 59 | status: 'success', 60 | output: 'test' 61 | } as never) 62 | 63 | report.execute.mockResolvedValue({ 64 | status: 'success', 65 | output: 'test' 66 | } as never) 67 | 68 | promote.execute.mockResolvedValue({ 69 | status: 'success', 70 | output: 'test' 71 | } as never) 72 | }) 73 | 74 | afterEach(() => { 75 | jest.resetAllMocks() 76 | }) 77 | 78 | it('Fails if an invalid command is provided', async () => { 79 | getInputsMock.mockReset().mockReturnValueOnce({ 80 | action: 'invalid' 81 | } as Inputs) 82 | 83 | await run() 84 | 85 | expect(mocktokit.rest.issues.createComment).toHaveBeenCalledTimes(1) 86 | expect(core.setFailed).toHaveBeenCalledTimes(1) 87 | }) 88 | 89 | it('Sets the organization output', async () => { 90 | getInputsMock.mockReset().mockReturnValueOnce({ 91 | action: Action.CHECK_AUTO_DEMOTION, 92 | parsedIssue: { 93 | organization: 'test' 94 | } 95 | } as Inputs) 96 | 97 | await run() 98 | 99 | expect(check.constructor).toHaveBeenCalledTimes(1) 100 | expect(check.validate).toHaveBeenCalledTimes(1) 101 | expect(check.execute).toHaveBeenCalledTimes(1) 102 | expect(core.setOutput).toHaveBeenCalledWith('organization', 'test') 103 | }) 104 | 105 | describe('check_auto_demotion', () => { 106 | it('Runs CheckAutoDemotionAction', async () => { 107 | getInputsMock.mockReset().mockReturnValueOnce({ 108 | action: Action.CHECK_AUTO_DEMOTION 109 | } as Inputs) 110 | 111 | await run() 112 | 113 | expect(check.constructor).toHaveBeenCalledTimes(1) 114 | expect(check.validate).toHaveBeenCalledTimes(1) 115 | expect(check.execute).toHaveBeenCalledTimes(1) 116 | }) 117 | }) 118 | 119 | describe('demotion_report', () => { 120 | it('Runs DemotionReportAction', async () => { 121 | getInputsMock.mockReset().mockReturnValueOnce({ 122 | action: Action.DEMOTION_REPORT 123 | } as Inputs) 124 | 125 | await run() 126 | 127 | expect(report.constructor).toHaveBeenCalledTimes(1) 128 | expect(report.validate).toHaveBeenCalledTimes(1) 129 | expect(report.execute).toHaveBeenCalledTimes(1) 130 | }) 131 | }) 132 | 133 | describe('promote_demote', () => { 134 | it('Runs PromoteDemoteAction', async () => { 135 | getInputsMock.mockReset().mockReturnValueOnce({ 136 | action: Action.PROMOTE_DEMOTE 137 | } as Inputs) 138 | 139 | await run() 140 | 141 | expect(promote.constructor).toHaveBeenCalledTimes(1) 142 | expect(promote.validate).toHaveBeenCalledTimes(1) 143 | expect(promote.execute).toHaveBeenCalledTimes(1) 144 | }) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /admin-support-cli/action.yml: -------------------------------------------------------------------------------- 1 | name: admin-support-cli 2 | author: GitHub Expert Services 3 | description: CLI to promote users as admins in a GitHub organization 4 | inputs: 5 | action: 6 | description: 7 | Command to invoke as part of the CLI (`check_auto_demotion`, 8 | `demotion_report`, `promote_demote`) 9 | required: true 10 | admin_token: 11 | description: Token to be used for the promotion workflow 12 | required: true 13 | allowed_orgs: 14 | description: 15 | Comma-separated list of allowed organizations (e.g. `org1,org2,org3`). 16 | required: true 17 | demotion_date: 18 | description: Date when the demotion happened 19 | required: false 20 | issue_number: 21 | description: Promotion request issue number 22 | required: false 23 | default: ${{ github.event.issue.number }} 24 | parsed_issue: 25 | description: Parsed issue JSON 26 | required: false 27 | promotion_date: 28 | description: Date when the promotion happened 29 | required: false 30 | default: ${{ github.event.issue.created_at }} 31 | report_path: 32 | description: Path where the report will be saved 33 | required: false 34 | default: ${{ github.workspace }}/reports 35 | role: 36 | description: Role to grant (`admin` or `member`) 37 | required: false 38 | username: 39 | description: User that will be promoted 40 | required: false 41 | default: ${{ github.actor }} 42 | 43 | outputs: 44 | output: 45 | description: Command output 46 | runs: 47 | using: node20 48 | main: dist/index.js 49 | -------------------------------------------------------------------------------- /admin-support-cli/dist/commands/actions/check-auto-demotion-action.d.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '@actions/github/lib/utils.js'; 2 | import type { Inputs, Result } from '../../types.js'; 3 | import { Command } from '../command.js'; 4 | export declare class CheckAutoDemotionAction implements Command { 5 | api: InstanceType; 6 | params: Inputs; 7 | constructor(); 8 | validate(): Promise; 9 | execute(): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /admin-support-cli/dist/commands/actions/demotion-report-action.d.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '@actions/github/lib/utils.js'; 2 | import type { Inputs, Result } from '../../types.js'; 3 | import { Command } from '../command.js'; 4 | export declare class DemotionReportAction implements Command { 5 | api: InstanceType; 6 | params: Inputs; 7 | constructor(); 8 | validate(): Promise; 9 | execute(): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /admin-support-cli/dist/commands/actions/promote-demote-action.d.ts: -------------------------------------------------------------------------------- 1 | import type { GitHub } from '@actions/github/lib/utils.js'; 2 | import type { Inputs, Result } from '../../types.js'; 3 | import { Command } from '../command.js'; 4 | export declare class PromoteDemoteAction implements Command { 5 | api: InstanceType; 6 | params: Inputs; 7 | constructor(); 8 | validate(): Promise; 9 | execute(): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /admin-support-cli/dist/commands/command.d.ts: -------------------------------------------------------------------------------- 1 | import type { Inputs, Result } from '../types.js'; 2 | export interface Command { 3 | api: any; 4 | params: Inputs; 5 | validate(): Promise; 6 | execute(): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /admin-support-cli/dist/enums.d.ts: -------------------------------------------------------------------------------- 1 | export declare enum Action { 2 | /** Check Auto Demotion */ 3 | CHECK_AUTO_DEMOTION = "check_auto_demotion", 4 | /** Demotion Report */ 5 | DEMOTION_REPORT = "demotion_report", 6 | /** Promote or Demote */ 7 | PROMOTE_DEMOTE = "promote_demote" 8 | } 9 | /** Role */ 10 | export declare enum Role { 11 | /** Admin */ 12 | ADMIN = "admin", 13 | /** Member */ 14 | MEMBER = "member" 15 | } 16 | -------------------------------------------------------------------------------- /admin-support-cli/dist/exceptions/CommandDoesNotExistError.d.ts: -------------------------------------------------------------------------------- 1 | export declare class CommandDoesNotExistError extends Error { 2 | constructor(message: string); 3 | } 4 | -------------------------------------------------------------------------------- /admin-support-cli/dist/exceptions/ParameterRequiredError.d.ts: -------------------------------------------------------------------------------- 1 | export declare class ParameterRequiredError extends Error { 2 | constructor(message: string); 3 | } 4 | -------------------------------------------------------------------------------- /admin-support-cli/dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /admin-support-cli/dist/inputs.d.ts: -------------------------------------------------------------------------------- 1 | import type { Inputs } from './types.js'; 2 | export declare function getInputs(): Inputs; 3 | -------------------------------------------------------------------------------- /admin-support-cli/dist/main.d.ts: -------------------------------------------------------------------------------- 1 | export declare function run(): Promise; 2 | -------------------------------------------------------------------------------- /admin-support-cli/dist/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Role } from './enums.js'; 2 | /** Action Inputs */ 3 | export type Inputs = { 4 | /** Required Inputs */ 5 | /** Action to Perform */ 6 | action: string; 7 | /** Allowed Organizations */ 8 | allowedOrgs: string[]; 9 | /** Admin Token */ 10 | adminToken: string; 11 | /** Optional Inputs */ 12 | /** Demotion Date */ 13 | demotionDate: Date | undefined; 14 | /** Issue Number */ 15 | issueNumber: number | undefined; 16 | /** Parsed Issue */ 17 | parsedIssue: { 18 | /** Description */ 19 | description: string | undefined; 20 | /** Duration */ 21 | duration: number; 22 | /** Ticket */ 23 | ticket: string | undefined; 24 | /** Target Organization */ 25 | organization: string; 26 | } | undefined; 27 | /** Promotion Date */ 28 | promotionDate: Date | undefined; 29 | /** Report Path */ 30 | reportPath: string | undefined; 31 | /** Role */ 32 | role: Role | undefined; 33 | /** Username */ 34 | username: string | undefined; 35 | }; 36 | /** Audit Log API Response Item */ 37 | export type AuditLogEntry = { 38 | _document_id: string; 39 | action: string; 40 | actor: string; 41 | event: string; 42 | name: string; 43 | org: string; 44 | repo: string; 45 | user: string; 46 | }; 47 | /** Command Result */ 48 | export type Result = { 49 | /** Result Status */ 50 | status: 'success' | 'error' | undefined; 51 | /** Output */ 52 | output: string; 53 | }; 54 | -------------------------------------------------------------------------------- /admin-support-cli/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // See: https://eslint.org/docs/latest/use/configure/configuration-files 2 | 3 | import { fixupPluginRules } from '@eslint/compat' 4 | import { FlatCompat } from '@eslint/eslintrc' 5 | import js from '@eslint/js' 6 | import typescriptEslint from '@typescript-eslint/eslint-plugin' 7 | import tsParser from '@typescript-eslint/parser' 8 | import _import from 'eslint-plugin-import' 9 | import jest from 'eslint-plugin-jest' 10 | import prettier from 'eslint-plugin-prettier' 11 | import globals from 'globals' 12 | import path from 'node:path' 13 | import { fileURLToPath } from 'node:url' 14 | 15 | const __filename = fileURLToPath(import.meta.url) 16 | const __dirname = path.dirname(__filename) 17 | const compat = new FlatCompat({ 18 | baseDirectory: __dirname, 19 | recommendedConfig: js.configs.recommended, 20 | allConfig: js.configs.all 21 | }) 22 | 23 | export default [ 24 | { 25 | ignores: ['**/coverage', '**/dist', '**/linter', '**/node_modules'] 26 | }, 27 | ...compat.extends( 28 | 'eslint:recommended', 29 | 'plugin:@typescript-eslint/eslint-recommended', 30 | 'plugin:@typescript-eslint/recommended', 31 | 'plugin:jest/recommended', 32 | 'plugin:prettier/recommended' 33 | ), 34 | { 35 | plugins: { 36 | import: fixupPluginRules(_import), 37 | jest, 38 | prettier, 39 | '@typescript-eslint': typescriptEslint 40 | }, 41 | 42 | languageOptions: { 43 | globals: { 44 | ...globals.node, 45 | ...globals.jest, 46 | Atomics: 'readonly', 47 | SharedArrayBuffer: 'readonly' 48 | }, 49 | 50 | parser: tsParser, 51 | ecmaVersion: 2023, 52 | sourceType: 'module', 53 | 54 | parserOptions: { 55 | project: ['tsconfig.eslint.json'], 56 | tsconfigRootDir: '.' 57 | } 58 | }, 59 | 60 | settings: { 61 | 'import/resolver': { 62 | typescript: { 63 | alwaysTryTypes: true, 64 | project: 'tsconfig.eslint.json' 65 | } 66 | } 67 | }, 68 | 69 | rules: { 70 | '@typescript-eslint/no-explicit-any': 'off', 71 | camelcase: 'off', 72 | 'eslint-comments/no-use': 'off', 73 | 'eslint-comments/no-unused-disable': 'off', 74 | 'i18n-text/no-en': 'off', 75 | 'import/no-namespace': 'off', 76 | 'no-console': 'off', 77 | 'no-shadow': 'off', 78 | 'no-unused-vars': 'off', 79 | 'prettier/prettier': 'error' 80 | } 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /admin-support-cli/issue_payload.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "assignee": { 5 | "login": "ncalteen" 6 | }, 7 | "body": "### Organization\n\nocto-org\n\nDescription\n\nI need admin access to the octo-org organization\n\nTicket\n\n1234\n\nDuration\n\n1", 8 | "closed_at": null, 9 | "created_at": "2025-01-24T12:00:00Z", 10 | "number": 1, 11 | "state": "open", 12 | "state_reason": null, 13 | "title": "Administrative Access Request", 14 | "updated_at": "2025-01-24T12:00:00Z", 15 | "user": { 16 | "login": "ncalteen" 17 | } 18 | }, 19 | "organization": { 20 | "login": "ActionsDesk" 21 | }, 22 | "repository": { 23 | "full_name": "ActionsDesk/admin-support-issueops-actions", 24 | "name": "admin-support-issueops-actions", 25 | "owner": { 26 | "login": "ActionsDesk" 27 | }, 28 | "url": "https://api.github.com/repos/ActionsDesk/admin-support-issueops-actions" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /admin-support-cli/jest.config.js: -------------------------------------------------------------------------------- 1 | // See: https://jestjs.io/docs/configuration 2 | 3 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 4 | export default { 5 | clearMocks: true, 6 | collectCoverage: true, 7 | collectCoverageFrom: ['./src/**'], 8 | coverageDirectory: './coverage', 9 | coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], 10 | coverageReporters: ['json-summary', 'text', 'lcov'], 11 | coverageThreshold: { 12 | global: { 13 | branches: 100, 14 | functions: 100, 15 | lines: 100, 16 | statements: 100 17 | } 18 | }, 19 | extensionsToTreatAsEsm: ['.ts'], 20 | moduleFileExtensions: ['ts', 'js'], 21 | preset: 'ts-jest', 22 | reporters: ['default'], 23 | resolver: 'ts-jest-resolver', 24 | testEnvironment: 'node', 25 | testMatch: ['**/*.test.ts'], 26 | testPathIgnorePatterns: ['/dist/', '/node_modules/'], 27 | transform: { 28 | '^.+\\.ts$': [ 29 | 'ts-jest', 30 | { 31 | tsconfig: 'tsconfig.eslint.json', 32 | useESM: true 33 | } 34 | ] 35 | }, 36 | verbose: true 37 | } 38 | -------------------------------------------------------------------------------- /admin-support-cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin-support-issueops-actions", 3 | "description": "Help support teams to handle requests providing temporary admin access", 4 | "version": "2.0.0", 5 | "author": "GitHub Expert Services", 6 | "type": "module", 7 | "private": true, 8 | "homepage": "https://github.com/ActionsDesk/admin-support-issueops-actions", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/ActionsDesk/admin-support-issueops-actions" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/ActionsDesk/admin-support-issueops-actions" 15 | }, 16 | "keywords": [ 17 | "GitHub", 18 | "Actions", 19 | "TypeScript" 20 | ], 21 | "exports": { 22 | ".": "./admin-support-cli/dist/index.js" 23 | }, 24 | "engines": { 25 | "node": ">=20" 26 | }, 27 | "scripts": { 28 | "bundle": "npm run format:write && npm run package", 29 | "ci-test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest", 30 | "coverage": "npx make-coverage-badge --output-path ../badges/coverage.svg", 31 | "format:check": "npx prettier --check . --config ../.prettierrc.yml --ignore-path ../.prettierignore", 32 | "format:write": "npx prettier --write . --config ../.prettierrc.yml --ignore-path ../.prettierignore", 33 | "lint": "npx eslint .", 34 | "local-action": "npx local-action . src/main.ts .env", 35 | "package": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript", 36 | "package:watch": "npm run package -- --watch", 37 | "test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest", 38 | "all": "npm run format:write && npm run lint && npm run test && npm run coverage && npm run package" 39 | }, 40 | "license": "MIT", 41 | "dependencies": { 42 | "@actions/core": "^1.11.1", 43 | "@actions/github": "^6.0.0", 44 | "@github/issue-parser": "^1.0.1", 45 | "@octokit/rest": "^21.1.0", 46 | "commander": "^13.1.0", 47 | "ts-dedent": "^2.2.0" 48 | }, 49 | "devDependencies": { 50 | "@eslint/compat": "^1.2.5", 51 | "@github/local-action": "^2.5.1", 52 | "@jest/globals": "^29.7.0", 53 | "@octokit/types": "^13.7.0", 54 | "@rollup/plugin-commonjs": "^28.0.1", 55 | "@rollup/plugin-node-resolve": "^16.0.0", 56 | "@rollup/plugin-typescript": "^12.1.1", 57 | "@types/jest": "^29.5.14", 58 | "@types/node": "^20.17.14", 59 | "@typescript-eslint/eslint-plugin": "^8.21.0", 60 | "@typescript-eslint/parser": "^8.21.0", 61 | "eslint": "^9.18.0", 62 | "eslint-config-prettier": "^10.0.1", 63 | "eslint-import-resolver-typescript": "^3.6.3", 64 | "eslint-plugin-import": "^2.31.0", 65 | "eslint-plugin-jest": "^28.11.0", 66 | "eslint-plugin-prettier": "^5.2.3", 67 | "jest": "^29.7.0", 68 | "make-coverage-badge": "^1.2.0", 69 | "prettier": "^3.4.2", 70 | "prettier-eslint": "^16.3.0", 71 | "rollup": "^4.31.0", 72 | "ts-jest": "^29.2.5", 73 | "ts-jest-resolver": "^2.0.1", 74 | "ts-node": "^10.9.2", 75 | "typescript": "^5.7.3" 76 | }, 77 | "optionalDependencies": { 78 | "@rollup/rollup-linux-x64-gnu": "*" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /admin-support-cli/rollup.config.ts: -------------------------------------------------------------------------------- 1 | // See: https://rollupjs.org/introduction/ 2 | 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import nodeResolve from '@rollup/plugin-node-resolve' 5 | import typescript from '@rollup/plugin-typescript' 6 | 7 | const config = { 8 | input: 'src/index.ts', 9 | output: { 10 | esModule: true, 11 | file: 'dist/index.js', 12 | format: 'es', 13 | sourcemap: true 14 | }, 15 | plugins: [typescript(), nodeResolve(), commonjs()] 16 | } 17 | 18 | export default config 19 | -------------------------------------------------------------------------------- /admin-support-cli/src/commands/actions/check-auto-demotion-action.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import type { GitHub } from '@actions/github/lib/utils.js' 4 | import { parseIssue } from '@github/issue-parser' 5 | import { getInputs } from '../../inputs.js' 6 | import type { Inputs, Result } from '../../types.js' 7 | import { Command } from '../command.js' 8 | 9 | const HOUR_IN_MILLIS = 60 * 60 * 1000 10 | 11 | export class CheckAutoDemotionAction implements Command { 12 | api: InstanceType 13 | params: Inputs 14 | 15 | constructor() { 16 | this.params = getInputs() 17 | this.api = github.getOctokit(this.params.adminToken) 18 | } 19 | 20 | async validate() { 21 | core.info('No validation needed.') 22 | } 23 | 24 | async execute(): Promise { 25 | const listOfIssues = await this.api.paginate( 26 | this.api.rest.issues.listForRepo, 27 | { 28 | owner: github.context.repo.owner, 29 | repo: github.context.repo.repo, 30 | state: 'open', 31 | labels: 'user-promoted' 32 | } 33 | ) 34 | 35 | for (const issue of listOfIssues) { 36 | const parsedIssue = parseIssue(issue.body!) 37 | 38 | const issueDuration = parseInt(parsedIssue.duration as string) 39 | const promotedTime = Date.parse(issue.created_at) 40 | const passedTime = Date.now() - promotedTime 41 | 42 | if (passedTime > issueDuration * HOUR_IN_MILLIS) { 43 | core.info(`Issue ${issue.number} has passed the duration.`) 44 | 45 | await this.api.rest.issues.update({ 46 | owner: github.context.repo.owner, 47 | repo: github.context.repo.repo, 48 | issue_number: issue.number, 49 | state: 'closed' 50 | }) 51 | } 52 | } 53 | 54 | return { 55 | status: 'success', 56 | output: 'All issues have been checked.' 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /admin-support-cli/src/commands/actions/demotion-report-action.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import type { GitHub } from '@actions/github/lib/utils.js' 4 | import fs from 'fs' 5 | import path from 'path' 6 | import { ParameterRequiredError } from '../../exceptions/ParameterRequiredError.js' 7 | import { getInputs } from '../../inputs.js' 8 | import type { AuditLogEntry, Inputs, Result } from '../../types.js' 9 | import { Command } from '../command.js' 10 | 11 | export class DemotionReportAction implements Command { 12 | api: InstanceType 13 | params: Inputs 14 | 15 | constructor() { 16 | this.params = getInputs() 17 | this.api = github.getOctokit(this.params.adminToken) 18 | } 19 | 20 | async validate() { 21 | if (!this.params.demotionDate) 22 | throw new ParameterRequiredError('A valid demotion date is required.') 23 | if (!this.params.issueNumber) 24 | throw new ParameterRequiredError('A valid issueNumber is required.') 25 | if (!this.params.parsedIssue) 26 | throw new ParameterRequiredError('A valid issue is required.') 27 | if (!this.params.promotionDate) 28 | throw new ParameterRequiredError('A valid promotion date is required.') 29 | if (!this.params.reportPath) 30 | throw new ParameterRequiredError('A valid report path is required.') 31 | if (!this.params.username) 32 | throw new ParameterRequiredError('A valid username is required.') 33 | 34 | if (!this.params.allowedOrgs.includes(this.params.parsedIssue.organization)) 35 | throw new Error( 36 | `Organization ${this.params.parsedIssue.organization} is not allowed` 37 | ) 38 | } 39 | 40 | async execute(): Promise { 41 | try { 42 | const report = { 43 | user: this.params.username, 44 | targetOrg: this.params.parsedIssue!.organization, 45 | description: this.params.parsedIssue!.description, 46 | issueNumber: this.params.issueNumber, 47 | duration: this.params.parsedIssue!.duration, 48 | ticket: this.params.parsedIssue!.ticket || '', 49 | demotionDate: this.params.demotionDate, 50 | promotionDate: this.params.promotionDate, 51 | auditLogTrail: [] as AuditLogEntry[] 52 | } 53 | 54 | // Filter only the events that happened from the moment the issue was 55 | // opened to the present 56 | const result = (await this.api.paginate( 57 | 'GET /orgs/{org}/audit-log{?include,phrase}', 58 | { 59 | org: this.params.parsedIssue!.organization, 60 | include: 'all', 61 | phrase: `created:>=${this.params.demotionDate!.toISOString().split('T')[0]} created:<=${this.params.demotionDate!.toISOString().split('T')[0]}` 62 | } 63 | )) as AuditLogEntry[] 64 | 65 | report.auditLogTrail = report.auditLogTrail.concat( 66 | result.filter( 67 | /* istanbul ignore next */ 68 | (item) => item.user === report.user || item.actor === report.user 69 | ) 70 | ) 71 | 72 | // Create the directory if it does not exist 73 | if ( 74 | !fs.existsSync( 75 | path.resolve(process.env.GITHUB_WORKSPACE!, this.params.reportPath!) 76 | ) 77 | ) 78 | fs.mkdirSync( 79 | path.resolve(process.env.GITHUB_WORKSPACE!, this.params.reportPath!), 80 | { recursive: true } 81 | ) 82 | 83 | const fileLocation = path.resolve( 84 | process.env.GITHUB_WORKSPACE!, 85 | this.params.reportPath!, 86 | `${this.params.issueNumber}_${this.params.username}.json` 87 | ) 88 | fs.writeFileSync(fileLocation, JSON.stringify(report, null, 2)) 89 | 90 | return { 91 | status: 'success', 92 | output: fileLocation 93 | } 94 | } catch (error: any) { 95 | core.error('Request Failed:', error.request) 96 | core.error(error.message) 97 | core.error(error.data) 98 | 99 | core.setFailed(error.message) 100 | return { 101 | status: 'error', 102 | output: error.message 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /admin-support-cli/src/commands/actions/promote-demote-action.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import type { GitHub } from '@actions/github/lib/utils.js' 4 | import { dedent } from 'ts-dedent' 5 | import { ParameterRequiredError } from '../../exceptions/ParameterRequiredError.js' 6 | import { getInputs } from '../../inputs.js' 7 | import type { Inputs, Result } from '../../types.js' 8 | import { Command } from '../command.js' 9 | 10 | export class PromoteDemoteAction implements Command { 11 | api: InstanceType 12 | params: Inputs 13 | 14 | constructor() { 15 | this.params = getInputs() 16 | this.api = github.getOctokit(this.params.adminToken) 17 | } 18 | 19 | async validate(): Promise { 20 | if (!this.params.parsedIssue) 21 | throw new ParameterRequiredError('A valid issue is required.') 22 | if (!this.params.username) 23 | throw new ParameterRequiredError('A valid username is required.') 24 | if (!this.params.role) 25 | throw new ParameterRequiredError('A valid role is required.') 26 | 27 | if (!this.params.allowedOrgs.includes(this.params.parsedIssue.organization)) 28 | throw new Error( 29 | `Organization ${this.params.parsedIssue.organization} is not allowed` 30 | ) 31 | } 32 | 33 | async execute(): Promise { 34 | try { 35 | await this.api.rest.orgs.setMembershipForUser({ 36 | org: this.params.parsedIssue!.organization, 37 | username: this.params.username!, 38 | role: this.params.role! 39 | }) 40 | 41 | return { 42 | status: 'success', 43 | output: `The role of ${this.params.username} has been successfully changed to: ${this.params.role} in ${this.params.parsedIssue!.organization}` 44 | } 45 | } catch (error: any) { 46 | core.error( 47 | `Failed to change the role of ${this.params.username} in ${this.params.parsedIssue!.organization}` 48 | ) 49 | core.error(`Status Code: ${error.status}`) 50 | core.error(dedent`Possible reasons: 51 | 52 | - Username is not a member of the organization 53 | - Personal access token provided does not have sufficient privileges 54 | - Organization does not exist 55 | - You do not have admin privileges for the organization provided`) 56 | 57 | return { 58 | status: 'error', 59 | output: error.message 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /admin-support-cli/src/commands/command.ts: -------------------------------------------------------------------------------- 1 | import type { Inputs, Result } from '../types.js' 2 | 3 | export interface Command { 4 | api: any 5 | params: Inputs 6 | 7 | validate(): Promise 8 | execute(): Promise 9 | } 10 | -------------------------------------------------------------------------------- /admin-support-cli/src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum Action { 2 | /** Check Auto Demotion */ 3 | CHECK_AUTO_DEMOTION = 'check_auto_demotion', 4 | /** Demotion Report */ 5 | DEMOTION_REPORT = 'demotion_report', 6 | /** Promote or Demote */ 7 | PROMOTE_DEMOTE = 'promote_demote' 8 | } 9 | 10 | /** Role */ 11 | export enum Role { 12 | /** Admin */ 13 | ADMIN = 'admin', 14 | /** Member */ 15 | MEMBER = 'member' 16 | } 17 | -------------------------------------------------------------------------------- /admin-support-cli/src/exceptions/CommandDoesNotExistError.ts: -------------------------------------------------------------------------------- 1 | export class CommandDoesNotExistError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | this.name = 'CommandDoesNotExistError' 5 | this.message = message 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /admin-support-cli/src/exceptions/ParameterRequiredError.ts: -------------------------------------------------------------------------------- 1 | export class ParameterRequiredError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | this.name = 'ParameterRequiredError' 5 | this.message = message 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /admin-support-cli/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The entrypoint for the action. This file simply imports and runs the action's 3 | * main logic. 4 | */ 5 | import { run } from './main.js' 6 | 7 | /* istanbul ignore next */ 8 | run() 9 | -------------------------------------------------------------------------------- /admin-support-cli/src/inputs.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { Action, Role } from './enums.js' 3 | import type { Inputs } from './types.js' 4 | 5 | export function getInputs(): Inputs { 6 | /** Required Inputs */ 7 | const action = core.getInput('action', { required: true }) 8 | const adminToken = core.getInput('admin_token', { required: true }) 9 | const allowedOrgs = core 10 | .getInput('allowed_orgs', { required: true }) 11 | .split(/,\s?/) 12 | 13 | /** Optional Inputs */ 14 | const demotionDate = core.getInput('demotion_date') 15 | const issueNumber = core.getInput('issue_number') 16 | const parsedIssue = core.getInput('parsed_issue') 17 | const promotionDate = core.getInput('promotion_date') 18 | const reportPath = core.getInput('report_path') 19 | const role = core.getInput('role').toLowerCase() 20 | const username = core.getInput('username') 21 | 22 | // Validate the inputs 23 | 24 | // Action must be a valid action 25 | if (!Object.values(Action).includes(action as Action)) 26 | throw new Error('Action must be a valid action') 27 | 28 | // Demotion date must be a valid date 29 | if (demotionDate !== '' && isNaN(Date.parse(demotionDate))) 30 | throw new Error('Demotion date must be a valid date') 31 | 32 | // Issue number must be a number 33 | if (issueNumber !== '' && isNaN(parseInt(issueNumber))) 34 | throw new Error('Issue number must be a number') 35 | 36 | // Promotion date must be a valid date 37 | if (promotionDate !== '' && isNaN(Date.parse(promotionDate))) 38 | throw new Error('Promotion date must be a valid date') 39 | 40 | // Role must be "admin" or "member" 41 | if (role !== '' && !Object.values(Role).includes(role as Role)) 42 | throw new Error('Role must be "admin" or "member"') 43 | 44 | /* istanbul ignore next */ 45 | return { 46 | /** Required Inputs */ 47 | action, 48 | allowedOrgs, 49 | adminToken, 50 | 51 | /** Optional Inputs */ 52 | demotionDate: demotionDate !== '' ? new Date(demotionDate) : undefined, 53 | issueNumber: issueNumber !== '' ? parseInt(issueNumber) : undefined, 54 | parsedIssue: parsedIssue !== '' ? JSON.parse(parsedIssue) : undefined, 55 | promotionDate: promotionDate !== '' ? new Date(promotionDate) : undefined, 56 | reportPath: reportPath !== '' ? reportPath : undefined, 57 | role: role !== '' ? (role as Role) : undefined, 58 | username: username !== '' ? username : undefined 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /admin-support-cli/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import { dedent } from 'ts-dedent' 4 | import { CheckAutoDemotionAction } from './commands/actions/check-auto-demotion-action.js' 5 | import { DemotionReportAction } from './commands/actions/demotion-report-action.js' 6 | import { PromoteDemoteAction } from './commands/actions/promote-demote-action.js' 7 | import type { Command } from './commands/command.js' 8 | import { Action } from './enums.js' 9 | import { CommandDoesNotExistError } from './exceptions/CommandDoesNotExistError.js' 10 | import { getInputs } from './inputs.js' 11 | 12 | export async function run() { 13 | /* istanbul ignore next */ 14 | if (!github.context.payload.issue) return core.setFailed('No issue found!') 15 | 16 | const inputs = getInputs() 17 | 18 | try { 19 | core.info('Running Action with input:') 20 | core.info(JSON.stringify(inputs, null, 2)) 21 | 22 | let commandInstance: Command | undefined 23 | switch (inputs.action) { 24 | case Action.CHECK_AUTO_DEMOTION: 25 | commandInstance = new CheckAutoDemotionAction() 26 | break 27 | case Action.DEMOTION_REPORT: 28 | commandInstance = new DemotionReportAction() 29 | break 30 | case Action.PROMOTE_DEMOTE: 31 | commandInstance = new PromoteDemoteAction() 32 | break 33 | default: 34 | throw new CommandDoesNotExistError(inputs.action) 35 | } 36 | 37 | await commandInstance.validate() 38 | const result = await commandInstance.execute() 39 | 40 | core.setOutput('output', result.output) 41 | 42 | if (inputs.parsedIssue !== undefined) 43 | core.setOutput('organization', inputs.parsedIssue.organization) 44 | } catch (error: any) { 45 | core.error(error) 46 | 47 | // Report the error in a comment 48 | const octokit = github.getOctokit(inputs.adminToken) 49 | await octokit.rest.issues.createComment({ 50 | owner: github.context.repo.owner, 51 | repo: github.context.repo.repo, 52 | issue_number: github.context.payload.issue.number, 53 | body: dedent`### :exclamation: An Error Occurred :exclamation: 54 | 55 | ${error.message} 56 | 57 | 58 | Details: here. 59 | 60 | ` 61 | }) 62 | 63 | core.debug(`Exit Code: ${process.exitCode}`) 64 | return core.setFailed('An error occurred running the command!') 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /admin-support-cli/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Role } from './enums.js' 2 | 3 | /** Action Inputs */ 4 | export type Inputs = { 5 | /** Required Inputs */ 6 | 7 | /** Action to Perform */ 8 | action: string 9 | /** Allowed Organizations */ 10 | allowedOrgs: string[] 11 | /** Admin Token */ 12 | adminToken: string 13 | 14 | /** Optional Inputs */ 15 | 16 | /** Demotion Date */ 17 | demotionDate: Date | undefined 18 | /** Issue Number */ 19 | issueNumber: number | undefined 20 | /** Parsed Issue */ 21 | parsedIssue: 22 | | { 23 | /** Description */ 24 | description: string | undefined 25 | /** Duration */ 26 | duration: number 27 | /** Ticket */ 28 | ticket: string | undefined 29 | /** Target Organization */ 30 | organization: string 31 | } 32 | | undefined 33 | /** Promotion Date */ 34 | promotionDate: Date | undefined 35 | /** Report Path */ 36 | reportPath: string | undefined 37 | /** Role */ 38 | role: Role | undefined 39 | /** Username */ 40 | username: string | undefined 41 | } 42 | 43 | /** Audit Log API Response Item */ 44 | export type AuditLogEntry = { 45 | _document_id: string 46 | action: string 47 | actor: string 48 | event: string 49 | name: string 50 | org: string 51 | repo: string 52 | user: string 53 | } 54 | 55 | /** Command Result */ 56 | export type Result = { 57 | /** Result Status */ 58 | status: 'success' | 'error' | undefined 59 | /** Output */ 60 | output: string 61 | } 62 | -------------------------------------------------------------------------------- /admin-support-cli/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "declarationMap": false, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "lib": ["ES2022"], 10 | "module": "NodeNext", 11 | "moduleResolution": "NodeNext", 12 | "newLine": "lf", 13 | "noImplicitAny": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": false, 16 | "pretty": true, 17 | "resolveJsonModule": true, 18 | "sourceMap": true, 19 | "strict": true, 20 | "strictNullChecks": true, 21 | "target": "ES2022" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /admin-support-cli/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "noEmit": true 7 | }, 8 | "exclude": ["dist", "node_modules"], 9 | "include": [ 10 | "__fixtures__", 11 | "__tests__", 12 | "src", 13 | "eslint.config.mjs", 14 | "jest.config.js", 15 | "rollup.config.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /admin-support-cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "outDir": "./dist" 8 | }, 9 | "exclude": ["__fixtures__", "__tests__", "coverage", "dist", "node_modules"], 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /badges/coverage.svg: -------------------------------------------------------------------------------- 1 | Coverage: 100%Coverage100% --------------------------------------------------------------------------------