├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── codeowners-validator.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── .yarn └── releases │ └── yarn-3.1.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── __mocks__ └── @actions │ └── github.ts ├── api ├── api.js ├── api.ts ├── octokit.js ├── octokit.ts ├── testbed.js └── testbed.ts ├── backport ├── action.yml ├── backport.js ├── backport.test.ts ├── backport.ts ├── index.js └── index.ts ├── bump-version ├── action.yml ├── index.js ├── index.ts ├── versions.js ├── versions.test.ts └── versions.ts ├── catalog-info.yaml ├── close-milestone ├── action.yml ├── index.js └── index.ts ├── commands ├── Commands.js ├── Commands.test.ts ├── Commands.ts ├── action.yml ├── index.js └── index.ts ├── common ├── Action.js ├── Action.ts ├── git.js ├── git.ts ├── globmatcher.js ├── globmatcher.test.ts ├── globmatcher.ts ├── telemetry.js ├── telemetry.test.ts ├── telemetry.ts ├── utils.js ├── utils.test.ts └── utils.ts ├── docs-target ├── action.yaml ├── index.js ├── index.ts ├── map.js ├── map.test.ts └── map.ts ├── enterprise-check ├── action.yml ├── index.js └── index.ts ├── github-release ├── action.yml ├── index.js └── index.ts ├── has-matching-release-tag ├── action.yaml ├── hasMatchingReleaseTag.js ├── hasMatchingReleaseTag.test.ts ├── hasMatchingReleaseTag.ts ├── index.js └── index.ts ├── jest.config.js ├── metrics-collector ├── action.yml ├── index.js └── index.ts ├── package.json ├── pr-checks ├── Check.js ├── Check.ts ├── Dispatcher.js ├── Dispatcher.test.ts ├── Dispatcher.ts ├── action.yml ├── checks │ ├── ChangelogCheck.js │ ├── ChangelogCheck.test.ts │ ├── ChangelogCheck.ts │ ├── LabelCheck.js │ ├── LabelCheck.test.ts │ ├── LabelCheck.ts │ ├── MilestoneCheck.js │ ├── MilestoneCheck.test.ts │ ├── MilestoneCheck.ts │ ├── index.js │ ├── index.test.ts │ └── index.ts ├── index.js ├── index.ts ├── types.js └── types.ts ├── release-notes-appender ├── FileAppender.js ├── FileAppender.ts ├── action.yml ├── index.js ├── index.ts ├── release.js └── release.ts ├── remove-milestone ├── action.yml ├── index.js └── index.ts ├── repository-dispatch ├── action.yml ├── index.js └── index.ts ├── tsconfig.json ├── tsconfig.test.json ├── update-changelog ├── ChangelogBuilder.js ├── ChangelogBuilder.test.ts ├── ChangelogBuilder.ts ├── FileUpdater.js ├── FileUpdater.test.ts ├── FileUpdater.ts ├── action.yml ├── index.js ├── index.ts └── testdata │ ├── _index.md │ └── changelog1.md ├── update-project-epic └── action.yml └── yarn.lock /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node 3 | { 4 | "name": "Node.js", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/javascript-node:16", 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 | // "forwardPorts": [], 13 | 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | "postCreateCommand": "yarn install" 16 | 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | 20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 | // "remoteUser": "root" 22 | } 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Tab indentation 5 | [{*.ts}] 6 | indent_style = tab 7 | trim_trailing_whitespace = true 8 | 9 | # The indent size used in the `package.json` file cannot be changed 10 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516 11 | [{*.yml,*.yaml,package.json}] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 6, 5 | "sourceType": "module", 6 | "project": [ 7 | "./tsconfig.json", 8 | "./tsconfig.test.json" 9 | ] 10 | }, 11 | "plugins": [ 12 | "@typescript-eslint", 13 | "prettier" 14 | ], 15 | "extends": [ 16 | "eslint:recommended", 17 | "plugin:prettier/recommended" 18 | ], 19 | "ignorePatterns": [ 20 | "node_modules", 21 | "*.js" 22 | ], 23 | "rules": { 24 | "no-unused-vars": "off", 25 | "@typescript-eslint/no-unused-vars": "error", 26 | "@typescript-eslint/no-floating-promises": "error", 27 | "@typescript-eslint/no-misused-promises": [ 28 | "error", 29 | { 30 | "checksVoidReturn": false 31 | } 32 | ], 33 | "prettier/prettier": "error" 34 | }, 35 | "env": { 36 | "jest": true, 37 | "node": true, 38 | "es6": true 39 | } 40 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # As fallback we set grafana-delivery as owner for everything that has no 2 | # explicit owner in order to make mandatory reviews possible: 3 | * @grafana/grafana-release-guild 4 | 5 | # Actions explicitly owned by docs-tooling 6 | /docs-target/ @grafana/docs-tooling 7 | /has-matching-release-tag/ @grafana/docs-tooling 8 | 9 | # Actions explicitly owned by grafana-delivery: 10 | /backport/ @grafana/grafana-release-guild 11 | /bump-version/ @grafana/grafana-release-guild 12 | /close-milestone/ @grafana/grafana-release-guild 13 | /enterprise-check/ @grafana/grafana-release-guild 14 | /github-release/ @grafana/grafana-release-guild 15 | /remove-milestone/ @grafana/grafana-release-guild 16 | /update-changelog/ @grafana/grafana-release-guild 17 | 18 | # Actions created by specified people: 19 | /repository-dispatch/ @AgnesToulet 20 | /release-notes-appender/ @trevorwhitney 21 | 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/codeowners-validator.yml: -------------------------------------------------------------------------------- 1 | name: "Codeowners Validator" 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | codeowners-validator: 9 | runs-on: ubuntu-latest 10 | steps: 11 | # Checks-out your repository, which is validated in the next step 12 | - uses: actions/checkout@v4 13 | - name: GitHub CODEOWNERS Validator 14 | uses: mszostok/codeowners-validator@v0.7.4 15 | # input parameters 16 | with: 17 | # ==== GitHub Auth ==== 18 | 19 | # "The list of checks that will be executed. By default, all checks are executed. Possible values: files,owners,duppatterns,syntax" 20 | checks: "files,duppatterns,syntax" 21 | 22 | # "The comma-separated list of experimental checks that should be executed. By default, all experimental checks are turned off. Possible values: notowned,avoid-shadowing" 23 | experimental_checks: "notowned,avoid-shadowing" 24 | 25 | # The repository path in which CODEOWNERS file should be validated." 26 | repository_path: "." 27 | 28 | # Defines the level on which the application should treat check issues as failures. Defaults to warning, which treats both errors and warnings as failures, and exits with error code 3. Possible values are error and warning. Default: warning" 29 | check_failure_level: "error" 30 | 31 | # The comma-separated list of patterns that should be ignored by not-owned-checker. For example, you can specify * and as a result, the * pattern from the CODEOWNERS file will be ignored and files owned by this pattern will be reported as unowned unless a later specific pattern will match that path. It's useful because often we have default owners entry at the begging of the CODOEWNERS file, e.g. * @global-owner1 @global-owner2" 32 | not_owned_checker_skip_patterns: "" 33 | 34 | # Specifies whether CODEOWNERS may have unowned files. For example, `/infra/oncall-rotator/oncall-config.yml` doesn't have owner and this is not reported. 35 | owner_checker_allow_unowned_patterns: "false" 36 | 37 | # Specifies whether only teams are allowed as owners of files. 38 | owner_checker_owners_must_be_teams: "false" 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 16.16.0 15 | 16 | - name: Get yarn cache directory path 17 | id: yarn-cache-dir-path 18 | run: echo "::set-output name=dir::$(yarn config get cacheFolder)" 19 | 20 | - name: Restore yarn cache 21 | uses: actions/cache@v4 22 | id: yarn-cache 23 | with: 24 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 25 | key: yarn-cache-folder-${{ hashFiles('**/yarn.lock', '.yarnrc.yml') }} 26 | restore-keys: | 27 | yarn-cache-folder- 28 | 29 | - name: Install dependencies 30 | run: yarn install --immutable 31 | 32 | - name: Build yarn 33 | run: yarn run build 34 | 35 | - name: Git checkout 36 | run: | 37 | if [ -n "$(git status --porcelain)" ]; then 38 | echo "ERROR: Please run *yarn build* and commit your changes."; 39 | git diff --exit-code 40 | fi 41 | 42 | - name: Run tests 43 | run: yarn run test 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.js.map 3 | classifier/issue_data.json 4 | classifier/issue_labels.json 5 | **/.DS_Store 6 | .env 7 | .idea/ 8 | 9 | # Yarn 10 | .yarn/* 11 | !.yarn/patches 12 | !.yarn/releases 13 | !.yarn/plugins 14 | !.yarn/sdks 15 | !.yarn/versions 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 110, 7 | "useTabs": true 8 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableTelemetry: false 2 | nodeLinker: node-modules 3 | 4 | yarnPath: .yarn/releases/yarn-3.1.1.cjs 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright for portions of grafana-github-actions are held by [Microsoft Corporation, 2020] as part of project https://github.com/microsoft/vscode-github-triage-actions. All other copyright for project grafana-github-actions are held by [Grafana Labs 2020]. 2 | 3 | MIT License 4 | 5 | Copyright (c) Grafana Labs. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Github action commands for automating issue management. 2 | 3 | Based on work from: https://github.com/microsoft/vscode-github-triage-actions 4 | 5 | 6 | ## Commands 7 | 8 | Type: `label` 9 | 10 | - `action`: defines what action to perform (`close` or `addToProject`) 11 | - `name`: defines which label to match on 12 | - `addToProject`: an object that is required when the `action` is `addToProject`, but is otherwise optional. 13 | - `addToProject.url`: Absolute url of the project where the project `id` will be parsed. 14 | - `addToProject.column`: Column name to add the issues to. Required for old types of projects 15 | - `removeFromProject`: an object that is required when the `action` is `removeFromProject`, but is otherwise optional. 16 | - `removeFromProject.url`: Absolute url of the project where the project `id` will be parsed. 17 | 18 | Note: When removed, the issue will irreversibly lose the project-specific metadata assigned to it. removeFromProject does not currently work for old project types. 19 | 20 | **Syntax**: 21 | ```json 22 | { 23 | "type": "label", 24 | "name": "plugins", 25 | "action": "addToProject", 26 | "addToProject": { 27 | "url": "https://github.com/orgs/grafana/projects/76", 28 | "column": "To Do" 29 | } 30 | } 31 | ``` 32 | 33 | ## PR Checks 34 | 35 | Mark commits with an error, failure, pending, or success state, which is then reflected in pull requests involving those commits. 36 | 37 | **Syntax**: 38 | ```json 39 | [ 40 | { 41 | "type": "" 42 | // check specific properties 43 | } 44 | ] 45 | ``` 46 | 47 | ### Milestone Check 48 | 49 | This will check if a milestone is set on a pull request or not. All properties below except `type` are optional. 50 | 51 | **Syntax**: 52 | ```json 53 | { 54 | "type": "check-milestone", 55 | "title": "Milestone Check", 56 | "targetUrl": "https://something", 57 | "success": "Milestone set", 58 | "failure": "Milestone not set" 59 | } 60 | ``` 61 | 62 | ### Label Check 63 | 64 | This will check if `labels.matches` matches any labels on a pull request. 65 | - If it matches, it will create a success status with a `labels.exists` message. 66 | - If it does not match it will create a failure status with a `labels.notExists` message. 67 | 68 | If `skip.matches` is specified, it will check if any of the labels exist on a pull request and if so, it will create a success status with a `skip.message` message. This will happen before returning a failure status according to the documentation above. 69 | 70 | All properties below except `type` and `labels.matches` are optional. The `labels.matches` and `skip.matches` support providing a `*` (star) at the end to denote only matching the beginning of a label. 71 | 72 | ```json 73 | { 74 | "type": "check-label", 75 | "title": "New Label Backport Check", 76 | "labels": { 77 | "exists": "Backport enabled", 78 | "notExists": "Backport decision needed", 79 | "matches": [ 80 | "backport v*" 81 | ] 82 | }, 83 | "skip": { 84 | "message": "Backport skipped", 85 | "matches": [ 86 | "backport", 87 | "no-backport" 88 | ] 89 | }, 90 | "targetUrl": "https://github.com/grafana/grafana/blob/main/contribute/merge-pull-request.md#should-the-pull-request-be-backported" 91 | } 92 | ``` 93 | 94 | ### Changelog Check 95 | 96 | This check will enforce that an active decision of including a change in changelog/release notes needs to be taken for every pull request. 97 | 98 | This check uses the [Label Check](#label-check) and its detailed description is applicable to this check config as well. 99 | 100 | If the result of the Label Check is a "success" status with `labels.exists` message, the check will continue to validate the PR title: 101 | - If the PR title formatting is not valid, e.g. `: `, it will create a failure status explaining that PR title formatting is invalid. 102 | 103 | If the PR title is valid it will continue to validate the PR body. If you use `breakingChangeLabels` it will check if any of the labels exist on a pull request and if so, it will verify that a breaking change notice section has been added to the PR body: 104 | - If there is no breaking change notice section, it will create a failure status explaining why. 105 | 106 | ```json 107 | { 108 | "type": "check-changelog", 109 | "title": "Changelog Check", 110 | "labels": { 111 | "exists": "Changelog enabled", 112 | "notExists": "Changelog decision needed", 113 | "matches": [ 114 | "add to changelog" 115 | ] 116 | }, 117 | "breakingChangeLabels": [ 118 | "breaking-change" 119 | ], 120 | "skip": { 121 | "message": "Changelog skipped", 122 | "matches": [ 123 | "no-changelog" 124 | ] 125 | }, 126 | "targetUrl": "https://github.com/grafana/grafana/blob/main/contribute/merge-pull-request.md#include-in-changelog-and-release-notes" 127 | } 128 | ``` 129 | -------------------------------------------------------------------------------- /__mocks__/@actions/github.ts: -------------------------------------------------------------------------------- 1 | exports.context = { repo: { repo: 'grafan', owner: 'grafana' } } 2 | -------------------------------------------------------------------------------- /api/api.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /*--------------------------------------------------------------------------------------------- 3 | * Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | exports.projectType = void 0; 8 | var projectType; 9 | (function (projectType) { 10 | projectType[projectType["Project"] = 0] = "Project"; 11 | projectType[projectType["ProjectV2"] = 1] = "ProjectV2"; 12 | })(projectType = exports.projectType || (exports.projectType = {})); 13 | //# sourceMappingURL=api.js.map -------------------------------------------------------------------------------- /api/api.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | export interface GitHub { 7 | query(query: Query): AsyncIterableIterator 8 | 9 | hasWriteAccess(user: User): Promise 10 | 11 | repoHasLabel(label: string): Promise 12 | createLabel(label: string, color: string, description: string): Promise 13 | deleteLabel(label: string): Promise 14 | 15 | readConfig(path: string): Promise 16 | 17 | dispatch(title: string): Promise 18 | 19 | createIssue(owner: string, repo: string, title: string, body: string): Promise 20 | 21 | releaseContainsCommit(release: string, commit: string): Promise<'yes' | 'no' | 'unknown'> 22 | 23 | getMilestone(number: number): Promise 24 | 25 | isUserMemberOfOrganization(org: string, username: string): Promise 26 | 27 | getProject(projectId: number, org?: string, columnName?: string): Promise 28 | 29 | addIssueToProject(project: number, issue: Issue, org?: string, columnName?: string): Promise 30 | 31 | removeIssueFromProject(project: number, issue: Issue, org?: string): Promise 32 | 33 | createStatus( 34 | sha: string, 35 | context: string, 36 | state: 'error' | 'failure' | 'pending' | 'success', 37 | description?: string, 38 | targetUrl?: string, 39 | ): Promise 40 | } 41 | 42 | export interface GitHubIssue extends GitHub { 43 | getIssue(): Promise 44 | 45 | postComment(body: string): Promise 46 | deleteComment(id: number): Promise 47 | getComments(last?: boolean): AsyncIterableIterator 48 | 49 | closeIssue(): Promise 50 | lockIssue(): Promise 51 | 52 | setMilestone(milestoneId: number): Promise 53 | 54 | addLabel(label: string): Promise 55 | removeLabel(label: string): Promise 56 | 57 | addAssignee(assignee: string): Promise 58 | removeAssignee(assignee: string): Promise 59 | 60 | getClosingInfo(): Promise<{ hash: string | undefined; timestamp: number } | undefined> 61 | 62 | getPullRequest(): Promise 63 | listPullRequestFilenames(): Promise 64 | } 65 | 66 | type SortVar = 67 | | 'comments' 68 | | 'reactions' 69 | | 'reactions-+1' 70 | | 'reactions--1' 71 | | 'reactions-smile' 72 | | 'reactions-thinking_face' 73 | | 'reactions-heart' 74 | | 'reactions-tada' 75 | | 'interactions' 76 | | 'created' 77 | | 'updated' 78 | type SortOrder = 'asc' | 'desc' 79 | export type Reactions = { 80 | '+1': number 81 | '-1': number 82 | laugh: number 83 | hooray: number 84 | confused: number 85 | heart: number 86 | rocket: number 87 | eyes: number 88 | } 89 | 90 | export interface User { 91 | name: string 92 | isGitHubApp?: boolean 93 | } 94 | 95 | export interface Comment { 96 | author: User 97 | body: string 98 | id: number 99 | timestamp: number 100 | } 101 | 102 | export interface Issue { 103 | author: User 104 | body: string 105 | title: string 106 | labels: string[] 107 | open: boolean 108 | locked: boolean 109 | number: number 110 | numComments: number 111 | reactions: Reactions 112 | milestoneId: number | null 113 | assignee?: string 114 | createdAt: number 115 | updatedAt: number 116 | closedAt?: number 117 | isPullRequest?: boolean 118 | nodeId: string 119 | } 120 | 121 | export interface PullRequest { 122 | number: number 123 | milestoneId: number | null 124 | headSHA: string 125 | } 126 | 127 | export interface Query { 128 | q: string 129 | sort?: SortVar 130 | order?: SortOrder 131 | repo?: string 132 | } 133 | 134 | export interface Milestone { 135 | closed_at: string | null 136 | number: number 137 | title: string 138 | } 139 | 140 | export enum projectType { 141 | Project, 142 | ProjectV2, 143 | } 144 | 145 | export interface ProjectAndColumnIds { 146 | columnNodeId?: string 147 | projectNodeId: string 148 | projectType: projectType 149 | } 150 | -------------------------------------------------------------------------------- /api/testbed.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /*--------------------------------------------------------------------------------------------- 3 | * Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | exports.TestbedIssue = exports.Testbed = void 0; 8 | const api_1 = require("./api"); 9 | class Testbed { 10 | constructor(config) { 11 | this.config = { 12 | globalLabels: config?.globalLabels ?? [], 13 | configs: config?.configs ?? {}, 14 | writers: config?.writers ?? [], 15 | milestone: config?.milestone, 16 | releasedCommits: config?.releasedCommits ?? [], 17 | queryRunner: config?.queryRunner ?? 18 | async function* () { 19 | yield []; 20 | }, 21 | userMemberOfOrganization: config?.userMemberOfOrganization ?? false, 22 | projectNodeId: config?.projectNodeId ?? 'TESTPROJECTID', 23 | }; 24 | } 25 | async *query(query) { 26 | for await (const page of this.config.queryRunner(query)) { 27 | yield page.map((issue) => issue instanceof TestbedIssue ? issue : new TestbedIssue(this.config, issue)); 28 | } 29 | } 30 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 31 | async createIssue(_owner, _repo, _title, _body) { 32 | // pass... 33 | } 34 | async readConfig(path) { 35 | return JSON.parse(JSON.stringify(this.config.configs[path])); 36 | } 37 | async hasWriteAccess(user) { 38 | return this.config.writers.includes(user.name); 39 | } 40 | async repoHasLabel(label) { 41 | return this.config.globalLabels.includes(label); 42 | } 43 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 44 | async createLabel(label, _color, _description) { 45 | this.config.globalLabels.push(label); 46 | } 47 | async deleteLabel(labelToDelete) { 48 | this.config.globalLabels = this.config.globalLabels.filter((label) => label !== labelToDelete); 49 | } 50 | async releaseContainsCommit(_release, commit) { 51 | return this.config.releasedCommits.includes(commit) ? 'yes' : 'no'; 52 | } 53 | async dispatch(title) { 54 | console.log('dispatching for', title); 55 | } 56 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 57 | async getMilestone(_number) { 58 | return this.config.milestone; 59 | } 60 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 61 | async isUserMemberOfOrganization(org, username) { 62 | return this.config.userMemberOfOrganization; 63 | } 64 | /* eslint-disable @typescript-eslint/no-unused-vars */ 65 | async getProject(_projectId, _org, _columnName) { 66 | return { 67 | projectNodeId: this.config.projectNodeId ?? 'TESTPROJECTID', 68 | projectType: api_1.projectType.ProjectV2, 69 | }; 70 | } 71 | /* eslint-enable @typescript-eslint/no-unused-vars */ 72 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 73 | async addIssueToProject(_project, _issue, org) { 74 | // pass... 75 | } 76 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 77 | async removeIssueFromProject(_project, _issue, org) { 78 | // pass... 79 | } 80 | /* eslint-disable */ 81 | async createStatus(_sha, _context, _state, _description, _targetUrl) { 82 | return; 83 | } 84 | } 85 | exports.Testbed = Testbed; 86 | class TestbedIssue extends Testbed { 87 | constructor(globalConfig, issueConfig) { 88 | super(globalConfig); 89 | issueConfig = issueConfig ?? {}; 90 | issueConfig.comments = issueConfig?.comments ?? []; 91 | issueConfig.labels = issueConfig?.labels ?? []; 92 | issueConfig.issue = { 93 | author: { name: 'JacksonKearl' }, 94 | body: 'issue body', 95 | locked: false, 96 | numComments: issueConfig?.comments?.length || 0, 97 | number: 1, 98 | open: true, 99 | title: 'issue title', 100 | assignee: undefined, 101 | reactions: { 102 | '+1': 0, 103 | '-1': 0, 104 | confused: 0, 105 | eyes: 0, 106 | heart: 0, 107 | hooray: 0, 108 | laugh: 0, 109 | rocket: 0, 110 | }, 111 | closedAt: undefined, 112 | createdAt: +new Date(), 113 | updatedAt: +new Date(), 114 | ...issueConfig.issue, 115 | }; 116 | (issueConfig.pullRequestFilenames = issueConfig?.pullRequestFilenames ?? []), 117 | (this.issueConfig = issueConfig); 118 | } 119 | async addAssignee(assignee) { 120 | this.issueConfig.issue.assignee = assignee; 121 | } 122 | async removeAssignee() { 123 | this.issueConfig.issue.assignee = undefined; 124 | } 125 | async setMilestone(milestoneId) { 126 | this.issueConfig.issue.milestoneId = milestoneId; 127 | } 128 | async getIssue() { 129 | const labels = [...this.issueConfig.labels]; 130 | return { ...this.issueConfig.issue, labels }; 131 | } 132 | async getPullRequest() { 133 | return { ...this.issueConfig.pullRequest }; 134 | } 135 | async postComment(body, author) { 136 | this.issueConfig.comments.push({ 137 | author: { name: author ?? 'bot' }, 138 | body, 139 | id: Math.random(), 140 | timestamp: +new Date(), 141 | }); 142 | } 143 | async deleteComment(id) { 144 | this.issueConfig.comments = this.issueConfig.comments.filter((comment) => comment.id !== id); 145 | } 146 | async *getComments(last) { 147 | yield last 148 | ? [this.issueConfig.comments[this.issueConfig.comments.length - 1]] 149 | : this.issueConfig.comments; 150 | } 151 | async addLabel(label) { 152 | this.issueConfig.labels.push(label); 153 | } 154 | async removeLabel(labelToDelete) { 155 | this.issueConfig.labels = this.issueConfig.labels.filter((label) => label !== labelToDelete); 156 | } 157 | async closeIssue() { 158 | this.issueConfig.issue.open = false; 159 | } 160 | async lockIssue() { 161 | this.issueConfig.issue.locked = true; 162 | } 163 | async getClosingInfo() { 164 | return this.issueConfig.closingCommit; 165 | } 166 | async listPullRequestFilenames() { 167 | return this.issueConfig.pullRequestFilenames; 168 | } 169 | } 170 | exports.TestbedIssue = TestbedIssue; 171 | //# sourceMappingURL=testbed.js.map -------------------------------------------------------------------------------- /backport/action.yml: -------------------------------------------------------------------------------- 1 | name: Backport 2 | description: Automatically creates a cherry pick PR 3 | inputs: 4 | token: 5 | description: | 6 | GitHub token with read and write permissions for issues, comments, and labels. 7 | 8 | Additionally, the token needs read permissions for organization members if `removeDefaultReviewers` is set to `true`. 9 | default: ${{ github.token }} 10 | title: 11 | description: Title for the backport PR 12 | default: "[Backport to {{base}}] {{originalTitle}}" 13 | labelsToAdd: 14 | description: Comma separated list of labels to add to the backport PR. 15 | required: false 16 | removeDefaultReviewers: 17 | default: true 18 | description: Whether to remove default reviewers from the backport PRs. 19 | type: boolean 20 | metricsWriteAPIKey: 21 | description: Grfanaa Cloud metrics api key 22 | required: false 23 | runs: 24 | using: 'node20' 25 | main: 'index.js' 26 | -------------------------------------------------------------------------------- /backport/backport.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BETTERER_RESULTS_PATH, 3 | getFinalLabels, 4 | isBettererConflict, 5 | getFailedBackportCommentBody, 6 | } from './backport' 7 | 8 | const onlyDocsChanges = ['docs/sources/_index.md', 'docs/sources/other.md'] 9 | const onlyBettererChanges = [BETTERER_RESULTS_PATH] 10 | 11 | test('isBettererConflict/onlyDocsChanges', () => { 12 | return expect(isBettererConflict(onlyDocsChanges)).resolves.toStrictEqual(false) 13 | }) 14 | test('isBettererConflict/onlyBettererChanges', () => { 15 | return expect(isBettererConflict(onlyBettererChanges)).resolves.toStrictEqual(true) 16 | }) 17 | 18 | test('getFinalLabels/simple', () => { 19 | return expect(getFinalLabels(['hello', 'world'], [])).toEqual(new Set(['hello', 'world'])) 20 | }) 21 | 22 | // All those `backport .*` should be removed from the labels ported over. 23 | test('getFinalLabels/remove-backports', () => { 24 | return expect(getFinalLabels(['backport v10.0.x', 'world'], [])).toEqual(new Set(['world'])) 25 | }) 26 | 27 | // The backport-failed label should not be ported over. 28 | test('getFinalLabels/remove-backport-failed', () => { 29 | return expect(getFinalLabels(['backport-failed', 'world'], [])).toEqual(new Set(['world'])) 30 | }) 31 | 32 | // If a backport label for a specific target is explicitly requested by the 33 | // configuration, it should still be included. 34 | test('getFinalLabels/remove-backports-original-only', () => { 35 | return expect(getFinalLabels(['backport v10.0.x', 'world'], ['backport v10.0.x'])).toEqual( 36 | new Set(['backport v10.0.x', 'world']), 37 | ) 38 | }) 39 | 40 | // If the original PR has the `add to changelog` label set but we explicitly 41 | // configured `no-changelog`, then the latter should override the first: 42 | test('getFinalLabels/enforce-no-changelog', () => { 43 | return expect(getFinalLabels(['add to changelog', 'world'], ['no-changelog'])).toEqual( 44 | new Set(['no-changelog', 'world']), 45 | ) 46 | }) 47 | 48 | // If the original PR has the `no-changelog` label set but we explicitly 49 | // configured `add to changelog`, then the latter should override the first: 50 | test('getFinalLabels/enforce-add-to-changelog', () => { 51 | return expect(getFinalLabels(['no-changelog', 'world'], ['add to changelog'])).toEqual( 52 | new Set(['add to changelog', 'world']), 53 | ) 54 | }) 55 | 56 | test('getFailedBackportCommentBody/gh-line-no-body', () => { 57 | const output = getFailedBackportCommentBody({ 58 | base: 'v10.0.x', 59 | commitToBackport: '123456', 60 | errorMessage: 'some error', 61 | head: 'backport-123-to-v10.0.x', 62 | title: '[v10.0.x] hello world', 63 | originalNumber: 123, 64 | labels: ['backport'], 65 | hasBody: false, 66 | }) 67 | expect(output).toContain( 68 | `gh pr create --title '[v10.0.x] hello world' --body 'Backport 123456 from #123' --label 'backport' --base v10.0.x --milestone 10.0.x --web`, 69 | ) 70 | expect(output).toContain('git push --set-upstream origin backport-123-to-v10.0.x') 71 | }) 72 | 73 | test('getFailedBackportCommentBody/gh-line-with-body', () => { 74 | const output = getFailedBackportCommentBody({ 75 | base: 'v10.0.x', 76 | commitToBackport: '123456', 77 | errorMessage: 'some error', 78 | head: 'backport-123-to-v10.0.x', 79 | title: '[v10.0.x] hello world', 80 | originalNumber: 123, 81 | labels: ['backport', 'no-changelog'], 82 | hasBody: true, 83 | }) 84 | expect(output).toContain( 85 | `gh pr create --title '[v10.0.x] hello world' --body-file - --label 'backport' --label 'no-changelog' --base v10.0.x --milestone 10.0.x --web`, 86 | ) 87 | expect(output).toContain('git push --set-upstream origin backport-123-to-v10.0.x') 88 | }) 89 | -------------------------------------------------------------------------------- /backport/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.getLabelsToAdd = void 0; 4 | const core_1 = require("@actions/core"); 5 | const github_1 = require("@actions/github"); 6 | const Action_1 = require("../common/Action"); 7 | const backport_1 = require("./backport"); 8 | class Backport extends Action_1.Action { 9 | constructor() { 10 | super(...arguments); 11 | this.id = 'Backport'; 12 | } 13 | async onClosed(issue) { 14 | return this.backport(issue); 15 | } 16 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 17 | async onLabeled(issue, _label) { 18 | return this.backport(issue); 19 | } 20 | async backport(issue) { 21 | try { 22 | await (0, backport_1.backport)({ 23 | issue, 24 | labelsToAdd: (0, exports.getLabelsToAdd)((0, core_1.getInput)('labelsToAdd')), 25 | payload: github_1.context.payload, 26 | titleTemplate: (0, core_1.getInput)('title'), 27 | removeDefaultReviewers: (0, core_1.getBooleanInput)('removeDefaultReviewers'), 28 | github: issue.octokit, 29 | token: this.getToken(), 30 | sender: github_1.context.payload.sender, 31 | }); 32 | } 33 | catch (error) { 34 | if (error instanceof Error) { 35 | (0, core_1.error)(error); 36 | (0, core_1.setFailed)(error.message); 37 | } 38 | } 39 | } 40 | } 41 | const getLabelsToAdd = (input) => { 42 | if (input === undefined || input === '') { 43 | return []; 44 | } 45 | const labels = input.split(','); 46 | return labels.map((v) => v.trim()).filter((v) => v !== ''); 47 | }; 48 | exports.getLabelsToAdd = getLabelsToAdd; 49 | new Backport().run(); // eslint-disable-line 50 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /backport/index.ts: -------------------------------------------------------------------------------- 1 | import { error as logError, getBooleanInput, getInput, setFailed } from '@actions/core' 2 | import { context } from '@actions/github' 3 | import { EventPayloads } from '@octokit/webhooks' 4 | import { OctoKitIssue } from '../api/octokit' 5 | import { Action } from '../common/Action' 6 | import { backport } from './backport' 7 | 8 | class Backport extends Action { 9 | id = 'Backport' 10 | 11 | async onClosed(issue: OctoKitIssue) { 12 | return this.backport(issue) 13 | } 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | async onLabeled(issue: OctoKitIssue, _label: string) { 16 | return this.backport(issue) 17 | } 18 | 19 | async backport(issue: OctoKitIssue) { 20 | try { 21 | await backport({ 22 | issue, 23 | labelsToAdd: getLabelsToAdd(getInput('labelsToAdd')), 24 | payload: context.payload as EventPayloads.WebhookPayloadPullRequest, 25 | titleTemplate: getInput('title'), 26 | removeDefaultReviewers: getBooleanInput('removeDefaultReviewers'), 27 | github: issue.octokit, 28 | token: this.getToken(), 29 | sender: context.payload.sender as EventPayloads.PayloadSender, 30 | }) 31 | } catch (error) { 32 | if (error instanceof Error) { 33 | logError(error) 34 | setFailed(error.message) 35 | } 36 | } 37 | } 38 | } 39 | 40 | export const getLabelsToAdd = (input: string | undefined): string[] => { 41 | if (input === undefined || input === '') { 42 | return [] 43 | } 44 | 45 | const labels = input.split(',') 46 | return labels.map((v) => v.trim()).filter((v) => v !== '') 47 | } 48 | 49 | new Backport().run() // eslint-disable-line 50 | -------------------------------------------------------------------------------- /bump-version/action.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | description: Updates version 3 | inputs: 4 | token: 5 | description: GitHub token with issue, comment, and label read/write permissions 6 | default: ${{ github.token }} 7 | metricsWriteAPIKey: 8 | description: Grafana Cloud metrics api key 9 | required: false 10 | version_call: 11 | description: Version number invoked from workflow 12 | precommit_make_target: 13 | required: false 14 | description: Make target to execute before the changes are committed for the PR 15 | runs: 16 | using: 'node20' 17 | main: 'index.js' 18 | -------------------------------------------------------------------------------- /bump-version/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | // import { error as logError, getInput, setFailed } from '@actions/core' 7 | const github_1 = require("@actions/github"); 8 | // import { EventPayloads } from '@octokit/webhooks' 9 | // import { OctoKitIssue } from '../api/octokit' 10 | const Action_1 = require("../common/Action"); 11 | const exec_1 = require("@actions/exec"); 12 | const git_1 = require("../common/git"); 13 | const fs_1 = __importDefault(require("fs")); 14 | const versions_1 = require("./versions"); 15 | const utils_1 = require("../common/utils"); 16 | class BumpVersion extends Action_1.Action { 17 | constructor() { 18 | super(...arguments); 19 | this.id = 'BumpVersion'; 20 | } 21 | async onTriggered(octokit) { 22 | const { owner, repo } = github_1.context.repo; 23 | const token = this.getToken(); 24 | await (0, git_1.cloneRepo)({ token, owner, repo }); 25 | await (0, git_1.setConfig)('grafana-delivery-bot'); 26 | process.chdir(repo); 27 | if (!this.isCalledFromWorkflow()) { 28 | // Manually invoked the action 29 | const version = this.getVersion(); 30 | const base = github_1.context.ref.substring(github_1.context.ref.lastIndexOf('/') + 1); 31 | await this.onTriggeredBase(octokit, base, version); 32 | return; 33 | } 34 | // Action invoked by a workflow 35 | const version_call = this.getVersion(); 36 | const matches = (0, versions_1.getVersionMatch)(version_call); 37 | if (!matches || matches.length < 2) { 38 | throw new Error('The input version format is not correct, please respect major.minor.patch or major.minor.patch-beta{number} format. Example: 7.4.3 or 7.4.3-beta1'); 39 | } 40 | let semantic_version = version_call; 41 | // if the milestone is beta 42 | if (matches[2] !== undefined) { 43 | // transform the milestone to use semantic versioning 44 | // i.e 8.2.3-beta1 --> 8.2.3-beta.1 45 | semantic_version = version_call.replace('-beta', '-beta.'); 46 | } 47 | const base = `v${matches[1]}.x`; 48 | await this.onTriggeredBase(octokit, base, semantic_version); 49 | } 50 | async onTriggeredBase(octokit, base, version) { 51 | const { owner, repo } = github_1.context.repo; 52 | const prBranch = `bump-version-${version}`; 53 | const hasLerna = fs_1.default.existsSync('lerna.json'); 54 | // Lerna was replaced with Nx release after 11.5.0. For backwards compatibility we support both 55 | const versionCmd = hasLerna 56 | ? [ 57 | 'run', 58 | 'lerna', 59 | 'version', 60 | version, 61 | '--no-push', 62 | '--no-git-tag-version', 63 | '--force-publish', 64 | '--exact', 65 | '--yes', 66 | ] 67 | : ['nx', 'release', 'version', version, '--groups', 'grafanaPackages,privatePackages,plugins']; 68 | // create branch 69 | await git('switch', base); 70 | await git('switch', '--create', prBranch); 71 | // Install dependencies so that we can run versioning commands 72 | await (0, exec_1.exec)('yarn', ['install']); 73 | // Update root package.json version 74 | await (0, exec_1.exec)('npm', ['version', version, '--no-git-tag-version']); 75 | // Update the npm packages and plugins package.json versions to align with grafana version. 76 | await (0, exec_1.exec)('yarn', versionCmd); 77 | try { 78 | //regenerate yarn.lock file 79 | //await exec('npm', ['install', '-g', 'corepack']) 80 | await (0, exec_1.exec)('corepack', ['enable']); 81 | //await exec('yarn', ['set', 'version', '3.1.1']) 82 | await (0, exec_1.exec)('yarn', ['--version']); 83 | await (0, exec_1.exec)('yarn', ['install', '--mode', 'update-lockfile']); 84 | } 85 | catch (e) { 86 | console.error('yarn failed', e); 87 | } 88 | const precommitMakeTarget = (0, utils_1.getInput)('precommit_make_target'); 89 | if (precommitMakeTarget) { 90 | await (0, exec_1.exec)('make', [precommitMakeTarget]); 91 | } 92 | await git('commit', '-am', `"Release: Updated versions in package to ${version}"`); 93 | // push 94 | await git('push', '--set-upstream', 'origin', prBranch); 95 | const body = `Executed:\n 96 | npm version ${version} --no-git-tag-version\n 97 | yarn install\n 98 | yarn ${versionCmd.join(' ')}\n 99 | yarn install --mode update-lockfile 100 | `; 101 | await octokit.octokit.pulls.create({ 102 | base, 103 | body, 104 | head: prBranch, 105 | owner, 106 | repo, 107 | title: `Release: Bump version to ${version}`, 108 | }); 109 | } 110 | } 111 | const git = async (...args) => { 112 | // await exec('git', args, { cwd: repo }) 113 | await (0, exec_1.exec)('git', args); 114 | }; 115 | new BumpVersion().run(); // eslint-disable-line 116 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /bump-version/index.ts: -------------------------------------------------------------------------------- 1 | // import { error as logError, getInput, setFailed } from '@actions/core' 2 | import { context } from '@actions/github' 3 | // import { EventPayloads } from '@octokit/webhooks' 4 | // import { OctoKitIssue } from '../api/octokit' 5 | import { Action } from '../common/Action' 6 | import { exec } from '@actions/exec' 7 | import { cloneRepo, setConfig } from '../common/git' 8 | import fs from 'fs' 9 | import { OctoKit } from '../api/octokit' 10 | import { getVersionMatch } from './versions' 11 | import { getInput } from '../common/utils' 12 | 13 | class BumpVersion extends Action { 14 | id = 'BumpVersion' 15 | 16 | async onTriggered(octokit: OctoKit) { 17 | const { owner, repo } = context.repo 18 | const token = this.getToken() 19 | 20 | await cloneRepo({ token, owner, repo }) 21 | await setConfig('grafana-delivery-bot') 22 | 23 | process.chdir(repo) 24 | 25 | if (!this.isCalledFromWorkflow()) { 26 | // Manually invoked the action 27 | const version = this.getVersion() 28 | const base = context.ref.substring(context.ref.lastIndexOf('/') + 1) 29 | await this.onTriggeredBase(octokit, base, version) 30 | return 31 | } 32 | 33 | // Action invoked by a workflow 34 | const version_call = this.getVersion() 35 | const matches = getVersionMatch(version_call) 36 | if (!matches || matches.length < 2) { 37 | throw new Error( 38 | 'The input version format is not correct, please respect major.minor.patch or major.minor.patch-beta{number} format. Example: 7.4.3 or 7.4.3-beta1', 39 | ) 40 | } 41 | 42 | let semantic_version = version_call 43 | 44 | // if the milestone is beta 45 | if (matches[2] !== undefined) { 46 | // transform the milestone to use semantic versioning 47 | // i.e 8.2.3-beta1 --> 8.2.3-beta.1 48 | semantic_version = version_call.replace('-beta', '-beta.') 49 | } 50 | 51 | const base = `v${matches[1]}.x` 52 | await this.onTriggeredBase(octokit, base, semantic_version) 53 | } 54 | 55 | async onTriggeredBase(octokit: OctoKit, base: string, version: string) { 56 | const { owner, repo } = context.repo 57 | const prBranch = `bump-version-${version}` 58 | const hasLerna = fs.existsSync('lerna.json') 59 | // Lerna was replaced with Nx release after 11.5.0. For backwards compatibility we support both 60 | const versionCmd = hasLerna 61 | ? [ 62 | 'run', 63 | 'lerna', 64 | 'version', 65 | version, 66 | '--no-push', 67 | '--no-git-tag-version', 68 | '--force-publish', 69 | '--exact', 70 | '--yes', 71 | ] 72 | : ['nx', 'release', 'version', version, '--groups', 'grafanaPackages,privatePackages,plugins'] 73 | // create branch 74 | await git('switch', base) 75 | await git('switch', '--create', prBranch) 76 | // Install dependencies so that we can run versioning commands 77 | await exec('yarn', ['install']) 78 | // Update root package.json version 79 | await exec('npm', ['version', version, '--no-git-tag-version']) 80 | // Update the npm packages and plugins package.json versions to align with grafana version. 81 | await exec('yarn', versionCmd) 82 | 83 | try { 84 | //regenerate yarn.lock file 85 | //await exec('npm', ['install', '-g', 'corepack']) 86 | await exec('corepack', ['enable']) 87 | //await exec('yarn', ['set', 'version', '3.1.1']) 88 | await exec('yarn', ['--version']) 89 | await exec('yarn', ['install', '--mode', 'update-lockfile']) 90 | } catch (e) { 91 | console.error('yarn failed', e) 92 | } 93 | 94 | const precommitMakeTarget = getInput('precommit_make_target') 95 | if (precommitMakeTarget) { 96 | await exec('make', [precommitMakeTarget]) 97 | } 98 | 99 | await git('commit', '-am', `"Release: Updated versions in package to ${version}"`) 100 | // push 101 | await git('push', '--set-upstream', 'origin', prBranch) 102 | const body = `Executed:\n 103 | npm version ${version} --no-git-tag-version\n 104 | yarn install\n 105 | yarn ${versionCmd.join(' ')}\n 106 | yarn install --mode update-lockfile 107 | ` 108 | await octokit.octokit.pulls.create({ 109 | base, 110 | body, 111 | head: prBranch, 112 | owner, 113 | repo, 114 | title: `Release: Bump version to ${version}`, 115 | }) 116 | } 117 | } 118 | 119 | const git = async (...args: string[]) => { 120 | // await exec('git', args, { cwd: repo }) 121 | await exec('git', args) 122 | } 123 | 124 | new BumpVersion().run() // eslint-disable-line 125 | -------------------------------------------------------------------------------- /bump-version/versions.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.getVersionMatch = void 0; 4 | function getVersionMatch(version) { 5 | return version.match(/^(\d+.\d+).\d+(?:-(((beta)\d+)|(?:pre)))?$/); 6 | } 7 | exports.getVersionMatch = getVersionMatch; 8 | //# sourceMappingURL=versions.js.map -------------------------------------------------------------------------------- /bump-version/versions.test.ts: -------------------------------------------------------------------------------- 1 | import { getVersionMatch } from './versions' 2 | 3 | describe('BumpVersion', () => { 4 | describe('validateVersion', () => { 5 | it('should allow for beta releases', async () => { 6 | const match = getVersionMatch('10.0.0-beta1') 7 | expect(match).not.toBeNull() 8 | expect(match).toHaveLength(5) 9 | expect(match[2]).toEqual('beta1') 10 | expect(match[1]).toEqual('10.0') 11 | }) 12 | it('should allow for pre releases', async () => { 13 | const match = getVersionMatch('10.0.0-pre') 14 | expect(match).not.toBeNull() 15 | expect(match).toHaveLength(5) 16 | expect(match[2]).toEqual('pre') 17 | expect(match[1]).toEqual('10.0') 18 | }) 19 | it('should allow for non-prerelease versions', async () => { 20 | const match = getVersionMatch('10.1.2') 21 | expect(match).not.toBeNull() 22 | expect(match).toHaveLength(5) 23 | expect(match[2]).toBeUndefined() 24 | expect(match[1]).toEqual('10.1') 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /bump-version/versions.ts: -------------------------------------------------------------------------------- 1 | export function getVersionMatch(version: string): string[] | null { 2 | return version.match(/^(\d+.\d+).\d+(?:-(((beta)\d+)|(?:pre)))?$/) 3 | } 4 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | title: Grafana GitHub Actions 5 | name: grafana-github-actions 6 | description: Various GitHub Actions for the Grafana project 7 | annotations: 8 | github.com/project-slug: grafana/grafana-github-actions 9 | backstage.io/source-location: url:https://github.com/grafana/grafana-github-actions/ 10 | spec: 11 | type: library 12 | lifecycle: production 13 | owner: "group:grafana-release-guild" 14 | system: grafana 15 | -------------------------------------------------------------------------------- /close-milestone/action.yml: -------------------------------------------------------------------------------- 1 | name: Close milestone 2 | description: Closes the milestone 3 | inputs: 4 | token: 5 | description: GitHub token with issue, comment, and label read/write permissions. 6 | default: ${{ github.token }} 7 | version_call: 8 | description: Version number invoked from workflow 9 | runs: 10 | using: 'node20' 11 | main: 'index.js' 12 | -------------------------------------------------------------------------------- /close-milestone/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const github_1 = require("@actions/github"); 4 | const Action_1 = require("../common/Action"); 5 | class CloseMilestone extends Action_1.Action { 6 | constructor() { 7 | super(...arguments); 8 | this.id = 'CloseMilestone'; 9 | } 10 | async onTriggered(octokit) { 11 | const { owner, repo } = github_1.context.repo; 12 | const version = this.getVersion(); 13 | // get all the milestones 14 | const milestones = await octokit.octokit.issues.listMilestonesForRepo({ 15 | owner, 16 | repo, 17 | state: 'open', 18 | }); 19 | for (const milestone of milestones.data) { 20 | if (milestone.title === version) { 21 | await octokit.octokit.issues.updateMilestone({ 22 | owner, 23 | repo, 24 | milestone_number: milestone.number, 25 | state: 'closed', 26 | description: `${milestone.description}\n Closed by github action`, 27 | }); 28 | return; 29 | } 30 | } 31 | throw new Error('Could not find milestone'); 32 | } 33 | } 34 | new CloseMilestone().run(); // eslint-disable-line 35 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /close-milestone/index.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import { Action } from '../common/Action' 3 | import { OctoKit } from '../api/octokit' 4 | 5 | class CloseMilestone extends Action { 6 | id = 'CloseMilestone' 7 | 8 | async onTriggered(octokit: OctoKit) { 9 | const { owner, repo } = context.repo 10 | const version = this.getVersion() 11 | 12 | // get all the milestones 13 | const milestones = await octokit.octokit.issues.listMilestonesForRepo({ 14 | owner, 15 | repo, 16 | state: 'open', 17 | }) 18 | 19 | for (const milestone of milestones.data) { 20 | if (milestone.title === version) { 21 | await octokit.octokit.issues.updateMilestone({ 22 | owner, 23 | repo, 24 | milestone_number: milestone.number, 25 | state: 'closed', 26 | description: `${milestone.description}\n Closed by github action`, 27 | }) 28 | return 29 | } 30 | } 31 | 32 | throw new Error('Could not find milestone') 33 | } 34 | } 35 | 36 | new CloseMilestone().run() // eslint-disable-line 37 | -------------------------------------------------------------------------------- /commands/action.yml: -------------------------------------------------------------------------------- 1 | name: Commands 2 | description: Respond to commands given in the form of either labels or comments by contributors or authorized community members 3 | inputs: 4 | token: 5 | description: GitHub token with issue, comment, and label read/write permissions. Requires metadata permissions to be able to check whether author is member of organization. 6 | default: ${{ github.token }} 7 | configPath: 8 | description: Name of .json file (no extension) in .github/ directory of repo holding configuration for this action 9 | required: true 10 | metricsWriteAPIKey: 11 | description: Grafana Cloud metrics api key 12 | required: false 13 | runs: 14 | using: 'node20' 15 | main: 'index.js' 16 | -------------------------------------------------------------------------------- /commands/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /*--------------------------------------------------------------------------------------------- 3 | * Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | Object.defineProperty(exports, "__esModule", { value: true }); 7 | const utils_1 = require("../common/utils"); 8 | const Commands_1 = require("./Commands"); 9 | const Action_1 = require("../common/Action"); 10 | class CommandsRunner extends Action_1.Action { 11 | constructor() { 12 | super(...arguments); 13 | this.id = 'Commands'; 14 | } 15 | async onCommented(issue, comment, actor) { 16 | const commands = await issue.readConfig((0, utils_1.getRequiredInput)('configPath')); 17 | await new Commands_1.Commands(issue, commands, { comment, user: { name: actor } }).run(); 18 | } 19 | async onLabeled(issue, label) { 20 | const commands = await issue.readConfig((0, utils_1.getRequiredInput)('configPath')); 21 | await new Commands_1.Commands(issue, commands, { label }).run(); 22 | } 23 | async onOpened(issue) { 24 | const commands = await issue.readConfig((0, utils_1.getRequiredInput)('configPath')); 25 | await new Commands_1.Commands(issue, commands, {}).run(); 26 | } 27 | async onSynchronized(issue) { 28 | const commands = await issue.readConfig((0, utils_1.getRequiredInput)('configPath')); 29 | await new Commands_1.Commands(issue, commands, {}).run(); 30 | } 31 | async onReopened(issue) { 32 | const commands = await issue.readConfig((0, utils_1.getRequiredInput)('configPath')); 33 | await new Commands_1.Commands(issue, commands, {}).run(); 34 | } 35 | async onUnlabeled(issue, label) { 36 | const commands = await issue.readConfig((0, utils_1.getRequiredInput)('configPath')); 37 | await new Commands_1.Commands(issue, commands, { label }).run(); 38 | } 39 | } 40 | new CommandsRunner().run(); // eslint-disable-line 41 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /commands/index.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import { OctoKitIssue } from '../api/octokit' 7 | import { getRequiredInput } from '../common/utils' 8 | import { Commands } from './Commands' 9 | import { Action } from '../common/Action' 10 | 11 | class CommandsRunner extends Action { 12 | id = 'Commands' 13 | 14 | async onCommented(issue: OctoKitIssue, comment: string, actor: string) { 15 | const commands = await issue.readConfig(getRequiredInput('configPath')) 16 | await new Commands(issue, commands, { comment, user: { name: actor } }).run() 17 | } 18 | 19 | async onLabeled(issue: OctoKitIssue, label: string) { 20 | const commands = await issue.readConfig(getRequiredInput('configPath')) 21 | await new Commands(issue, commands, { label }).run() 22 | } 23 | 24 | async onOpened(issue: OctoKitIssue): Promise { 25 | const commands = await issue.readConfig(getRequiredInput('configPath')) 26 | await new Commands(issue, commands, {}).run() 27 | } 28 | 29 | async onSynchronized(issue: OctoKitIssue): Promise { 30 | const commands = await issue.readConfig(getRequiredInput('configPath')) 31 | await new Commands(issue, commands, {}).run() 32 | } 33 | 34 | async onReopened(issue: OctoKitIssue): Promise { 35 | const commands = await issue.readConfig(getRequiredInput('configPath')) 36 | await new Commands(issue, commands, {}).run() 37 | } 38 | async onUnlabeled(issue: OctoKitIssue, label: string) { 39 | const commands = await issue.readConfig(getRequiredInput('configPath')) 40 | await new Commands(issue, commands, { label }).run() 41 | } 42 | } 43 | 44 | new CommandsRunner().run() // eslint-disable-line 45 | -------------------------------------------------------------------------------- /common/git.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.cloneRepo = exports.setConfig = void 0; 4 | const exec_1 = require("@actions/exec"); 5 | const grafanaBotProps = { userEmail: 'bot@grafana.com', userName: 'grafanabot' }; 6 | const grafanaDeliveryBotProps = { 7 | userEmail: '132647405+grafana-delivery-bot[bot]@users.noreply.github.com', 8 | userName: 'grafana-delivery-bot[bot]', 9 | }; 10 | async function setConfig(botSlug) { 11 | let configProps; 12 | switch (botSlug) { 13 | case 'grafanabot': 14 | configProps = grafanaBotProps; 15 | break; 16 | case 'grafana-delivery-bot': 17 | configProps = grafanaDeliveryBotProps; 18 | break; 19 | default: 20 | throw Error('Users available: grafanabot and grafana-delivery-bot, please add another user if that is the case'); 21 | } 22 | await (0, exec_1.exec)('git', ['config', '--global', 'user.email', configProps.userEmail]); 23 | await (0, exec_1.exec)('git', ['config', '--global', 'user.name', configProps.userName]); 24 | } 25 | exports.setConfig = setConfig; 26 | async function cloneRepo({ token, owner, repo }) { 27 | await (0, exec_1.exec)('git', ['clone', `https://x-access-token:${token}@github.com/${owner}/${repo}.git`]); 28 | } 29 | exports.cloneRepo = cloneRepo; 30 | //# sourceMappingURL=git.js.map -------------------------------------------------------------------------------- /common/git.ts: -------------------------------------------------------------------------------- 1 | import { exec } from '@actions/exec' 2 | 3 | export interface CloneProps { 4 | token: string 5 | owner: string 6 | repo: string 7 | } 8 | 9 | export interface ConfigProps { 10 | userEmail: string 11 | userName: string 12 | } 13 | 14 | const grafanaBotProps: ConfigProps = { userEmail: 'bot@grafana.com', userName: 'grafanabot' } 15 | const grafanaDeliveryBotProps: ConfigProps = { 16 | userEmail: '132647405+grafana-delivery-bot[bot]@users.noreply.github.com', 17 | userName: 'grafana-delivery-bot[bot]', 18 | } 19 | 20 | export async function setConfig(botSlug: string) { 21 | let configProps: ConfigProps 22 | switch (botSlug) { 23 | case 'grafanabot': 24 | configProps = grafanaBotProps 25 | break 26 | case 'grafana-delivery-bot': 27 | configProps = grafanaDeliveryBotProps 28 | break 29 | default: 30 | throw Error( 31 | 'Users available: grafanabot and grafana-delivery-bot, please add another user if that is the case', 32 | ) 33 | } 34 | await exec('git', ['config', '--global', 'user.email', configProps.userEmail]) 35 | await exec('git', ['config', '--global', 'user.name', configProps.userName]) 36 | } 37 | 38 | export async function cloneRepo({ token, owner, repo }: CloneProps) { 39 | await exec('git', ['clone', `https://x-access-token:${token}@github.com/${owner}/${repo}.git`]) 40 | } 41 | -------------------------------------------------------------------------------- /common/globmatcher.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | Object.defineProperty(exports, "__esModule", { value: true }); 26 | exports.checkMatch = void 0; 27 | const micromatch = __importStar(require("micromatch")); 28 | function isMatch(input, matchers) { 29 | for (const matcher of matchers) { 30 | if (matcher(input)) { 31 | return true; 32 | } 33 | } 34 | return false; 35 | } 36 | // equivalent to "Array.some()" but expanded for debugging and clarity 37 | function checkAny(inputs, globs) { 38 | const matchers = globs.map((g) => micromatch.matcher(g)); 39 | for (const changedFile of inputs) { 40 | if (isMatch(changedFile, matchers)) { 41 | return true; 42 | } 43 | } 44 | return false; 45 | } 46 | // equivalent to "Array.every()" but expanded for debugging and clarity 47 | function checkAll(inputs, globs) { 48 | const matchers = globs.map((g) => micromatch.matcher(g)); 49 | for (const changedFile of inputs) { 50 | if (!isMatch(changedFile, matchers)) { 51 | return false; 52 | } 53 | } 54 | return true; 55 | } 56 | function checkMatch(inputs, matchConfig) { 57 | if (matchConfig.all !== undefined) { 58 | if (!checkAll(inputs, matchConfig.all)) { 59 | return false; 60 | } 61 | } 62 | if (matchConfig.any !== undefined) { 63 | if (!checkAny(inputs, matchConfig.any)) { 64 | return false; 65 | } 66 | } 67 | return true; 68 | } 69 | exports.checkMatch = checkMatch; 70 | //# sourceMappingURL=globmatcher.js.map -------------------------------------------------------------------------------- /common/globmatcher.test.ts: -------------------------------------------------------------------------------- 1 | import { checkMatch } from './globmatcher' 2 | 3 | describe('glob matching', () => { 4 | describe('any', () => { 5 | it('should match any file', () => { 6 | const result = checkMatch( 7 | ['backend/backend.go', 'backend/a/b/c/c.go', 'src/app.ts', 'src/app.ts/a/b/c/c.ts'], 8 | { 9 | any: ['**'], 10 | }, 11 | ) 12 | expect(result).toBeTruthy() 13 | }) 14 | 15 | it('should match any backend files', () => { 16 | const result = checkMatch( 17 | ['backend/backend.go', 'backend/a/b/c/c.go', 'src/app.ts', 'src/app.ts/a/b/c/c.ts'], 18 | { 19 | any: ['backend/**/*'], 20 | }, 21 | ) 22 | expect(result).toBeTruthy() 23 | }) 24 | 25 | it('should not match any docs files', () => { 26 | const result = checkMatch( 27 | ['backend/backend.go', 'backend/a/b/c/c.go', 'src/app.ts', 'src/app.ts/a/b/c/c.ts'], 28 | { 29 | any: ['docs/**/*'], 30 | }, 31 | ) 32 | expect(result).toBeFalsy() 33 | }) 34 | 35 | it('should match any changed file with multiple match configurations', () => { 36 | const result = checkMatch(['docs/sources/developers/contribute.md', 'pkg/api/dataproxy.go'], { 37 | any: ['docs/**/*', 'contribute/**/*'], 38 | }) 39 | expect(result).toBeTruthy() 40 | }) 41 | }) 42 | 43 | describe('all', () => { 44 | it('should match all changed files', () => { 45 | const result = checkMatch( 46 | ['backend/backend.go', 'backend/a/b/c/c.go', 'src/app.ts', 'src/app.ts/a/b/c/c.ts'], 47 | { 48 | all: ['**'], 49 | }, 50 | ) 51 | expect(result).toBeTruthy() 52 | }) 53 | 54 | it('should not match all backend files', () => { 55 | const result = checkMatch( 56 | ['backend/backend.go', 'backend/a/b/c/c.go', 'src/app.ts', 'src/app.ts/a/b/c/c.ts'], 57 | { 58 | all: ['backend/**/*'], 59 | }, 60 | ) 61 | expect(result).toBeFalsy() 62 | }) 63 | 64 | it('should not match all docs files', () => { 65 | const result = checkMatch( 66 | ['backend/backend.go', 'backend/a/b/c/c.go', 'src/app.ts', 'src/app.ts/a/b/c/c.ts'], 67 | { 68 | all: ['docs/**/*'], 69 | }, 70 | ) 71 | expect(result).toBeFalsy() 72 | }) 73 | 74 | it('should match all changed files with multiple match configurations', () => { 75 | const result = checkMatch(['docs/sources/developers/contribute.md', 'contribute/readme.md'], { 76 | all: ['docs/**/*', 'contribute/**/*'], 77 | }) 78 | expect(result).toBeTruthy() 79 | }) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /common/globmatcher.ts: -------------------------------------------------------------------------------- 1 | import * as micromatch from 'micromatch' 2 | 3 | export interface MatchConfig { 4 | all?: string[] 5 | any?: string[] 6 | } 7 | 8 | function isMatch(input: string, matchers: ((str: string) => boolean)[]): boolean { 9 | for (const matcher of matchers) { 10 | if (matcher(input)) { 11 | return true 12 | } 13 | } 14 | 15 | return false 16 | } 17 | 18 | // equivalent to "Array.some()" but expanded for debugging and clarity 19 | function checkAny(inputs: string[], globs: string[]): boolean { 20 | const matchers = globs.map((g) => micromatch.matcher(g)) 21 | for (const changedFile of inputs) { 22 | if (isMatch(changedFile, matchers)) { 23 | return true 24 | } 25 | } 26 | 27 | return false 28 | } 29 | 30 | // equivalent to "Array.every()" but expanded for debugging and clarity 31 | function checkAll(inputs: string[], globs: string[]): boolean { 32 | const matchers = globs.map((g) => micromatch.matcher(g)) 33 | for (const changedFile of inputs) { 34 | if (!isMatch(changedFile, matchers)) { 35 | return false 36 | } 37 | } 38 | 39 | return true 40 | } 41 | 42 | export function checkMatch(inputs: string[], matchConfig: MatchConfig): boolean { 43 | if (matchConfig.all !== undefined) { 44 | if (!checkAll(inputs, matchConfig.all)) { 45 | return false 46 | } 47 | } 48 | 49 | if (matchConfig.any !== undefined) { 50 | if (!checkAny(inputs, matchConfig.any)) { 51 | return false 52 | } 53 | } 54 | 55 | return true 56 | } 57 | -------------------------------------------------------------------------------- /common/telemetry.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /*--------------------------------------------------------------------------------------------- 3 | * Copyright (c) Microsoft Corporation. All rights reserved. 4 | * Licensed under the MIT License. See LICENSE in the project root for license information. 5 | *--------------------------------------------------------------------------------------------*/ 6 | var __importDefault = (this && this.__importDefault) || function (mod) { 7 | return (mod && mod.__esModule) ? mod : { "default": mod }; 8 | }; 9 | Object.defineProperty(exports, "__esModule", { value: true }); 10 | exports.trackEvent = exports.aiHandle = void 0; 11 | const utils_1 = require("./utils"); 12 | const axios_1 = __importDefault(require("axios")); 13 | const github_1 = require("@actions/github"); 14 | exports.aiHandle = undefined; 15 | const apiKey = (0, utils_1.getInput)('metricsWriteAPIKey'); 16 | if (apiKey) { 17 | exports.aiHandle = { 18 | trackException: (arg) => { 19 | console.log('trackException', arg); 20 | }, 21 | trackMetric: (metric) => { 22 | console.log(`trackMetric ${metric.name} ${metric.value}`); 23 | const tags = []; 24 | if (metric.labels) { 25 | for (const key of Object.keys(metric.labels)) { 26 | const safeKey = key.replace(' ', '_').replace('/', '_'); 27 | const safeValue = metric.labels[key].replace(' ', '_').replace('/', '_'); 28 | tags.push(`${safeKey}=${safeValue}`); 29 | } 30 | } 31 | (0, axios_1.default)({ 32 | url: 'https://graphite-us-central1.grafana.net/metrics', 33 | method: 'POST', 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | }, 37 | auth: { 38 | username: '6371', 39 | password: apiKey, 40 | }, 41 | data: JSON.stringify([ 42 | { 43 | name: `${getMetricsNamePrefix()}.${metric.name}`, 44 | value: metric.value, 45 | interval: 60, 46 | mtype: metric.type ?? 'count', 47 | time: Math.floor(new Date().valueOf() / 1000), 48 | tags, 49 | }, 50 | ]), 51 | }).catch((e) => { 52 | console.log(e); 53 | }); 54 | }, 55 | }; 56 | } 57 | function getMetricsNamePrefix() { 58 | if (!github_1.context || github_1.context.repo.repo === 'grafana') { 59 | // this is for grafana repo, did not make this multi repo at the start and do not want to lose past metrics 60 | return 'gh_action'; 61 | } 62 | return `repo_stats.${github_1.context.repo.repo}`; 63 | } 64 | const trackEvent = async (issue, event, props) => { 65 | console.debug('tracking event', issue, event, props); 66 | }; 67 | exports.trackEvent = trackEvent; 68 | //# sourceMappingURL=telemetry.js.map -------------------------------------------------------------------------------- /common/telemetry.test.ts: -------------------------------------------------------------------------------- 1 | import { aiHandle } from './telemetry' 2 | 3 | describe('Telemetry', () => { 4 | it.skip('should work', () => { 5 | aiHandle!.trackMetric({ 6 | name: 'action-metric-test', 7 | value: 10, 8 | }) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /common/telemetry.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import { getInput } from './utils' 7 | import { GitHubIssue } from '../api/api' 8 | import axios from 'axios' 9 | import { context } from '@actions/github' 10 | 11 | export interface TelemetryClient { 12 | trackMetric: (metric: TelemetryMetric) => void 13 | trackException: (arg: any) => void 14 | } 15 | 16 | export interface TelemetryMetric { 17 | name: string 18 | value: number 19 | labels?: Record 20 | type?: string 21 | } 22 | 23 | export let aiHandle: TelemetryClient | undefined = undefined 24 | 25 | const apiKey = getInput('metricsWriteAPIKey') 26 | 27 | if (apiKey) { 28 | aiHandle = { 29 | trackException: (arg: any) => { 30 | console.log('trackException', arg) 31 | }, 32 | trackMetric: (metric: TelemetryMetric) => { 33 | console.log(`trackMetric ${metric.name} ${metric.value}`) 34 | 35 | const tags = [] 36 | 37 | if (metric.labels) { 38 | for (const key of Object.keys(metric.labels)) { 39 | const safeKey = key.replace(' ', '_').replace('/', '_') 40 | const safeValue = metric.labels[key].replace(' ', '_').replace('/', '_') 41 | tags.push(`${safeKey}=${safeValue}`) 42 | } 43 | } 44 | 45 | axios({ 46 | url: 'https://graphite-us-central1.grafana.net/metrics', 47 | method: 'POST', 48 | headers: { 49 | 'Content-Type': 'application/json', 50 | }, 51 | auth: { 52 | username: '6371', 53 | password: apiKey as string, 54 | }, 55 | data: JSON.stringify([ 56 | { 57 | name: `${getMetricsNamePrefix()}.${metric.name}`, 58 | value: metric.value, 59 | interval: 60, 60 | mtype: metric.type ?? 'count', 61 | time: Math.floor(new Date().valueOf() / 1000), 62 | tags, 63 | }, 64 | ]), 65 | }).catch((e) => { 66 | console.log(e) 67 | }) 68 | }, 69 | } 70 | } 71 | 72 | function getMetricsNamePrefix() { 73 | if (!context || context.repo.repo === 'grafana') { 74 | // this is for grafana repo, did not make this multi repo at the start and do not want to lose past metrics 75 | return 'gh_action' 76 | } 77 | return `repo_stats.${context.repo.repo}` 78 | } 79 | 80 | export const trackEvent = async (issue: GitHubIssue, event: string, props?: Record) => { 81 | console.debug('tracking event', issue, event, props) 82 | } 83 | -------------------------------------------------------------------------------- /common/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { getProjectIdFromUrl } from './utils' 2 | import { isPreRelease } from './utils' 3 | import { expect as jestExpect } from '@jest/globals' 4 | 5 | describe('GitHub Release Utils', () => { 6 | it('should be a pre-release if version is not stable', () => { 7 | //arrange 8 | const preReleaseVersionVariants = ['v1.2.3-beta', 'v9.4.2-test', 'v3.2.2-omgthisisnotarealrelease'] 9 | //act 10 | for (const version of preReleaseVersionVariants) { 11 | jestExpect(isPreRelease(version)).toBe(true) 12 | } 13 | }) 14 | 15 | it('should not be a pre-release if version is stable', () => { 16 | //arrange 17 | const preReleaseVersionVariants = ['v7.4.0', 'v9.4.2', 'v8.3.0'] 18 | //act 19 | for (const version of preReleaseVersionVariants) { 20 | jestExpect(isPreRelease(version)).toBe(false) 21 | } 22 | }) 23 | }) 24 | 25 | describe('General Utils', () => { 26 | it('should be a pre-release if version is not stable', () => { 27 | //arrange 28 | const url = 'https://github.com/orgs/grafana/projects/76' 29 | //act 30 | jestExpect(getProjectIdFromUrl(url)).toEqual(76) 31 | }) 32 | 33 | it('should not be a pre-release if version is stable', () => { 34 | //arrange 35 | const url = 'https://github.com/orgs/grafana/projects/76/views/2' 36 | //act 37 | jestExpect(getProjectIdFromUrl(url)).toEqual(76) 38 | }) 39 | 40 | it('should return null if project id is not readable', () => { 41 | //arrange 42 | const url = 'https://github.com/orgs/grafana/76/views/2' 43 | //act 44 | jestExpect(getProjectIdFromUrl(url)).toEqual(null) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /common/utils.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import * as core from '@actions/core' 7 | import { context, GitHub } from '@actions/github' 8 | import axios from 'axios' 9 | import { OctoKitIssue } from '../api/octokit' 10 | 11 | export const getInput = (name: string) => core.getInput(name) || undefined 12 | export const getRequiredInput = (name: string) => core.getInput(name, { required: true }) 13 | 14 | export const normalizeIssue = (issue: { 15 | body: string 16 | title: string 17 | }): { body: string; title: string; issueType: 'bug' | 'feature_request' | 'unknown' } => { 18 | let { body, title } = issue 19 | body = body ?? '' 20 | title = title ?? '' 21 | const isBug = body.includes('bug_report_template') || /Issue Type:.*Bug.*/.test(body) 22 | const isFeatureRequest = 23 | body.includes('feature_request_template') || /Issue Type:.*Feature Request.*/.test(body) 24 | 25 | const cleanse = (str: string) => { 26 | let out = str 27 | .toLowerCase() 28 | .replace(//gu, '') 29 | .replace(/.* version: .*/gu, '') 30 | .replace(/issue type: .*/gu, '') 31 | .replace(/vs ?code/gu, '') 32 | .replace(/we have written.*please paste./gu, '') 33 | .replace(/steps to reproduce:/gu, '') 34 | .replace(/does this issue occur when all extensions are disabled.*/gu, '') 35 | .replace(/!?\[[^\]]*\]\([^)]*\)/gu, '') 36 | .replace(/\s+/gu, ' ') 37 | .replace(/```[^`]*?```/gu, '') 38 | 39 | while ( 40 | out.includes(`
`) && 41 | out.includes('
') && 42 | out.indexOf(``) > out.indexOf(`
`) 43 | ) { 44 | out = out.slice(0, out.indexOf('
')) + out.slice(out.indexOf(`
`) + 10) 45 | } 46 | 47 | return out 48 | } 49 | 50 | return { 51 | body: cleanse(body), 52 | title: cleanse(title), 53 | issueType: isBug ? 'bug' : isFeatureRequest ? 'feature_request' : 'unknown', 54 | } 55 | } 56 | 57 | export interface Release { 58 | productVersion: string 59 | timestamp: number 60 | version: string 61 | } 62 | 63 | export const loadLatestRelease = async (quality: 'stable' | 'insider'): Promise => 64 | (await axios.get(`https://vscode-update.azurewebsites.net/api/update/darwin/${quality}/latest`)).data 65 | 66 | export const daysAgoToTimestamp = (days: number): number => +new Date(Date.now() - days * 24 * 60 * 60 * 1000) 67 | 68 | export const daysAgoToHumanReadbleDate = (days: number) => 69 | new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d{3}\w$/, '') 70 | 71 | export const getRateLimit = async (token: string) => { 72 | const usageData = (await new GitHub(token).rateLimit.get()).data.resources 73 | const usage = {} as { core: number; graphql: number; search: number } 74 | ;(['core', 'graphql', 'search'] as const).forEach(async (category) => { 75 | usage[category] = 1 - usageData[category].remaining / usageData[category].limit 76 | }) 77 | return usage 78 | } 79 | 80 | export const errorLoggingIssue = (() => { 81 | try { 82 | const repo = context.repo.owner.toLowerCase() + '/' + context.repo.repo.toLowerCase() 83 | if (repo === 'microsoft/vscode' || repo === 'microsoft/vscode-remote-release') { 84 | return { repo: 'vscode', owner: 'Microsoft', issue: 93814 } 85 | } else if (/microsoft\//.test(repo)) { 86 | return { repo: 'vscode-internalbacklog', owner: 'Microsoft', issue: 974 } 87 | } else if (getInput('errorLogIssueNumber')) { 88 | return { ...context.repo, issue: +getRequiredInput('errorLogIssueNumber') } 89 | } else { 90 | return undefined 91 | } 92 | } catch (e) { 93 | console.error(e) 94 | return undefined 95 | } 96 | })() 97 | 98 | export const logErrorToIssue = async (message: string, ping: boolean, token: string): Promise => { 99 | // Attempt to wait out abuse detection timeout if present 100 | await new Promise((resolve) => setTimeout(resolve, 10000)) 101 | const dest = errorLoggingIssue 102 | if (!dest) return console.log('no error logging repo defined. swallowing error:', message) 103 | 104 | return new OctoKitIssue(token, { owner: dest.owner, repo: dest.repo }, { number: dest.issue }) 105 | .postComment(` 106 | Workflow: ${context.workflow} 107 | 108 | Error: ${message} 109 | 110 | Issue: ${ping ? `${context.repo.owner}/${context.repo.repo}#` : ''}${context.issue.number} 111 | 112 | Repo: ${context.repo.owner}/${context.repo.repo} 113 | 114 | /gu, '--@>') 118 | .replace(/\/|\\/gu, 'slash-')} 119 | --> 120 | `) 121 | } 122 | 123 | export function splitStringIntoLines(content: string) { 124 | return content.split(/(?:\r\n|\r|\n)/g) 125 | } 126 | 127 | export const getProjectIdFromUrl = (url: string) => { 128 | const projectIdPattern = /(?<=projects\/)\d+/g 129 | const projectId = url.match(projectIdPattern) 130 | if (projectId) { 131 | return parseInt(projectId[0]) 132 | } 133 | return null 134 | } 135 | 136 | export const isPreRelease = (version: string) => { 137 | return !version.match(/[vV]{1}\d{1,3}\.\d{1,3}\.\d{1,3}$/g) 138 | } 139 | -------------------------------------------------------------------------------- /docs-target/action.yaml: -------------------------------------------------------------------------------- 1 | name: DocsTarget 2 | description: Extract the version from the given branch 3 | inputs: 4 | ref_name: 5 | description: Branch or tag name to extract from 6 | default: ${{ github.ref_name }} 7 | outputs: 8 | target: 9 | description: Technical documentation version for the given reference 10 | runs: 11 | using: 'node16' 12 | main: 'index.js' 13 | -------------------------------------------------------------------------------- /docs-target/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const core_1 = require("@actions/core"); 4 | const utils_1 = require("../common/utils"); 5 | const map_1 = require("./map"); 6 | try { 7 | var ref = (0, utils_1.getRequiredInput)('ref_name'); 8 | console.log('Input ref_name: ' + ref); 9 | let target = (0, map_1.map)(ref); 10 | console.log('Output target: ' + target); 11 | (0, core_1.setOutput)('target', target); 12 | } 13 | catch (error) { 14 | (0, core_1.setFailed)(error); 15 | } 16 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /docs-target/index.ts: -------------------------------------------------------------------------------- 1 | import { setOutput, setFailed } from '@actions/core' 2 | import { getRequiredInput } from '../common/utils' 3 | import { map } from './map' 4 | 5 | try { 6 | var ref = getRequiredInput('ref_name') 7 | console.log('Input ref_name: ' + ref) 8 | 9 | let target = map(ref) 10 | 11 | console.log('Output target: ' + target) 12 | setOutput('target', target) 13 | } catch (error: any) { 14 | setFailed(error) 15 | } 16 | -------------------------------------------------------------------------------- /docs-target/map.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.map = void 0; 4 | const semver_1 = require("semver"); 5 | let forcePrefix = 'v'; 6 | let knownRefs = new Map([ 7 | ['main', 'next'], 8 | ['master', 'next'], 9 | ]); 10 | // map the given git reference (branch or tag name) to the corresponding 11 | // documentation subfolder. 12 | // The output will be "vMajor.Minor". 13 | // The coercion performed is very permissive and most any reference will 14 | // result in some output. 15 | // All references that approximate semantic version, but deviate perhaps 16 | // by having a prefix, should be coerced into a reasonable output. 17 | function map(ref) { 18 | // Hard-coded mapping? 19 | if (knownRefs.has(ref)) { 20 | return knownRefs.get(ref); 21 | } 22 | var ver = (0, semver_1.coerce)(ref); 23 | if (ver == null) { 24 | throw 'ref_name invalid: ' + ref; 25 | } 26 | return forcePrefix + ver.major + '.' + ver.minor; 27 | } 28 | exports.map = map; 29 | //# sourceMappingURL=map.js.map -------------------------------------------------------------------------------- /docs-target/map.test.ts: -------------------------------------------------------------------------------- 1 | import { map } from './map' 2 | 3 | test('mappings', () => { 4 | expect(map('v1.2.3')).toBe('v1.2') 5 | expect(map('release-1.3')).toBe('v1.3') 6 | expect(map('release-1.4.0')).toBe('v1.4') 7 | expect(map('main')).toBe('next') 8 | expect(map('master')).toBe('next') 9 | expect(map('mimir-2.0.1')).toBe('v2.0') 10 | 11 | expect(() => map('foo')).toThrow() 12 | }) 13 | -------------------------------------------------------------------------------- /docs-target/map.ts: -------------------------------------------------------------------------------- 1 | import { coerce } from 'semver' 2 | 3 | let forcePrefix = 'v' 4 | let knownRefs = new Map([ 5 | ['main', 'next'], 6 | ['master', 'next'], 7 | ]) 8 | 9 | // map the given git reference (branch or tag name) to the corresponding 10 | // documentation subfolder. 11 | // The output will be "vMajor.Minor". 12 | // The coercion performed is very permissive and most any reference will 13 | // result in some output. 14 | // All references that approximate semantic version, but deviate perhaps 15 | // by having a prefix, should be coerced into a reasonable output. 16 | export function map(ref: string): string { 17 | // Hard-coded mapping? 18 | if (knownRefs.has(ref)) { 19 | return knownRefs.get(ref)! 20 | } 21 | 22 | var ver = coerce(ref) 23 | if (ver == null) { 24 | throw 'ref_name invalid: ' + ref 25 | } 26 | 27 | return forcePrefix + ver.major + '.' + ver.minor 28 | } 29 | -------------------------------------------------------------------------------- /enterprise-check/action.yml: -------------------------------------------------------------------------------- 1 | name: Enterprise check 2 | description: Check if an Enterprise branch exist for the OSS PR check, else create it 3 | inputs: 4 | token: 5 | description: GitHub token with branch read/write permissions 6 | default: ${{ github.token }} 7 | source_branch: 8 | description: Source branch of the OSS PR 9 | required: true 10 | target_branch: 11 | description: Target branch of the OSS PR 12 | pr_number: 13 | description: OSS PR number 14 | source_sha: 15 | description: the HEAD of the OSS PR 16 | runs: 17 | using: 'node20' 18 | main: 'index.js' 19 | -------------------------------------------------------------------------------- /enterprise-check/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const Action_1 = require("../common/Action"); 4 | const core_1 = require("@actions/core"); 5 | const request_error_1 = require("@octokit/request-error"); 6 | class EnterpriseCheck extends Action_1.Action { 7 | constructor() { 8 | super(...arguments); 9 | this.id = 'EnterpriseCheck'; 10 | } 11 | async createRef(octokit) { 12 | (0, core_1.debug)('Getting source branch from input...'); 13 | const sourceBranch = (0, core_1.getInput)('source_branch'); 14 | if (!sourceBranch) { 15 | throw new Error('Missing source branch'); 16 | } 17 | (0, core_1.debug)('Getting PR number from input...'); 18 | const prNumber = (0, core_1.getInput)('pr_number'); 19 | if (!prNumber) { 20 | throw new Error('Missing OSS PR number'); 21 | } 22 | (0, core_1.debug)('Getting source commit from input...'); 23 | const sourceSha = (0, core_1.getInput)('source_sha'); 24 | if (!sourceSha) { 25 | throw new Error('Missing OSS source SHA'); 26 | } 27 | (0, core_1.debug)('Getting branch ref from grafana enterprise...'); 28 | try { 29 | let branch = await getBranch(octokit, sourceBranch); 30 | if (branch) { 31 | // Create the branch from the ref found in grafana-enterprise. 32 | await createOrUpdateRef(octokit, prNumber, sourceBranch, branch.commit.sha, sourceSha); 33 | return; 34 | } 35 | } 36 | catch (err) { 37 | console.log('error fetching branch with same name in Enterprise', err); 38 | } 39 | (0, core_1.debug)("Branch in grafana enterprise doesn't exist, getting branch from 'target_branch' or 'main'..."); 40 | // If the source branch was not found on Enterprise, then attempt to use the targetBranch (likely something like v9.2.x). 41 | // If the targetBranch was not found, then use `main`. If `main` wasn't found, then we have a problem. 42 | const targetBranch = (0, core_1.getInput)('target_branch') || 'main'; 43 | try { 44 | const branch = await getBranch(octokit, targetBranch); 45 | if (branch) { 46 | // Create the branch from the ref found in grafana-enterprise. 47 | await createOrUpdateRef(octokit, prNumber, sourceBranch, branch.commit.sha, sourceSha); 48 | return; 49 | } 50 | } 51 | catch (err) { 52 | console.log(`error fetching ${targetBranch}:`, err); 53 | } 54 | try { 55 | const branch = await getBranch(octokit, 'main'); 56 | if (branch) { 57 | // Create the branch from the ref found in grafana-enterprise. 58 | await createOrUpdateRef(octokit, prNumber, sourceBranch, branch.commit.sha, sourceSha); 59 | return; 60 | } 61 | } 62 | catch (err) { 63 | console.log('error fetching main:', err); 64 | } 65 | throw new Error('Failed to create upstream ref; no branch was found. Not even main.'); 66 | } 67 | async onTriggered(octokit) { 68 | try { 69 | await this.createRef(octokit); 70 | } 71 | catch (err) { 72 | if (err instanceof Error) { 73 | (0, core_1.setFailed)(err); 74 | } 75 | } 76 | } 77 | } 78 | async function getBranch(octokit, branch) { 79 | let res; 80 | try { 81 | res = await octokit.octokit.repos.getBranch({ 82 | branch: branch, 83 | owner: 'grafana', 84 | repo: 'grafana-enterprise', 85 | }); 86 | return res.data; 87 | } 88 | catch (err) { 89 | console.log('Could not get branch from upstream:', err); 90 | throw err; 91 | } 92 | } 93 | async function createOrUpdateRef(octokit, prNumber, branch, sha, sourceSha) { 94 | const ref = `refs/heads/prc-${prNumber}-${sourceSha}/${branch}`; 95 | (0, core_1.debug)(`Creating ref in grafana-enterprise: '${ref}'`); 96 | try { 97 | await octokit.octokit.git.createRef({ 98 | owner: 'grafana', 99 | repo: 'grafana-enterprise', 100 | ref: ref, 101 | sha: sha, 102 | }); 103 | } 104 | catch (err) { 105 | if (err instanceof request_error_1.RequestError && err.message === 'Reference already exists') { 106 | await octokit.octokit.git.updateRef({ 107 | owner: 'grafana', 108 | repo: 'grafana-enterprise', 109 | ref: ref, 110 | sha: sha, 111 | force: true, 112 | }); 113 | } 114 | else { 115 | throw err; 116 | } 117 | } 118 | } 119 | new EnterpriseCheck().run(); // eslint-disable-line 120 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /enterprise-check/index.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '../common/Action' 2 | import { Octokit } from '@octokit/rest' 3 | import { OctoKit } from '../api/octokit' 4 | import { getInput, setFailed, debug } from '@actions/core' 5 | import { RequestError } from '@octokit/request-error' 6 | 7 | class EnterpriseCheck extends Action { 8 | id = 'EnterpriseCheck' 9 | 10 | async createRef(octokit: OctoKit) { 11 | debug('Getting source branch from input...') 12 | const sourceBranch = getInput('source_branch') 13 | if (!sourceBranch) { 14 | throw new Error('Missing source branch') 15 | } 16 | 17 | debug('Getting PR number from input...') 18 | const prNumber = getInput('pr_number') 19 | if (!prNumber) { 20 | throw new Error('Missing OSS PR number') 21 | } 22 | 23 | debug('Getting source commit from input...') 24 | const sourceSha = getInput('source_sha') 25 | if (!sourceSha) { 26 | throw new Error('Missing OSS source SHA') 27 | } 28 | 29 | debug('Getting branch ref from grafana enterprise...') 30 | try { 31 | let branch = await getBranch(octokit, sourceBranch) 32 | if (branch) { 33 | // Create the branch from the ref found in grafana-enterprise. 34 | await createOrUpdateRef(octokit, prNumber, sourceBranch, branch.commit.sha, sourceSha) 35 | return 36 | } 37 | } catch (err) { 38 | console.log('error fetching branch with same name in Enterprise', err) 39 | } 40 | 41 | debug("Branch in grafana enterprise doesn't exist, getting branch from 'target_branch' or 'main'...") 42 | // If the source branch was not found on Enterprise, then attempt to use the targetBranch (likely something like v9.2.x). 43 | // If the targetBranch was not found, then use `main`. If `main` wasn't found, then we have a problem. 44 | 45 | const targetBranch = getInput('target_branch') || 'main' 46 | try { 47 | const branch = await getBranch(octokit, targetBranch) 48 | if (branch) { 49 | // Create the branch from the ref found in grafana-enterprise. 50 | await createOrUpdateRef(octokit, prNumber, sourceBranch, branch.commit.sha, sourceSha) 51 | return 52 | } 53 | } catch (err) { 54 | console.log(`error fetching ${targetBranch}:`, err) 55 | } 56 | 57 | try { 58 | const branch = await getBranch(octokit, 'main') 59 | if (branch) { 60 | // Create the branch from the ref found in grafana-enterprise. 61 | await createOrUpdateRef(octokit, prNumber, sourceBranch, branch.commit.sha, sourceSha) 62 | return 63 | } 64 | } catch (err) { 65 | console.log('error fetching main:', err) 66 | } 67 | 68 | throw new Error('Failed to create upstream ref; no branch was found. Not even main.') 69 | } 70 | 71 | async onTriggered(octokit: OctoKit) { 72 | try { 73 | await this.createRef(octokit) 74 | } catch (err) { 75 | if (err instanceof Error) { 76 | setFailed(err) 77 | } 78 | } 79 | } 80 | } 81 | 82 | async function getBranch(octokit: OctoKit, branch: string): Promise { 83 | let res: Octokit.Response 84 | try { 85 | res = await octokit.octokit.repos.getBranch({ 86 | branch: branch, 87 | owner: 'grafana', 88 | repo: 'grafana-enterprise', 89 | }) 90 | 91 | return res.data 92 | } catch (err) { 93 | console.log('Could not get branch from upstream:', err) 94 | throw err 95 | } 96 | } 97 | 98 | async function createOrUpdateRef( 99 | octokit: OctoKit, 100 | prNumber: string, 101 | branch: string, 102 | sha: string, 103 | sourceSha: string, 104 | ) { 105 | const ref = `refs/heads/prc-${prNumber}-${sourceSha}/${branch}` 106 | debug(`Creating ref in grafana-enterprise: '${ref}'`) 107 | 108 | try { 109 | await octokit.octokit.git.createRef({ 110 | owner: 'grafana', 111 | repo: 'grafana-enterprise', 112 | ref: ref, 113 | sha: sha, 114 | }) 115 | } catch (err) { 116 | if (err instanceof RequestError && err.message === 'Reference already exists') { 117 | await octokit.octokit.git.updateRef({ 118 | owner: 'grafana', 119 | repo: 'grafana-enterprise', 120 | ref: ref, 121 | sha: sha, 122 | force: true, 123 | }) 124 | } else { 125 | throw err 126 | } 127 | } 128 | } 129 | 130 | new EnterpriseCheck().run() // eslint-disable-line 131 | -------------------------------------------------------------------------------- /github-release/action.yml: -------------------------------------------------------------------------------- 1 | name: GitHub release 2 | description: Creates and updates GitHub releases 3 | inputs: 4 | token: 5 | description: GitHub token with issue, comment, and label read/write permissions 6 | default: ${{ github.token }} 7 | metricsWriteAPIKey: 8 | description: Grafana Cloud metrics api key 9 | required: false 10 | runs: 11 | using: 'node20' 12 | main: 'index.js' 13 | -------------------------------------------------------------------------------- /github-release/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const github_1 = require("@actions/github"); 4 | const Action_1 = require("../common/Action"); 5 | const utils_1 = require("../common/utils"); 6 | const request_error_1 = require("@octokit/request-error"); 7 | const ChangelogBuilder_1 = require("../update-changelog/ChangelogBuilder"); 8 | class GitHubRelease extends Action_1.Action { 9 | constructor() { 10 | super(...arguments); 11 | this.id = 'GitHubRelease'; 12 | } 13 | async onTriggered(octokit) { 14 | const { owner, repo } = github_1.context.repo; 15 | const payload = github_1.context.payload; 16 | const version = payload.inputs.version; 17 | if (!version) { 18 | throw new Error('Missing version input'); 19 | } 20 | const builder = new ChangelogBuilder_1.ChangelogBuilder(octokit, version); 21 | const tag = `v${version}`; 22 | const notes = await builder.buildChangelog({ noHeader: true }); 23 | const title = builder.getTitle(); 24 | const content = ` 25 | [Download page](https://grafana.com/grafana/download/${version}) 26 | [What's new highlights](https://grafana.com/docs/grafana/latest/whatsnew/) 27 | 28 | 29 | ${notes} 30 | `; 31 | try { 32 | const existingRelease = await octokit.octokit.repos.getReleaseByTag({ 33 | repo, 34 | owner, 35 | tag, 36 | }); 37 | console.log('Updating github release'); 38 | await octokit.octokit.repos.updateRelease({ 39 | draft: existingRelease.data.draft, 40 | release_id: existingRelease.data.id, 41 | repo, 42 | owner, 43 | name: title, 44 | body: content, 45 | tag_name: tag, 46 | }); 47 | } 48 | catch (err) { 49 | if (err instanceof request_error_1.RequestError && err.status !== 404) { 50 | console.log('getReleaseByTag error', err); 51 | } 52 | console.log('Creating github release'); 53 | await octokit.octokit.repos.createRelease({ 54 | repo, 55 | owner, 56 | name: title, 57 | body: content, 58 | tag_name: tag, 59 | prerelease: (0, utils_1.isPreRelease)(tag), 60 | }); 61 | } 62 | } 63 | } 64 | new GitHubRelease().run(); // eslint-disable-line 65 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /github-release/index.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import { Action } from '../common/Action' 3 | import { OctoKit } from '../api/octokit' 4 | import { EventPayloads } from '@octokit/webhooks' 5 | import { isPreRelease } from '../common/utils' 6 | import { RequestError } from '@octokit/request-error' 7 | import { ChangelogBuilder } from '../update-changelog/ChangelogBuilder' 8 | 9 | class GitHubRelease extends Action { 10 | id = 'GitHubRelease' 11 | 12 | async onTriggered(octokit: OctoKit) { 13 | const { owner, repo } = context.repo 14 | const payload = context.payload as EventPayloads.WebhookPayloadWorkflowDispatch 15 | const version = (payload.inputs as any).version 16 | 17 | if (!version) { 18 | throw new Error('Missing version input') 19 | } 20 | 21 | const builder = new ChangelogBuilder(octokit, version) 22 | const tag = `v${version}` 23 | const notes = await builder.buildChangelog({ noHeader: true }) 24 | const title = builder.getTitle() 25 | const content = ` 26 | [Download page](https://grafana.com/grafana/download/${version}) 27 | [What's new highlights](https://grafana.com/docs/grafana/latest/whatsnew/) 28 | 29 | 30 | ${notes} 31 | ` 32 | try { 33 | const existingRelease = await octokit.octokit.repos.getReleaseByTag({ 34 | repo, 35 | owner, 36 | tag, 37 | }) 38 | 39 | console.log('Updating github release') 40 | 41 | await octokit.octokit.repos.updateRelease({ 42 | draft: existingRelease.data.draft, 43 | release_id: existingRelease.data.id, 44 | repo, 45 | owner, 46 | name: title, 47 | body: content, 48 | tag_name: tag, 49 | }) 50 | } catch (err) { 51 | if (err instanceof RequestError && err.status !== 404) { 52 | console.log('getReleaseByTag error', err) 53 | } 54 | 55 | console.log('Creating github release') 56 | 57 | await octokit.octokit.repos.createRelease({ 58 | repo, 59 | owner, 60 | name: title, 61 | body: content, 62 | tag_name: tag, 63 | prerelease: isPreRelease(tag), 64 | }) 65 | } 66 | } 67 | } 68 | 69 | new GitHubRelease().run() // eslint-disable-line 70 | -------------------------------------------------------------------------------- /has-matching-release-tag/action.yaml: -------------------------------------------------------------------------------- 1 | name: 'Has matching release tag' 2 | description: | 3 | Produce a boolean output that has the value "true" if and only if there is a release tag that 4 | matches the release branch. 5 | 6 | If the provided `ref_name` is a release tag reference, the output value is "true". 7 | If the provided `ref_name` is a release branch reference, the output value is "true" if 8 | and only if there is a corresponding release tag as defined by the `release_tag_regexp`. 9 | 10 | For release branches without a patch version, the capture groups for major and minor 11 | versions must match between the `release_branch_without_patch_regexp` and the 12 | `release_tag_regexp` and the release tag must have a `0` patch version. 13 | 14 | For release branches with a patch version, the capture groups for major, minor, and patch 15 | versions must match between the `release_branch_with_patch_regexp` and the 16 | `release_tag_regexp`. 17 | 18 | inputs: 19 | ref_name: 20 | description: 'Branch or tag name' 21 | default: '${{ github.ref_name }}' 22 | release_tag_regexp: 23 | description: | 24 | Regexp to match release tag references. 25 | Must be an escaped string suitable for passing to new RegExp (https://developer.mozilla.org/en-US/docs/web/javascript/reference/global_objects/regexp). 26 | The regexp must have three capture groups, one for each of major, minor, and patch versions. 27 | For example: "^v(\\d+)\\.(\\d+)\\.(\\d+)$" 28 | release_branch_regexp: 29 | description: | 30 | Regexp to match release branches that do not include a patch version. 31 | Must be an escaped string suitable for passing to new RegExp (https://developer.mozilla.org/en-US/docs/web/javascript/reference/global_objects/regexp). 32 | The regexp must have two capture groups, one for each of major and minor versions. 33 | For example: "^release-(\\d+)\\.(\\d+)$" 34 | release_branch_with_patch_regexp: 35 | description: | 36 | Regexp to match release branches that include a patch version. 37 | Must be an escaped string suitable for passing to new RegExp (https://developer.mozilla.org/en-US/docs/web/javascript/reference/global_objects/regexp). 38 | The regexp must have three capture groups, one for each of major, minor, and patch versions. 39 | For example: "^release-(\\d+)\\.(\\d+)\\.(\\d+)$" 40 | Can be omitted if not required. 41 | outputs: 42 | bool: 43 | description: 'Boolean represented by either the string value "true" or "false".' 44 | runs: 45 | using: 'node16' 46 | main: 'index.js' 47 | -------------------------------------------------------------------------------- /has-matching-release-tag/hasMatchingReleaseTag.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | Object.defineProperty(exports, "__esModule", { value: true }); 26 | exports.hasMatchingReleaseTagWithRefNames = exports.filterRefNames = exports.hasMatchingReleaseTag = void 0; 27 | const core = __importStar(require("@actions/core")); 28 | const child_process_1 = require("child_process"); 29 | const utils_1 = require("../common/utils"); 30 | function hasMatchingReleaseTag(refName, releaseTagRegexp, releaseBranchRegexp, releaseBranchWithPatchRegexp) { 31 | let refNames = (0, utils_1.splitStringIntoLines)((0, child_process_1.execFileSync)('git', ['tag'], { encoding: 'utf8' })).filter((e) => e); 32 | if (refNames.length == 0) { 33 | core.warning('No tags found. Is there an `actions/checkout` step with `fetch-depth: 0` before this action? https://github.com/actions/checkout#fetch-all-history-for-all-tags-and-branches'); 34 | } 35 | core.debug(`Found the following references:\n${refNames.join('\n')}`); 36 | return hasMatchingReleaseTagWithRefNames(refNames, refName, releaseTagRegexp, releaseBranchRegexp, releaseBranchWithPatchRegexp); 37 | } 38 | exports.hasMatchingReleaseTag = hasMatchingReleaseTag; 39 | function filterRefNames(refNames, regexp) { 40 | return refNames.filter((name) => name.match(regexp)); 41 | } 42 | exports.filterRefNames = filterRefNames; 43 | // hasMatchingReleaseTagWithRefNames returns either the string "true" or "false". 44 | // "true" is returned for each of the following cases: 45 | // - releaseTagRegexp matches refName and is therefore a release tag reference name. 46 | // - releaseBranchRegexp matches refName and there is a corresponding reference name in 47 | // refNames that matched by releaseTagRegexp. For a reference name to be corresponding, 48 | // it must share the major and minor versions with refName, and it must have a '0' 49 | // patch version. 50 | // - releaseBranchWithPatchRegexp is defined, matches refName, and there is a corresponding 51 | // reference name in matched by releaseTagRegexp. For a reference name to be corresponding, 52 | // it must share the major, minor, and patch versions with the refName. 53 | // Otherwise, the function returns "false". 54 | function hasMatchingReleaseTagWithRefNames(refNames, refName, releaseTagRegexp, releaseBranchRegexp, releaseBranchWithPatchRegexp) { 55 | if (refName.match(releaseTagRegexp)) { 56 | core.notice(`Reference name is a release tag`); 57 | return 'true'; 58 | } 59 | let releaseTags = filterRefNames(refNames, releaseTagRegexp); 60 | core.debug(`The following release tags match the release tag regular expression ${releaseTagRegexp}:\n${releaseTags.join('\n')}`); 61 | let branchMatches = refName.match(releaseBranchRegexp); 62 | if (branchMatches) { 63 | for (let i = 0; i < releaseTags.length; i++) { 64 | let tagMatches = releaseTags[i].match(releaseTagRegexp); 65 | if (tagMatches && 66 | tagMatches[1] == branchMatches[1] && 67 | tagMatches[2] == branchMatches[2] && 68 | tagMatches[3].match(new RegExp('0|[1-9]d*'))) { 69 | core.notice(`Found corresponding release tag for branch '${refName}': '${releaseTags[i]}'`); 70 | return 'true'; 71 | } 72 | } 73 | } 74 | if (releaseBranchWithPatchRegexp) { 75 | branchMatches = refName.match(releaseBranchWithPatchRegexp); 76 | if (branchMatches) { 77 | for (let i = 0; i < releaseTags.length; i++) { 78 | let tagMatches = releaseTags[i].match(releaseTagRegexp); 79 | if (tagMatches && 80 | tagMatches[1] == branchMatches[1] && 81 | tagMatches[2] == branchMatches[2] && 82 | tagMatches[3] == branchMatches[3]) { 83 | core.notice(`Found corresponding release tag for branch '${refName}': '${releaseTags[i]}'`); 84 | return 'true'; 85 | } 86 | } 87 | } 88 | } 89 | core.notice(`Did not find a corresponding release tag for reference '${refName}'`); 90 | return 'false'; 91 | } 92 | exports.hasMatchingReleaseTagWithRefNames = hasMatchingReleaseTagWithRefNames; 93 | //# sourceMappingURL=hasMatchingReleaseTag.js.map -------------------------------------------------------------------------------- /has-matching-release-tag/hasMatchingReleaseTag.test.ts: -------------------------------------------------------------------------------- 1 | import { filterRefNames, hasMatchingReleaseTagWithRefNames } from './hasMatchingReleaseTag' 2 | 3 | test('filterRefNames', () => { 4 | expect( 5 | filterRefNames( 6 | ['v1.0.0', 'v1.0.1', 'release-1.0.0', 'release-1.0.1', 'not-release'], 7 | new RegExp('^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 8 | ), 9 | ).toStrictEqual(['v1.0.0', 'v1.0.1']) 10 | }) 11 | 12 | test('hasMatchingReleaseTagWithRefNames', () => { 13 | expect( 14 | hasMatchingReleaseTagWithRefNames( 15 | ['v1.0.0', 'v1.0.1', 'release-1.0.0', 'release-1.0.1', 'not-release'], 16 | 'release-1.0', 17 | new RegExp('^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 18 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 19 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 20 | ), 21 | ).toBe('true') 22 | 23 | // Not providing the releaseBranchWithPatch regexp. 24 | expect( 25 | hasMatchingReleaseTagWithRefNames( 26 | ['v1.0.0', 'v1.0.1', 'release-1.0.0', 'release-1.0.1', 'not-release'], 27 | 'release-1.0', 28 | new RegExp('^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 29 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 30 | ), 31 | ).toBe('true') 32 | 33 | expect( 34 | hasMatchingReleaseTagWithRefNames( 35 | ['v1.0.0', 'v1.0.1', 'release-1.0.0', 'release-1.0.1', 'not-release'], 36 | 'v1.0.0', 37 | new RegExp('^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 38 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 39 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 40 | ), 41 | ).toBe('true') 42 | 43 | expect( 44 | hasMatchingReleaseTagWithRefNames( 45 | ['v1.0.0', 'v1.0.1', 'release-1.0.0', 'release-1.0.1', 'not-release'], 46 | 'release-1.0.1', 47 | new RegExp('^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 48 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 49 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 50 | ), 51 | ).toBe('true') 52 | 53 | expect( 54 | hasMatchingReleaseTagWithRefNames( 55 | ['v1.0.0', 'v1.0.1', 'release-1.0.0', 'release-1.0.1', 'not-release'], 56 | 'release-1.1', 57 | new RegExp('^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 58 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 59 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 60 | ), 61 | ).toBe('false') 62 | 63 | expect( 64 | hasMatchingReleaseTagWithRefNames( 65 | ['v1.0.0', 'v1.0.1', 'release-1.0.0', 'release-1.0.1', 'not-release'], 66 | 'release-2.0.0', 67 | new RegExp('^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 68 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 69 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 70 | ), 71 | ).toBe('false') 72 | 73 | // Missing the `.0` patch version. 74 | // Although semver requires that every bump of a major or minor version resets the patch version, 75 | // it is not always the case that the tag will exist in the repository. 76 | // For example, the first release might be made privately in a fork and might never be made public 77 | // because of bugs or security concerns. 78 | expect( 79 | hasMatchingReleaseTagWithRefNames( 80 | ['v1.0.1'], 81 | 'release-1.0', 82 | new RegExp('^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 83 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 84 | ), 85 | ).toBe('true') 86 | // Don't allow release candidates to be considered "proper" releases. 87 | expect( 88 | hasMatchingReleaseTagWithRefNames( 89 | ['v1.0.1-rc.1'], 90 | 'release-1.0', 91 | new RegExp('^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 92 | new RegExp('^release-(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$'), 93 | ), 94 | ).toBe('false') 95 | }) 96 | -------------------------------------------------------------------------------- /has-matching-release-tag/hasMatchingReleaseTag.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { execFileSync } from 'child_process' 3 | import { splitStringIntoLines } from '../common/utils' 4 | 5 | export function hasMatchingReleaseTag( 6 | refName: string, 7 | releaseTagRegexp: RegExp, 8 | releaseBranchRegexp: RegExp, 9 | releaseBranchWithPatchRegexp: RegExp | undefined, 10 | ): string { 11 | let refNames = splitStringIntoLines(execFileSync('git', ['tag'], { encoding: 'utf8' })).filter((e) => e) 12 | 13 | if (refNames.length == 0) { 14 | core.warning( 15 | 'No tags found. Is there an `actions/checkout` step with `fetch-depth: 0` before this action? https://github.com/actions/checkout#fetch-all-history-for-all-tags-and-branches', 16 | ) 17 | } 18 | 19 | core.debug(`Found the following references:\n${refNames.join('\n')}`) 20 | 21 | return hasMatchingReleaseTagWithRefNames( 22 | refNames, 23 | refName, 24 | releaseTagRegexp, 25 | releaseBranchRegexp, 26 | releaseBranchWithPatchRegexp, 27 | ) 28 | } 29 | 30 | export function filterRefNames(refNames: Array, regexp: RegExp): Array { 31 | return refNames.filter((name) => name.match(regexp)) 32 | } 33 | 34 | // hasMatchingReleaseTagWithRefNames returns either the string "true" or "false". 35 | // "true" is returned for each of the following cases: 36 | // - releaseTagRegexp matches refName and is therefore a release tag reference name. 37 | // - releaseBranchRegexp matches refName and there is a corresponding reference name in 38 | // refNames that matched by releaseTagRegexp. For a reference name to be corresponding, 39 | // it must share the major and minor versions with refName, and it must have a '0' 40 | // patch version. 41 | // - releaseBranchWithPatchRegexp is defined, matches refName, and there is a corresponding 42 | // reference name in matched by releaseTagRegexp. For a reference name to be corresponding, 43 | // it must share the major, minor, and patch versions with the refName. 44 | // Otherwise, the function returns "false". 45 | export function hasMatchingReleaseTagWithRefNames( 46 | refNames: Array, 47 | refName: string, 48 | releaseTagRegexp: RegExp, 49 | releaseBranchRegexp: RegExp, 50 | releaseBranchWithPatchRegexp: RegExp | undefined, 51 | ): string { 52 | if (refName.match(releaseTagRegexp)) { 53 | core.notice(`Reference name is a release tag`) 54 | 55 | return 'true' 56 | } 57 | 58 | let releaseTags = filterRefNames(refNames, releaseTagRegexp) 59 | 60 | core.debug( 61 | `The following release tags match the release tag regular expression ${releaseTagRegexp}:\n${releaseTags.join('\n')}`, 62 | ) 63 | 64 | let branchMatches = refName.match(releaseBranchRegexp) 65 | if (branchMatches) { 66 | for (let i = 0; i < releaseTags.length; i++) { 67 | let tagMatches = releaseTags[i].match(releaseTagRegexp) 68 | if ( 69 | tagMatches && 70 | tagMatches[1] == branchMatches[1] && 71 | tagMatches[2] == branchMatches[2] && 72 | tagMatches[3].match(new RegExp('0|[1-9]d*')) 73 | ) { 74 | core.notice(`Found corresponding release tag for branch '${refName}': '${releaseTags[i]}'`) 75 | 76 | return 'true' 77 | } 78 | } 79 | } 80 | 81 | if (releaseBranchWithPatchRegexp) { 82 | branchMatches = refName.match(releaseBranchWithPatchRegexp) 83 | if (branchMatches) { 84 | for (let i = 0; i < releaseTags.length; i++) { 85 | let tagMatches = releaseTags[i].match(releaseTagRegexp) 86 | if ( 87 | tagMatches && 88 | tagMatches[1] == branchMatches[1] && 89 | tagMatches[2] == branchMatches[2] && 90 | tagMatches[3] == branchMatches[3] 91 | ) { 92 | core.notice( 93 | `Found corresponding release tag for branch '${refName}': '${releaseTags[i]}'`, 94 | ) 95 | 96 | return 'true' 97 | } 98 | } 99 | } 100 | } 101 | 102 | core.notice(`Did not find a corresponding release tag for reference '${refName}'`) 103 | 104 | return 'false' 105 | } 106 | -------------------------------------------------------------------------------- /has-matching-release-tag/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | var desc = Object.getOwnPropertyDescriptor(m, k); 5 | if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { 6 | desc = { enumerable: true, get: function() { return m[k]; } }; 7 | } 8 | Object.defineProperty(o, k2, desc); 9 | }) : (function(o, m, k, k2) { 10 | if (k2 === undefined) k2 = k; 11 | o[k2] = m[k]; 12 | })); 13 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 14 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 15 | }) : function(o, v) { 16 | o["default"] = v; 17 | }); 18 | var __importStar = (this && this.__importStar) || function (mod) { 19 | if (mod && mod.__esModule) return mod; 20 | var result = {}; 21 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 22 | __setModuleDefault(result, mod); 23 | return result; 24 | }; 25 | Object.defineProperty(exports, "__esModule", { value: true }); 26 | const core = __importStar(require("@actions/core")); 27 | const utils_1 = require("../common/utils"); 28 | const hasMatchingReleaseTag_1 = require("./hasMatchingReleaseTag"); 29 | function prefixLines(prefix, lines) { 30 | return lines.map((l) => `${prefix}${l}`); 31 | } 32 | try { 33 | const refName = (0, utils_1.getRequiredInput)('ref_name'); 34 | const withPath = (0, utils_1.getInput)('release_branch_with_patch_regexp'); 35 | core.info('Input ref_name: ' + refName); 36 | const hasMatchingBool = (0, hasMatchingReleaseTag_1.hasMatchingReleaseTag)(refName, new RegExp((0, utils_1.getRequiredInput)('release_tag_regexp')), new RegExp((0, utils_1.getRequiredInput)('release_branch_regexp')), withPath ? new RegExp(withPath) : undefined); 37 | core.info('Output bool: ' + hasMatchingBool); 38 | core.setOutput('bool', hasMatchingBool); 39 | } 40 | catch (error) { 41 | // Failed to spawn child process from execFileSync call. 42 | if (error.code) { 43 | core.setFailed(error.code); 44 | } 45 | // Child was spawned but exited with non-zero exit code. 46 | if (error.stdout || error.stderr) { 47 | const { stdout, stderr } = error; 48 | core.setFailed(prefixLines('stdout: ', (0, utils_1.splitStringIntoLines)(stdout)) 49 | .concat(prefixLines('stderr: ', (0, utils_1.splitStringIntoLines)(stderr))) 50 | .join('\n')); 51 | } 52 | // Some other error was thrown. 53 | core.setFailed(error); 54 | } 55 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /has-matching-release-tag/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { getInput, getRequiredInput, splitStringIntoLines } from '../common/utils' 3 | import { hasMatchingReleaseTag } from './hasMatchingReleaseTag' 4 | 5 | function prefixLines(prefix: string, lines: Array): Array { 6 | return lines.map((l) => `${prefix}${l}`) 7 | } 8 | 9 | try { 10 | const refName = getRequiredInput('ref_name') 11 | const withPath = getInput('release_branch_with_patch_regexp') 12 | 13 | core.info('Input ref_name: ' + refName) 14 | 15 | const hasMatchingBool = hasMatchingReleaseTag( 16 | refName, 17 | new RegExp(getRequiredInput('release_tag_regexp')), 18 | new RegExp(getRequiredInput('release_branch_regexp')), 19 | withPath ? new RegExp(withPath) : undefined, 20 | ) 21 | 22 | core.info('Output bool: ' + hasMatchingBool) 23 | 24 | core.setOutput('bool', hasMatchingBool) 25 | } catch (error: any) { 26 | // Failed to spawn child process from execFileSync call. 27 | if (error.code) { 28 | core.setFailed(error.code) 29 | } 30 | 31 | // Child was spawned but exited with non-zero exit code. 32 | if (error.stdout || error.stderr) { 33 | const { stdout, stderr } = error 34 | core.setFailed( 35 | prefixLines('stdout: ', splitStringIntoLines(stdout)) 36 | .concat(prefixLines('stderr: ', splitStringIntoLines(stderr))) 37 | .join('\n'), 38 | ) 39 | } 40 | 41 | // Some other error was thrown. 42 | core.setFailed(error) 43 | } 44 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: false, 3 | transform: { 4 | '^.+\\.(ts|tsx|js|jsx)$': 'ts-jest', 5 | }, 6 | moduleDirectories: ['node_modules'], 7 | testRegex: '(\\.|/)(test)\\.(jsx?|tsx?)$', 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 9 | globals: { 'ts-jest': { isolatedModules: true } }, 10 | watchPathIgnorePatterns: ['/node_modules/'], 11 | } 12 | -------------------------------------------------------------------------------- /metrics-collector/action.yml: -------------------------------------------------------------------------------- 1 | name: Metrics collector 2 | description: Records metrics on issue open & close 3 | inputs: 4 | token: 5 | description: GitHub token with issue, comment, and label read/write permissions 6 | default: ${{ github.token }} 7 | configPath: 8 | description: Name of .json file (no extension) in .github/ directory of repo holding configuration for this action 9 | metricsWriteAPIKey: 10 | description: Grafana Cloud metrics api key 11 | required: false 12 | runs: 13 | using: 'node20' 14 | main: 'index.js' 15 | -------------------------------------------------------------------------------- /metrics-collector/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const utils_1 = require("../common/utils"); 4 | const Action_1 = require("../common/Action"); 5 | const telemetry_1 = require("../common/telemetry"); 6 | class MetricsCollector extends Action_1.Action { 7 | constructor() { 8 | super(...arguments); 9 | this.id = 'MetricsCollector'; 10 | } 11 | async onClosed(issue) { 12 | const issueData = await issue.getIssue(); 13 | const typeLabel = issueData.labels.find((label) => label.startsWith('type/')); 14 | const labels = {}; 15 | if (typeLabel) { 16 | labels['type'] = typeLabel.substr(5); 17 | } 18 | telemetry_1.aiHandle?.trackMetric({ name: 'issue.closed_count', value: 1, labels }); 19 | } 20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 | async onOpened(_issue) { 22 | telemetry_1.aiHandle?.trackMetric({ name: 'issue.opened_count', value: 1 }); 23 | } 24 | async onTriggered(octokit) { 25 | const repo = await octokit.getRepoInfo(); 26 | telemetry_1.aiHandle?.trackMetric({ name: 'repo.stargazers', value: repo.data.stargazers_count, type: 'gauge' }); 27 | telemetry_1.aiHandle?.trackMetric({ name: 'repo.watchers', value: repo.data.watchers_count, type: 'gauge' }); 28 | telemetry_1.aiHandle?.trackMetric({ name: 'repo.size', value: repo.data.size, type: 'gauge' }); 29 | telemetry_1.aiHandle?.trackMetric({ name: 'repo.forks', value: repo.data.forks_count, type: 'gauge' }); 30 | telemetry_1.aiHandle?.trackMetric({ 31 | name: 'repo.open_issues_count', 32 | value: repo.data.open_issues_count, 33 | type: 'gauge', 34 | }); 35 | const configPath = (0, utils_1.getInput)('configPath'); 36 | if (!configPath) { 37 | return; 38 | } 39 | const config = (await octokit.readConfig(configPath)); 40 | for (const query of config.queries) { 41 | await this.countQuery(query.name, query.query, octokit); 42 | } 43 | } 44 | async countQuery(name, query, octokit) { 45 | let count = 0; 46 | for await (const page of octokit.query({ q: query })) { 47 | count += page.length; 48 | } 49 | telemetry_1.aiHandle?.trackMetric({ 50 | name: `issue_query.${name}.gauge`, 51 | value: count, 52 | type: 'gauge', 53 | }); 54 | } 55 | } 56 | new MetricsCollector().run(); // eslint-disable-line 57 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /metrics-collector/index.ts: -------------------------------------------------------------------------------- 1 | import { OctoKitIssue, OctoKit } from '../api/octokit' 2 | import { getInput } from '../common/utils' 3 | import { Action } from '../common/Action' 4 | import { aiHandle } from '../common/telemetry' 5 | 6 | class MetricsCollector extends Action { 7 | id = 'MetricsCollector' 8 | 9 | async onClosed(issue: OctoKitIssue) { 10 | const issueData = await issue.getIssue() 11 | 12 | const typeLabel = issueData.labels.find((label) => label.startsWith('type/')) 13 | const labels: Record = {} 14 | 15 | if (typeLabel) { 16 | labels['type'] = typeLabel.substr(5) 17 | } 18 | 19 | aiHandle?.trackMetric({ name: 'issue.closed_count', value: 1, labels }) 20 | } 21 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 22 | async onOpened(_issue: OctoKitIssue) { 23 | aiHandle?.trackMetric({ name: 'issue.opened_count', value: 1 }) 24 | } 25 | 26 | async onTriggered(octokit: OctoKit) { 27 | const repo = await octokit.getRepoInfo() 28 | aiHandle?.trackMetric({ name: 'repo.stargazers', value: repo.data.stargazers_count, type: 'gauge' }) 29 | aiHandle?.trackMetric({ name: 'repo.watchers', value: repo.data.watchers_count, type: 'gauge' }) 30 | aiHandle?.trackMetric({ name: 'repo.size', value: repo.data.size, type: 'gauge' }) 31 | aiHandle?.trackMetric({ name: 'repo.forks', value: repo.data.forks_count, type: 'gauge' }) 32 | aiHandle?.trackMetric({ 33 | name: 'repo.open_issues_count', 34 | value: repo.data.open_issues_count, 35 | type: 'gauge', 36 | }) 37 | 38 | const configPath = getInput('configPath') 39 | if (!configPath) { 40 | return 41 | } 42 | 43 | const config = (await octokit.readConfig(configPath)) as MetricsCollectorConfig 44 | for (const query of config.queries) { 45 | await this.countQuery(query.name, query.query, octokit) 46 | } 47 | } 48 | 49 | private async countQuery(name: string, query: string, octokit: OctoKit) { 50 | let count = 0 51 | 52 | for await (const page of octokit.query({ q: query })) { 53 | count += page.length 54 | } 55 | 56 | aiHandle?.trackMetric({ 57 | name: `issue_query.${name}.gauge`, 58 | value: count, 59 | type: 'gauge', 60 | }) 61 | } 62 | } 63 | 64 | interface QueryIssueCount { 65 | name: string 66 | query: string 67 | } 68 | 69 | interface MetricsCollectorConfig { 70 | queries: QueryIssueCount[] 71 | } 72 | 73 | new MetricsCollector().run() // eslint-disable-line 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-github-triage-actions", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "GitHub Actions used by VS Code for triaging issues", 6 | "scripts": { 7 | "test": "jest --silent", 8 | "build": "tsc", 9 | "lint": "eslint -c .eslintrc --fix --ext .ts .", 10 | "watch": "tsc --watch" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/microsoft/vscode-github-triage-actions.git" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/microsoft/vscode-github-triage-actions/issues" 21 | }, 22 | "homepage": "https://github.com/microsoft/vscode-github-triage-actions#readme", 23 | "dependencies": { 24 | "@actions/core": "^1.6.0", 25 | "@actions/exec": "^1.1.0", 26 | "@actions/github": "^2.1.1", 27 | "@betterer/betterer": "^5.3.7", 28 | "@octokit/graphql": "^4.8.0", 29 | "@octokit/request-error": "^2.1.0", 30 | "@octokit/webhooks": "^7.12.2", 31 | "@types/lodash.escaperegexp": "^4.1.9", 32 | "@types/micromatch": "^4.0.1", 33 | "@types/semver": "^7.5.6", 34 | "axios": "^0.19.2", 35 | "dotenv": "^8.2.0", 36 | "lodash": "^4.17.20", 37 | "lodash.escaperegexp": "^4.1.2", 38 | "micromatch": "^4.0.2", 39 | "semver": "^7.5.4" 40 | }, 41 | "devDependencies": { 42 | "@types/chai": "^4.2.10", 43 | "@types/jest": "^27.4.0", 44 | "@types/node": "^20.9.2", 45 | "@types/yargs": "^17.0.32", 46 | "@typescript-eslint/eslint-plugin": "^6.19.0", 47 | "@typescript-eslint/parser": "^6.19.0", 48 | "chai": "^4.2.0", 49 | "eslint": "^8.4.1", 50 | "eslint-config-prettier": "^8.3.0", 51 | "eslint-plugin-prettier": "^4.0.0", 52 | "husky": "^4.2.3", 53 | "jest": "^27.4", 54 | "mock-fs": "^5.1.2", 55 | "prettier": "2.5.1", 56 | "ts-jest": "^27.1.3", 57 | "ts-node": "^8.10.2", 58 | "typescript": "^4.7.2", 59 | "yargs": "^17.7.2" 60 | }, 61 | "husky": { 62 | "hooks": { 63 | "pre-commit": "npm run lint && npm run build && git add \"**/*.js\"" 64 | } 65 | }, 66 | "engines": { 67 | "node": ">=16", 68 | "yarn": ">=3.1.1", 69 | "npm": "please-use-yarn" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pr-checks/Check.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.Check = void 0; 4 | class Check { 5 | } 6 | exports.Check = Check; 7 | //# sourceMappingURL=Check.js.map -------------------------------------------------------------------------------- /pr-checks/Check.ts: -------------------------------------------------------------------------------- 1 | import { CheckSubscriber } from './types' 2 | 3 | export abstract class Check { 4 | abstract id: string 5 | public abstract subscribe(s: CheckSubscriber): void 6 | } 7 | -------------------------------------------------------------------------------- /pr-checks/Dispatcher.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.Dispatcher = void 0; 4 | const types_1 = require("./types"); 5 | class Dispatcher { 6 | constructor(api) { 7 | this.api = api; 8 | this.subscribers = []; 9 | } 10 | on(...args) { 11 | const eventsArg = args[0]; 12 | let actionsArg = ''; 13 | let callback; 14 | if (args.length > 2) { 15 | actionsArg = args[1]; 16 | callback = args[2]; 17 | } 18 | else { 19 | callback = args[1]; 20 | } 21 | let events = []; 22 | let actions = []; 23 | if (typeof eventsArg === 'string') { 24 | events = [eventsArg]; 25 | } 26 | else if (Array.isArray(eventsArg)) { 27 | events = eventsArg; 28 | } 29 | if (typeof actionsArg === 'string' && actionsArg.length > 0) { 30 | actions = [actionsArg]; 31 | } 32 | else if (Array.isArray(actionsArg)) { 33 | actions = actionsArg; 34 | } 35 | this.subscribers.push({ 36 | events, 37 | actions, 38 | callback, 39 | }); 40 | } 41 | async dispatch(context) { 42 | console.debug('dispatch based on', { 43 | eventName: context.eventName, 44 | action: context.payload?.action, 45 | }); 46 | const matches = this.subscribers.filter((s) => { 47 | return (s.events.includes(context.eventName) && 48 | (s.actions.length === 0 || 49 | (context.payload.action && s.actions.includes(context.payload?.action)))); 50 | }); 51 | console.debug('got matches', matches); 52 | for (let n = 0; n < matches.length; n++) { 53 | const match = matches[n]; 54 | let ctx = new types_1.CheckContext(this.api); 55 | try { 56 | console.debug('calling subcriber of event(s) and action(s)', match.events, match.actions); 57 | await match.callback(ctx); 58 | const result = ctx.getResult(); 59 | if (!result) { 60 | continue; 61 | } 62 | console.debug('got check result', result); 63 | await this.api.createStatus(result.sha, result.title, result.state, result.description, result.targetURL); 64 | } 65 | catch (e) { 66 | console.error('failed to dispatch', e); 67 | } 68 | } 69 | } 70 | } 71 | exports.Dispatcher = Dispatcher; 72 | //# sourceMappingURL=Dispatcher.js.map -------------------------------------------------------------------------------- /pr-checks/Dispatcher.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { Dispatcher } from './Dispatcher' 3 | import { Check } from './Check' 4 | import { CheckState, CheckSubscriber } from './types' 5 | import { context } from '@actions/github' 6 | 7 | describe('Dispatcher', () => { 8 | describe('isMatch and dispatch', () => { 9 | describe('When check subscribes and webhook event unhandled is triggered', () => { 10 | it('Should not dispatch to check', async () => { 11 | const d = new Dispatcher(null) 12 | const check = new TestCheck() 13 | check.subscribe(d) 14 | context.eventName = 'unhandled' 15 | await d.dispatch(context) 16 | 17 | expect(check.calls).equal(0) 18 | }) 19 | }) 20 | 21 | describe('When check subscribes and webhook event e1 is triggered', () => { 22 | it('Should dispatch to check 2 times', async () => { 23 | const d = new Dispatcher(null) 24 | const check = new TestCheck() 25 | check.subscribe(d) 26 | context.eventName = 'e1' 27 | context.payload = { 28 | action: 'a100', 29 | } 30 | await d.dispatch(context) 31 | 32 | expect(check.calls).equal(2) 33 | }) 34 | }) 35 | 36 | describe('When check subscribes and webhook event e1 with action a1 is triggered', () => { 37 | it('Should dispatch to check 6 times', async () => { 38 | const d = new Dispatcher(null) 39 | const check = new TestCheck() 40 | check.subscribe(d) 41 | context.eventName = 'e1' 42 | context.payload = { 43 | action: 'a1', 44 | } 45 | await d.dispatch(context) 46 | 47 | expect(check.calls).equal(6) 48 | }) 49 | }) 50 | 51 | describe('When check subscribes and webhook event e1 with action a2 is triggered', () => { 52 | it('Should dispatch to check 4 times', async () => { 53 | const d = new Dispatcher(null) 54 | const check = new TestCheck() 55 | check.subscribe(d) 56 | context.eventName = 'e1' 57 | context.payload = { 58 | action: 'a2', 59 | } 60 | await d.dispatch(context) 61 | 62 | expect(check.calls).equal(4) 63 | }) 64 | }) 65 | 66 | describe('When check subscribes and webhook event e2 is triggered', () => { 67 | it('Should dispatch to check 1 time', async () => { 68 | const d = new Dispatcher(null) 69 | const check = new TestCheck() 70 | check.subscribe(d) 71 | context.eventName = 'e2' 72 | context.payload = { 73 | action: 'a100', 74 | } 75 | await d.dispatch(context) 76 | 77 | expect(check.calls).equal(1) 78 | }) 79 | }) 80 | 81 | describe('When check subscribes and webhook event e2 with action a1 is triggered', () => { 82 | it('Should dispatch to check 3 times', async () => { 83 | const d = new Dispatcher(null) 84 | const check = new TestCheck() 85 | check.subscribe(d) 86 | context.eventName = 'e2' 87 | context.payload = { 88 | action: 'a1', 89 | } 90 | await d.dispatch(context) 91 | 92 | expect(check.calls).equal(3) 93 | }) 94 | }) 95 | 96 | describe('When check subscribes and webhook event e2 with action a2 is triggered', () => { 97 | it('Should dispatch to check 2 times', async () => { 98 | const d = new Dispatcher(null) 99 | const check = new TestCheck() 100 | check.subscribe(d) 101 | context.eventName = 'e2' 102 | context.payload = { 103 | action: 'a2', 104 | } 105 | await d.dispatch(context) 106 | 107 | expect(check.calls).equal(2) 108 | }) 109 | }) 110 | }) 111 | 112 | describe('dispatch should create status', () => { 113 | describe('When check subscribes and webhook event success is triggered', () => { 114 | it('Should dispatch and create success status', async () => { 115 | const mockCallback = jest.fn() 116 | const d = new Dispatcher({ 117 | createStatus: mockCallback, 118 | getPullRequest: jest.fn(), 119 | }) 120 | const check = new TestUpdateCheck({ sha: 'sha', title: 'Test', description: 'Success' }) 121 | check.subscribe(d) 122 | context.eventName = 'success' 123 | await d.dispatch(context) 124 | 125 | expect(mockCallback.mock.calls.length).to.equal(1) 126 | expect(mockCallback.mock.calls[0][0]).to.equal('sha') 127 | expect(mockCallback.mock.calls[0][1]).to.equal('Test') 128 | expect(mockCallback.mock.calls[0][2]).to.equal(CheckState.Success) 129 | expect(mockCallback.mock.calls[0][3]).to.equal('Success') 130 | expect(mockCallback.mock.calls[0][4]).to.equal(undefined) 131 | }) 132 | }) 133 | 134 | describe('When check subscribes and webhook event failure is triggered', () => { 135 | it('Should dispatch and create failure status', async () => { 136 | const mockCallback = jest.fn() 137 | const d = new Dispatcher({ 138 | createStatus: mockCallback, 139 | getPullRequest: jest.fn(), 140 | }) 141 | const check = new TestUpdateCheck({ sha: 'sha', title: 'Test', description: 'Failure' }) 142 | check.subscribe(d) 143 | context.eventName = 'failure' 144 | await d.dispatch(context) 145 | 146 | expect(mockCallback.mock.calls.length).to.equal(1) 147 | expect(mockCallback.mock.calls[0][0]).to.equal('sha') 148 | expect(mockCallback.mock.calls[0][1]).to.equal('Test') 149 | expect(mockCallback.mock.calls[0][2]).to.equal(CheckState.Failure) 150 | expect(mockCallback.mock.calls[0][3]).to.equal('Failure') 151 | expect(mockCallback.mock.calls[0][4]).to.equal(undefined) 152 | }) 153 | }) 154 | }) 155 | }) 156 | 157 | class TestCheck extends Check { 158 | id = 'test' 159 | public calls = 0 160 | 161 | public subscribe(s: CheckSubscriber) { 162 | s.on('e1', async () => { 163 | this.calls++ 164 | }) 165 | 166 | s.on(['e1', 'e2'], async () => { 167 | this.calls++ 168 | }) 169 | 170 | s.on('e1', 'a1', async () => { 171 | this.calls++ 172 | }) 173 | 174 | s.on('e1', ['a1', 'a2'], async () => { 175 | this.calls++ 176 | }) 177 | 178 | s.on(['e1', 'e2'], 'a1', async () => { 179 | this.calls++ 180 | }) 181 | 182 | s.on(['e1', 'e2'], ['a1', 'a2'], async () => { 183 | this.calls++ 184 | }) 185 | } 186 | } 187 | 188 | class TestUpdateCheck extends Check { 189 | id = 'testUpdate' 190 | 191 | constructor(private config: { sha: string; title: string; description: string }) { 192 | super() 193 | } 194 | 195 | public subscribe(s: CheckSubscriber) { 196 | s.on('success', async (ctx) => { 197 | ctx.success(this.config) 198 | }) 199 | 200 | s.on('failure', async (ctx) => { 201 | ctx.failure(this.config) 202 | }) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /pr-checks/Dispatcher.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '@actions/github/lib/context' 2 | import { API, CheckContext, CheckSubscriber, SubscribeCallback } from './types' 3 | 4 | export class Dispatcher implements CheckSubscriber { 5 | private subscribers: { 6 | events: string[] 7 | actions: string[] 8 | callback: SubscribeCallback 9 | }[] = [] 10 | 11 | constructor(private api: API) {} 12 | 13 | on( 14 | ...args: 15 | | [events: string | string[], callback: SubscribeCallback] 16 | | [events: string | string[], actions: string | string[], callback: SubscribeCallback] 17 | ): void { 18 | const eventsArg = args[0] 19 | let actionsArg: string | string[] = '' 20 | let callback: SubscribeCallback 21 | 22 | if (args.length > 2) { 23 | actionsArg = args[1] as string | string[] 24 | callback = args[2] as SubscribeCallback 25 | } else { 26 | callback = args[1] as SubscribeCallback 27 | } 28 | 29 | let events: string[] = [] 30 | let actions: string[] = [] 31 | 32 | if (typeof eventsArg === 'string') { 33 | events = [eventsArg] 34 | } else if (Array.isArray(eventsArg)) { 35 | events = eventsArg 36 | } 37 | 38 | if (typeof actionsArg === 'string' && actionsArg.length > 0) { 39 | actions = [actionsArg] 40 | } else if (Array.isArray(actionsArg)) { 41 | actions = actionsArg 42 | } 43 | 44 | this.subscribers.push({ 45 | events, 46 | actions, 47 | callback, 48 | }) 49 | } 50 | 51 | async dispatch(context: Context): Promise { 52 | console.debug('dispatch based on', { 53 | eventName: context.eventName, 54 | action: context.payload?.action, 55 | }) 56 | const matches = this.subscribers.filter((s) => { 57 | return ( 58 | s.events.includes(context.eventName) && 59 | (s.actions.length === 0 || 60 | (context.payload.action && s.actions.includes(context.payload?.action))) 61 | ) 62 | }) 63 | console.debug('got matches', matches) 64 | 65 | for (let n = 0; n < matches.length; n++) { 66 | const match = matches[n] 67 | let ctx = new CheckContext(this.api) 68 | try { 69 | console.debug('calling subcriber of event(s) and action(s)', match.events, match.actions) 70 | await match.callback(ctx) 71 | const result = ctx.getResult() 72 | if (!result) { 73 | continue 74 | } 75 | 76 | console.debug('got check result', result) 77 | 78 | await this.api.createStatus( 79 | result.sha, 80 | result.title, 81 | result.state, 82 | result.description, 83 | result.targetURL, 84 | ) 85 | } catch (e) { 86 | console.error('failed to dispatch', e) 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pr-checks/action.yml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | description: Mark commits with an error, failure, pending, or success state, which is then reflected in pull requests involving those commits. 3 | inputs: 4 | token: 5 | description: GitHub token with issue, comment, and label read/write permissions. Requires metadata permissions to be able to check whether author is member of organization. 6 | default: ${{ github.token }} 7 | configPath: 8 | description: Name of .json file (no extension) in .github/ directory of repo holding configuration for this action 9 | required: true 10 | metricsWriteAPIKey: 11 | description: Grafana Cloud metrics api key 12 | required: false 13 | runs: 14 | using: 'node20' 15 | main: 'index.js' 16 | -------------------------------------------------------------------------------- /pr-checks/checks/ChangelogCheck.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.containsBreakingChangeNotice = exports.isTitleValid = exports.ChangelogCheck = exports.defaultConfig = exports.defaultChangelogLabelCheckConfig = void 0; 4 | const github_1 = require("@actions/github"); 5 | const Check_1 = require("../Check"); 6 | const types_1 = require("../types"); 7 | const LabelCheck_1 = require("./LabelCheck"); 8 | exports.defaultChangelogLabelCheckConfig = { 9 | title: 'Changelog Check', 10 | exists: 'Changelog enabled', 11 | notExists: `Changelog decision needed`, 12 | skipped: 'Changelog skipped', 13 | }; 14 | exports.defaultConfig = { 15 | title: exports.defaultChangelogLabelCheckConfig.title, 16 | valid: 'Validation passed', 17 | invalidTitle: 'PR title formatting is invalid', 18 | breakingChangeNoticeMissing: 'Breaking change notice is missing', 19 | }; 20 | class ChangelogCheck extends Check_1.Check { 21 | constructor(config) { 22 | super(); 23 | this.config = config; 24 | this.id = 'changelog'; 25 | this.changelogLabelCheck = new LabelCheck_1.LabelCheck({ 26 | title: this.config.title ?? exports.defaultChangelogLabelCheckConfig.title, 27 | targetUrl: this.config.targetUrl, 28 | labels: { 29 | ...this.config.labels, 30 | exists: this.config.labels.exists ?? exports.defaultChangelogLabelCheckConfig.exists, 31 | notExists: this.config.labels.notExists ?? exports.defaultChangelogLabelCheckConfig.notExists, 32 | }, 33 | skip: this.config.skip 34 | ? { 35 | matches: this.config.skip.matches, 36 | message: this.config.skip.message ?? exports.defaultChangelogLabelCheckConfig.skipped, 37 | } 38 | : undefined, 39 | }); 40 | this.breakingChangeLabelCheck = new LabelCheck_1.LabelCheck({ 41 | title: this.config.title ?? exports.defaultChangelogLabelCheckConfig.title, 42 | labels: { 43 | matches: this.config.breakingChangeLabels ?? [], 44 | }, 45 | }); 46 | } 47 | subscribe(s) { 48 | s.on(['pull_request', 'pull_request_target'], ['edited', 'labeled', 'unlabeled', 'opened', 'reopened', 'ready_for_review', 'synchronize'], async (ctx) => { 49 | const payload = github_1.context.payload; 50 | if (!payload) { 51 | return; 52 | } 53 | await this.changelogLabelCheck.runCheck(ctx); 54 | const changelogEnabled = ctx.getResult(); 55 | if (!changelogEnabled || 56 | changelogEnabled.state !== types_1.CheckState.Success || 57 | changelogEnabled.description?.indexOf(this.config.labels.exists ?? exports.defaultChangelogLabelCheckConfig.exists) === -1) { 58 | return; 59 | } 60 | if (!isTitleValid(payload.pull_request.title)) { 61 | return this.failure(ctx, exports.defaultConfig.invalidTitle, payload.pull_request.head.sha); 62 | } 63 | if (this.config.breakingChangeLabels && this.config.breakingChangeLabels.length > 0) { 64 | ctx.reset(); 65 | await this.breakingChangeLabelCheck.runCheck(ctx); 66 | const breakingChangeEnabled = ctx.getResult(); 67 | if (breakingChangeEnabled && 68 | breakingChangeEnabled.state === types_1.CheckState.Success && 69 | !containsBreakingChangeNotice(payload.pull_request.body)) { 70 | return this.failure(ctx, exports.defaultConfig.breakingChangeNoticeMissing, payload.pull_request.head.sha); 71 | } 72 | } 73 | return this.success(ctx, payload.pull_request.head.sha); 74 | }); 75 | } 76 | success(ctx, sha) { 77 | const title = this.config.title ?? exports.defaultConfig.title; 78 | const description = `${this.config.labels.exists ?? exports.defaultChangelogLabelCheckConfig.exists} - ${exports.defaultConfig.valid}`; 79 | return ctx.success({ sha, title, description, targetURL: this.config.targetUrl }); 80 | } 81 | failure(ctx, reason, sha) { 82 | const title = this.config.title ?? exports.defaultConfig.title; 83 | const description = `${this.config.labels.exists ?? exports.defaultChangelogLabelCheckConfig.exists} - ${reason}`; 84 | return ctx.failure({ sha, title, description, targetURL: this.config.targetUrl }); 85 | } 86 | } 87 | exports.ChangelogCheck = ChangelogCheck; 88 | const titleRegExp = /^[\s]*(\[.+][\s])?[A-Z]{1}[a-zA-Z0-9\s/]+:[\s]{1}[A-Z]{1}.*$/; 89 | function isTitleValid(title) { 90 | title = title.trim(); 91 | const matches = titleRegExp.exec(title); 92 | return matches !== null; 93 | } 94 | exports.isTitleValid = isTitleValid; 95 | const breakingChangeNoticeRegExp = /# Release notice breaking change/m; 96 | function containsBreakingChangeNotice(str) { 97 | const matches = breakingChangeNoticeRegExp.exec(str); 98 | return matches !== null; 99 | } 100 | exports.containsBreakingChangeNotice = containsBreakingChangeNotice; 101 | //# sourceMappingURL=ChangelogCheck.js.map -------------------------------------------------------------------------------- /pr-checks/checks/ChangelogCheck.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import { EventPayloads } from '@octokit/webhooks' 3 | import { Check } from '../Check' 4 | import { CheckContext, CheckState, CheckSubscriber } from '../types' 5 | import { LabelCheck } from './LabelCheck' 6 | 7 | export type ChangelogCheckConfig = { 8 | title?: string 9 | targetUrl?: string 10 | labels: { 11 | exists?: string 12 | notExists?: string 13 | matches: string[] 14 | } 15 | breakingChangeLabels?: string[] 16 | skip?: { 17 | message?: string 18 | matches: string[] 19 | } 20 | } 21 | 22 | export const defaultChangelogLabelCheckConfig = { 23 | title: 'Changelog Check', 24 | exists: 'Changelog enabled', 25 | notExists: `Changelog decision needed`, 26 | skipped: 'Changelog skipped', 27 | } 28 | 29 | export const defaultConfig = { 30 | title: defaultChangelogLabelCheckConfig.title, 31 | valid: 'Validation passed', 32 | invalidTitle: 'PR title formatting is invalid', 33 | breakingChangeNoticeMissing: 'Breaking change notice is missing', 34 | } 35 | 36 | export class ChangelogCheck extends Check { 37 | id = 'changelog' 38 | private changelogLabelCheck: LabelCheck 39 | private breakingChangeLabelCheck: LabelCheck 40 | 41 | constructor(private config: ChangelogCheckConfig) { 42 | super() 43 | 44 | this.changelogLabelCheck = new LabelCheck({ 45 | title: this.config.title ?? defaultChangelogLabelCheckConfig.title, 46 | targetUrl: this.config.targetUrl, 47 | labels: { 48 | ...this.config.labels, 49 | exists: this.config.labels.exists ?? defaultChangelogLabelCheckConfig.exists, 50 | notExists: this.config.labels.notExists ?? defaultChangelogLabelCheckConfig.notExists, 51 | }, 52 | skip: this.config.skip 53 | ? { 54 | matches: this.config.skip.matches, 55 | message: this.config.skip.message ?? defaultChangelogLabelCheckConfig.skipped, 56 | } 57 | : undefined, 58 | }) 59 | 60 | this.breakingChangeLabelCheck = new LabelCheck({ 61 | title: this.config.title ?? defaultChangelogLabelCheckConfig.title, 62 | labels: { 63 | matches: this.config.breakingChangeLabels ?? [], 64 | }, 65 | }) 66 | } 67 | 68 | subscribe(s: CheckSubscriber) { 69 | s.on( 70 | ['pull_request', 'pull_request_target'], 71 | ['edited', 'labeled', 'unlabeled', 'opened', 'reopened', 'ready_for_review', 'synchronize'], 72 | async (ctx) => { 73 | const payload = context.payload as EventPayloads.WebhookPayloadPullRequest 74 | if (!payload) { 75 | return 76 | } 77 | 78 | await this.changelogLabelCheck.runCheck(ctx) 79 | const changelogEnabled = ctx.getResult() 80 | if ( 81 | !changelogEnabled || 82 | changelogEnabled.state !== CheckState.Success || 83 | changelogEnabled.description?.indexOf( 84 | this.config.labels.exists ?? defaultChangelogLabelCheckConfig.exists, 85 | ) === -1 86 | ) { 87 | return 88 | } 89 | 90 | if (!isTitleValid(payload.pull_request.title)) { 91 | return this.failure(ctx, defaultConfig.invalidTitle, payload.pull_request.head.sha) 92 | } 93 | 94 | if (this.config.breakingChangeLabels && this.config.breakingChangeLabels.length > 0) { 95 | ctx.reset() 96 | await this.breakingChangeLabelCheck.runCheck(ctx) 97 | 98 | const breakingChangeEnabled = ctx.getResult() 99 | if ( 100 | breakingChangeEnabled && 101 | breakingChangeEnabled.state === CheckState.Success && 102 | !containsBreakingChangeNotice(payload.pull_request.body) 103 | ) { 104 | return this.failure( 105 | ctx, 106 | defaultConfig.breakingChangeNoticeMissing, 107 | payload.pull_request.head.sha, 108 | ) 109 | } 110 | } 111 | 112 | return this.success(ctx, payload.pull_request.head.sha) 113 | }, 114 | ) 115 | } 116 | 117 | private success(ctx: CheckContext, sha: string) { 118 | const title = this.config.title ?? defaultConfig.title 119 | const description = `${this.config.labels.exists ?? defaultChangelogLabelCheckConfig.exists} - ${ 120 | defaultConfig.valid 121 | }` 122 | return ctx.success({ sha, title, description, targetURL: this.config.targetUrl }) 123 | } 124 | 125 | private failure(ctx: CheckContext, reason: string, sha: string) { 126 | const title = this.config.title ?? defaultConfig.title 127 | const description = `${ 128 | this.config.labels.exists ?? defaultChangelogLabelCheckConfig.exists 129 | } - ${reason}` 130 | return ctx.failure({ sha, title, description, targetURL: this.config.targetUrl }) 131 | } 132 | } 133 | 134 | const titleRegExp = /^[\s]*(\[.+][\s])?[A-Z]{1}[a-zA-Z0-9\s/]+:[\s]{1}[A-Z]{1}.*$/ 135 | 136 | export function isTitleValid(title: string) { 137 | title = title.trim() 138 | const matches = titleRegExp.exec(title) 139 | return matches !== null 140 | } 141 | 142 | const breakingChangeNoticeRegExp = /# Release notice breaking change/m 143 | 144 | export function containsBreakingChangeNotice(str: string) { 145 | const matches = breakingChangeNoticeRegExp.exec(str) 146 | return matches !== null 147 | } 148 | -------------------------------------------------------------------------------- /pr-checks/checks/LabelCheck.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.stringMatchesLabel = exports.LabelCheck = exports.defaultConfig = void 0; 4 | const github_1 = require("@actions/github"); 5 | const Check_1 = require("../Check"); 6 | exports.defaultConfig = { 7 | title: 'Label Check', 8 | exists: 'Check success', 9 | notExists: `Check failure`, 10 | skipped: 'Check skipped', 11 | }; 12 | class LabelCheck extends Check_1.Check { 13 | constructor(config) { 14 | super(); 15 | this.config = config; 16 | this.id = 'label'; 17 | } 18 | subscribe(s) { 19 | s.on(['pull_request', 'pull_request_target'], ['labeled', 'unlabeled', 'opened', 'reopened', 'ready_for_review', 'synchronize'], async (ctx) => { 20 | await this.runCheck(ctx); 21 | }); 22 | } 23 | async runCheck(ctx) { 24 | const payload = github_1.context.payload; 25 | if (!payload) { 26 | return; 27 | } 28 | if (payload.pull_request.state !== 'open') { 29 | return; 30 | } 31 | for (let n = 0; n < payload.pull_request.labels.length; n++) { 32 | const existingLabel = payload.pull_request.labels[n]; 33 | for (let i = 0; i < this.config.labels.matches.length; i++) { 34 | if (stringMatchesLabel(this.config.labels.matches[i], existingLabel.name)) { 35 | return this.successEnabled(ctx, payload.pull_request.head.sha); 36 | } 37 | } 38 | } 39 | if (this.config.skip) { 40 | for (let n = 0; n < payload.pull_request.labels.length; n++) { 41 | const existingLabel = payload.pull_request.labels[n]; 42 | for (let i = 0; i < this.config.skip.matches.length; i++) { 43 | if (stringMatchesLabel(this.config.skip.matches[i], existingLabel.name)) { 44 | return this.successSkip(ctx, payload.pull_request.head.sha); 45 | } 46 | } 47 | } 48 | } 49 | return this.failure(ctx, payload.pull_request.head.sha); 50 | } 51 | successEnabled(ctx, sha) { 52 | const title = this.config.title ?? exports.defaultConfig.title; 53 | const description = this.config.labels.exists ?? exports.defaultConfig.exists; 54 | return ctx.success({ sha, title, description, targetURL: this.config.targetUrl }); 55 | } 56 | successSkip(ctx, sha) { 57 | const title = this.config.title ?? exports.defaultConfig.title; 58 | const description = this.config.skip?.message ?? exports.defaultConfig.skipped; 59 | return ctx.success({ sha, title, description, targetURL: this.config.targetUrl }); 60 | } 61 | failure(ctx, sha) { 62 | const title = this.config.title ?? exports.defaultConfig.title; 63 | const description = this.config.labels.notExists ?? exports.defaultConfig.notExists; 64 | return ctx.failure({ sha, title, description, targetURL: this.config.targetUrl }); 65 | } 66 | } 67 | exports.LabelCheck = LabelCheck; 68 | function stringMatchesLabel(str, label) { 69 | str = str.trim(); 70 | if (str === '') { 71 | return false; 72 | } 73 | if (str === '*') { 74 | return true; 75 | } 76 | const lastAnyCharIndex = str.lastIndexOf('*'); 77 | return (lastAnyCharIndex !== -1 && label.startsWith(str.substring(0, lastAnyCharIndex))) || str === label; 78 | } 79 | exports.stringMatchesLabel = stringMatchesLabel; 80 | //# sourceMappingURL=LabelCheck.js.map -------------------------------------------------------------------------------- /pr-checks/checks/LabelCheck.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import { EventPayloads } from '@octokit/webhooks' 3 | import { Check } from '../Check' 4 | import { CheckContext, CheckSubscriber } from '../types' 5 | 6 | export type LabelCheckConfig = { 7 | title?: string 8 | targetUrl?: string 9 | labels: { 10 | exists?: string 11 | notExists?: string 12 | matches: string[] 13 | } 14 | skip?: { 15 | message?: string 16 | matches: string[] 17 | } 18 | } 19 | 20 | export const defaultConfig = { 21 | title: 'Label Check', 22 | exists: 'Check success', 23 | notExists: `Check failure`, 24 | skipped: 'Check skipped', 25 | } 26 | 27 | export class LabelCheck extends Check { 28 | id = 'label' 29 | 30 | constructor(private config: LabelCheckConfig) { 31 | super() 32 | } 33 | 34 | subscribe(s: CheckSubscriber) { 35 | s.on( 36 | ['pull_request', 'pull_request_target'], 37 | ['labeled', 'unlabeled', 'opened', 'reopened', 'ready_for_review', 'synchronize'], 38 | async (ctx) => { 39 | await this.runCheck(ctx) 40 | }, 41 | ) 42 | } 43 | 44 | async runCheck(ctx: CheckContext) { 45 | const payload = context.payload as EventPayloads.WebhookPayloadPullRequest 46 | if (!payload) { 47 | return 48 | } 49 | 50 | if (payload.pull_request.state !== 'open') { 51 | return 52 | } 53 | 54 | for (let n = 0; n < payload.pull_request.labels.length; n++) { 55 | const existingLabel = payload.pull_request.labels[n] 56 | 57 | for (let i = 0; i < this.config.labels.matches.length; i++) { 58 | if (stringMatchesLabel(this.config.labels.matches[i], existingLabel.name)) { 59 | return this.successEnabled(ctx, payload.pull_request.head.sha) 60 | } 61 | } 62 | } 63 | 64 | if (this.config.skip) { 65 | for (let n = 0; n < payload.pull_request.labels.length; n++) { 66 | const existingLabel = payload.pull_request.labels[n] 67 | 68 | for (let i = 0; i < this.config.skip.matches.length; i++) { 69 | if (stringMatchesLabel(this.config.skip.matches[i], existingLabel.name)) { 70 | return this.successSkip(ctx, payload.pull_request.head.sha) 71 | } 72 | } 73 | } 74 | } 75 | 76 | return this.failure(ctx, payload.pull_request.head.sha) 77 | } 78 | 79 | private successEnabled(ctx: CheckContext, sha: string) { 80 | const title = this.config.title ?? defaultConfig.title 81 | const description = this.config.labels.exists ?? defaultConfig.exists 82 | return ctx.success({ sha, title, description, targetURL: this.config.targetUrl }) 83 | } 84 | 85 | private successSkip(ctx: CheckContext, sha: string) { 86 | const title = this.config.title ?? defaultConfig.title 87 | const description = this.config.skip?.message ?? defaultConfig.skipped 88 | return ctx.success({ sha, title, description, targetURL: this.config.targetUrl }) 89 | } 90 | 91 | private failure(ctx: CheckContext, sha: string) { 92 | const title = this.config.title ?? defaultConfig.title 93 | const description = this.config.labels.notExists ?? defaultConfig.notExists 94 | return ctx.failure({ sha, title, description, targetURL: this.config.targetUrl }) 95 | } 96 | } 97 | 98 | export function stringMatchesLabel(str: string, label: string) { 99 | str = str.trim() 100 | 101 | if (str === '') { 102 | return false 103 | } 104 | 105 | if (str === '*') { 106 | return true 107 | } 108 | 109 | const lastAnyCharIndex = str.lastIndexOf('*') 110 | 111 | return (lastAnyCharIndex !== -1 && label.startsWith(str.substring(0, lastAnyCharIndex))) || str === label 112 | } 113 | -------------------------------------------------------------------------------- /pr-checks/checks/MilestoneCheck.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.MilestoneCheck = void 0; 4 | const github_1 = require("@actions/github"); 5 | const Check_1 = require("../Check"); 6 | class MilestoneCheck extends Check_1.Check { 7 | constructor(config) { 8 | super(); 9 | this.config = config; 10 | this.id = 'milestone'; 11 | } 12 | subscribe(s) { 13 | s.on(['pull_request', 'pull_request_target'], ['opened', 'reopened', 'ready_for_review', 'synchronize'], async (ctx) => { 14 | const pr = github_1.context.payload.pull_request; 15 | if (!pr) { 16 | return this.failure(ctx, ''); 17 | } 18 | // This check is relevant only for PRs opened against some specific branches. 19 | // We can skip it if the base branch is not one of those. 20 | // If for any reason the base branch is not specified in the webhook event payload, we still run the check 21 | const versionBranchRegex = /v\d*\.\d*\.\d*.*/; 22 | if (pr.base?.ref && pr.base.ref !== 'main' && !versionBranchRegex.test(pr.base.ref)) { 23 | return this.success(ctx, pr.head.sha); 24 | } 25 | if (pr.milestone) { 26 | return this.success(ctx, pr.head.sha); 27 | } 28 | return this.failure(ctx, pr.head.sha); 29 | }); 30 | s.on('issues', ['milestoned', 'demilestoned'], async (ctx) => { 31 | const issue = github_1.context.payload.issue; 32 | if (!issue || !issue.pull_request) { 33 | return; 34 | } 35 | if (issue.state !== 'open') { 36 | return; 37 | } 38 | const pr = await ctx.getAPI().getPullRequest(); 39 | if (pr.milestoneId) { 40 | return this.success(ctx, pr.headSHA); 41 | } 42 | return this.failure(ctx, pr.headSHA); 43 | }); 44 | } 45 | success(ctx, sha) { 46 | const title = this.config.title ?? 'Milestone Check'; 47 | const description = this.config.success ?? 'Milestone set'; 48 | return ctx.success({ sha, title, description, targetURL: this.config.targetUrl }); 49 | } 50 | failure(ctx, sha) { 51 | const title = this.config.title ?? 'Milestone Check'; 52 | const description = this.config.failure ?? 'Milestone not set'; 53 | return ctx.failure({ sha, title, description, targetURL: this.config.targetUrl }); 54 | } 55 | } 56 | exports.MilestoneCheck = MilestoneCheck; 57 | //# sourceMappingURL=MilestoneCheck.js.map -------------------------------------------------------------------------------- /pr-checks/checks/MilestoneCheck.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import { EventPayloads } from '@octokit/webhooks' 3 | import { Check } from '../Check' 4 | import { CheckContext, CheckSubscriber } from '../types' 5 | 6 | export type MilestoneCheckConfig = { 7 | title?: string 8 | targetUrl?: string 9 | success?: string 10 | failure?: string 11 | } 12 | 13 | export class MilestoneCheck extends Check { 14 | id = 'milestone' 15 | 16 | constructor(private config: MilestoneCheckConfig) { 17 | super() 18 | } 19 | 20 | subscribe(s: CheckSubscriber) { 21 | s.on( 22 | ['pull_request', 'pull_request_target'], 23 | ['opened', 'reopened', 'ready_for_review', 'synchronize'], 24 | async (ctx) => { 25 | const pr = context.payload.pull_request as EventPayloads.WebhookPayloadPullRequestPullRequest 26 | 27 | if (!pr) { 28 | return this.failure(ctx, '') 29 | } 30 | 31 | // This check is relevant only for PRs opened against some specific branches. 32 | // We can skip it if the base branch is not one of those. 33 | // If for any reason the base branch is not specified in the webhook event payload, we still run the check 34 | const versionBranchRegex = /v\d*\.\d*\.\d*.*/ 35 | if (pr.base?.ref && pr.base.ref !== 'main' && !versionBranchRegex.test(pr.base.ref)) { 36 | return this.success(ctx, pr.head.sha) 37 | } 38 | 39 | if (pr.milestone) { 40 | return this.success(ctx, pr.head.sha) 41 | } 42 | 43 | return this.failure(ctx, pr.head.sha) 44 | }, 45 | ) 46 | 47 | s.on('issues', ['milestoned', 'demilestoned'], async (ctx) => { 48 | const issue = context.payload.issue as EventPayloads.WebhookPayloadIssuesIssue 49 | if (!issue || !issue.pull_request) { 50 | return 51 | } 52 | 53 | if (issue.state !== 'open') { 54 | return 55 | } 56 | 57 | const pr = await ctx.getAPI().getPullRequest() 58 | if (pr.milestoneId) { 59 | return this.success(ctx, pr.headSHA) 60 | } 61 | 62 | return this.failure(ctx, pr.headSHA) 63 | }) 64 | } 65 | 66 | private success(ctx: CheckContext, sha: string) { 67 | const title = this.config.title ?? 'Milestone Check' 68 | const description = this.config.success ?? 'Milestone set' 69 | return ctx.success({ sha, title, description, targetURL: this.config.targetUrl }) 70 | } 71 | 72 | private failure(ctx: CheckContext, sha: string) { 73 | const title = this.config.title ?? 'Milestone Check' 74 | const description = this.config.failure ?? 'Milestone not set' 75 | return ctx.failure({ sha, title, description, targetURL: this.config.targetUrl }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pr-checks/checks/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.getChecks = void 0; 4 | const ChangelogCheck_1 = require("./ChangelogCheck"); 5 | const LabelCheck_1 = require("./LabelCheck"); 6 | const MilestoneCheck_1 = require("./MilestoneCheck"); 7 | function getChecks(config) { 8 | const checks = []; 9 | for (let n = 0; n < config.length; n++) { 10 | const checkConfig = config[n]; 11 | switch (checkConfig.type) { 12 | case 'check-milestone': 13 | checks.push(new MilestoneCheck_1.MilestoneCheck(checkConfig)); 14 | break; 15 | case 'check-label': 16 | checks.push(new LabelCheck_1.LabelCheck(checkConfig)); 17 | break; 18 | case 'check-changelog': 19 | checks.push(new ChangelogCheck_1.ChangelogCheck(checkConfig)); 20 | break; 21 | } 22 | } 23 | return checks; 24 | } 25 | exports.getChecks = getChecks; 26 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /pr-checks/checks/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { getChecks } from './index' 3 | 4 | describe('getChecks', () => { 5 | it('Should return checks based on config', async () => { 6 | const config = [ 7 | { 8 | type: 'check-milestone', 9 | }, 10 | ] 11 | const checks = getChecks(config) 12 | expect(checks[0]).to.not.be.undefined 13 | expect(checks[0].id).to.equal('milestone') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /pr-checks/checks/index.ts: -------------------------------------------------------------------------------- 1 | import { Check } from '../Check' 2 | import { ChangelogCheck, ChangelogCheckConfig } from './ChangelogCheck' 3 | import { LabelCheck, LabelCheckConfig } from './LabelCheck' 4 | import { MilestoneCheck, MilestoneCheckConfig } from './MilestoneCheck' 5 | 6 | export type CheckConfig = 7 | | ({ type: 'check-milestone' } & MilestoneCheckConfig) 8 | | ({ type: 'check-label' } & LabelCheckConfig) 9 | | ({ type: 'check-changelog' } & ChangelogCheckConfig) 10 | 11 | export function getChecks(config: CheckConfig[]) { 12 | const checks: Check[] = [] 13 | 14 | for (let n = 0; n < config.length; n++) { 15 | const checkConfig = config[n] 16 | 17 | switch (checkConfig.type) { 18 | case 'check-milestone': 19 | checks.push(new MilestoneCheck(checkConfig)) 20 | break 21 | case 'check-label': 22 | checks.push(new LabelCheck(checkConfig)) 23 | break 24 | case 'check-changelog': 25 | checks.push(new ChangelogCheck(checkConfig)) 26 | break 27 | } 28 | } 29 | 30 | return checks 31 | } 32 | -------------------------------------------------------------------------------- /pr-checks/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const github_1 = require("@actions/github"); 4 | const octokit_1 = require("../api/octokit"); 5 | const utils_1 = require("../common/utils"); 6 | const Action_1 = require("../common/Action"); 7 | const checks_1 = require("./checks"); 8 | const Dispatcher_1 = require("./Dispatcher"); 9 | class PRChecksAction extends Action_1.ActionBase { 10 | constructor() { 11 | super(...arguments); 12 | this.id = 'PR Checks'; 13 | } 14 | async runAction() { 15 | const issue = github_1.context?.issue?.number; 16 | if (!issue) { 17 | return; 18 | } 19 | const api = new octokit_1.OctoKitIssue(this.getToken(), github_1.context.repo, { number: issue }); 20 | const dispatcher = new Dispatcher_1.Dispatcher(api); 21 | const config = await api.readConfig((0, utils_1.getRequiredInput)('configPath')); 22 | const checks = (0, checks_1.getChecks)(config); 23 | for (let n = 0; n < checks.length; n++) { 24 | const check = checks[n]; 25 | console.debug('subscribing to check', check.id); 26 | check.subscribe(dispatcher); 27 | } 28 | await dispatcher.dispatch(github_1.context); 29 | } 30 | } 31 | new PRChecksAction().run(); // eslint-disable-line 32 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /pr-checks/index.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import { OctoKitIssue } from '../api/octokit' 3 | import { getRequiredInput } from '../common/utils' 4 | import { ActionBase } from '../common/Action' 5 | import { CheckConfig, getChecks } from './checks' 6 | import { Dispatcher } from './Dispatcher' 7 | 8 | class PRChecksAction extends ActionBase { 9 | id = 'PR Checks' 10 | 11 | protected async runAction(): Promise { 12 | const issue = context?.issue?.number 13 | 14 | if (!issue) { 15 | return 16 | } 17 | 18 | const api = new OctoKitIssue(this.getToken(), context.repo, { number: issue }) 19 | const dispatcher = new Dispatcher(api) 20 | 21 | const config = await api.readConfig(getRequiredInput('configPath')) 22 | const checks = getChecks(config as CheckConfig[]) 23 | 24 | for (let n = 0; n < checks.length; n++) { 25 | const check = checks[n] 26 | console.debug('subscribing to check', check.id) 27 | check.subscribe(dispatcher) 28 | } 29 | 30 | await dispatcher.dispatch(context) 31 | } 32 | } 33 | 34 | new PRChecksAction().run() // eslint-disable-line 35 | -------------------------------------------------------------------------------- /pr-checks/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.CheckContext = exports.CheckState = void 0; 4 | var CheckState; 5 | (function (CheckState) { 6 | CheckState["Error"] = "error"; 7 | CheckState["Failure"] = "failure"; 8 | CheckState["Pending"] = "pending"; 9 | CheckState["Success"] = "success"; 10 | })(CheckState = exports.CheckState || (exports.CheckState = {})); 11 | class CheckContext { 12 | constructor(api) { 13 | this.api = api; 14 | } 15 | getAPI() { 16 | return this.api; 17 | } 18 | getResult() { 19 | return this.result; 20 | } 21 | pending(status) { 22 | this.result = { 23 | state: CheckState.Pending, 24 | ...status, 25 | }; 26 | } 27 | failure(status) { 28 | this.result = { 29 | state: CheckState.Failure, 30 | ...status, 31 | }; 32 | } 33 | success(status) { 34 | this.result = { 35 | state: CheckState.Success, 36 | ...status, 37 | }; 38 | } 39 | error(status) { 40 | this.result = { 41 | state: CheckState.Error, 42 | ...status, 43 | }; 44 | } 45 | reset() { 46 | this.result = undefined; 47 | } 48 | } 49 | exports.CheckContext = CheckContext; 50 | //# sourceMappingURL=types.js.map -------------------------------------------------------------------------------- /pr-checks/types.ts: -------------------------------------------------------------------------------- 1 | import { PullRequest } from '../api/api' 2 | 3 | export enum CheckState { 4 | Error = 'error', 5 | Failure = 'failure', 6 | Pending = 'pending', 7 | Success = 'success', 8 | } 9 | 10 | export type CheckResult = { 11 | state: CheckState 12 | sha: string 13 | title: string 14 | description?: string 15 | targetURL?: string 16 | } 17 | 18 | export type SubscribeCallback = (checkContext: CheckContext) => Promise 19 | 20 | export type CheckSubscriber = { 21 | on(events: string | string[], callback: SubscribeCallback): void 22 | on(events: string | string[], actions: string | string[], callback: SubscribeCallback): void 23 | } 24 | 25 | export type ChecksAPI = { 26 | getPullRequest(): Promise 27 | } 28 | 29 | export type API = { 30 | createStatus( 31 | sha: string, 32 | context: string, 33 | state: 'error' | 'failure' | 'pending' | 'success', 34 | description?: string, 35 | targetUrl?: string, 36 | ): Promise 37 | } & ChecksAPI 38 | 39 | export class CheckContext { 40 | private result: CheckResult | undefined 41 | 42 | constructor(private api: ChecksAPI) {} 43 | 44 | getAPI() { 45 | return this.api 46 | } 47 | 48 | getResult(): CheckResult | undefined { 49 | return this.result 50 | } 51 | 52 | pending(status: { sha: string; title: string; description?: string; targetURL?: string }) { 53 | this.result = { 54 | state: CheckState.Pending, 55 | ...status, 56 | } 57 | } 58 | 59 | failure(status: { sha: string; title: string; description?: string; targetURL?: string }) { 60 | this.result = { 61 | state: CheckState.Failure, 62 | ...status, 63 | } 64 | } 65 | 66 | success(status: { sha: string; title: string; description?: string; targetURL?: string }) { 67 | this.result = { 68 | state: CheckState.Success, 69 | ...status, 70 | } 71 | } 72 | 73 | error(status: { sha: string; title: string; description?: string; targetURL?: string }) { 74 | this.result = { 75 | state: CheckState.Error, 76 | ...status, 77 | } 78 | } 79 | 80 | reset() { 81 | this.result = undefined 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /release-notes-appender/FileAppender.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.FileAppender = void 0; 7 | const fs_1 = __importDefault(require("fs")); 8 | const path_1 = __importDefault(require("path")); 9 | const utils_1 = require("../common/utils"); 10 | class FileAppender { 11 | constructor(opts = {}) { 12 | this.lines = []; 13 | this.options = {}; 14 | this.options = opts; 15 | } 16 | loadFile(relPath) { 17 | let filePath = this.options.cwd ? path_1.default.resolve(this.options.cwd, relPath) : relPath; 18 | if (!fs_1.default.existsSync(filePath)) { 19 | throw new Error(`File not found ${filePath}`); 20 | } 21 | const fileContent = fs_1.default.readFileSync(filePath, 'utf-8'); 22 | this.lines = (0, utils_1.splitStringIntoLines)(fileContent); 23 | } 24 | append(content) { 25 | this.lines.push(content); 26 | } 27 | writeFile(relPath) { 28 | let filePath = this.options.cwd ? path_1.default.resolve(this.options.cwd, relPath) : relPath; 29 | fs_1.default.writeFileSync(filePath, this.getContent(), { encoding: 'utf-8' }); 30 | } 31 | getContent() { 32 | return this.lines.join('\r\n'); 33 | } 34 | } 35 | exports.FileAppender = FileAppender; 36 | //# sourceMappingURL=FileAppender.js.map -------------------------------------------------------------------------------- /release-notes-appender/FileAppender.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { splitStringIntoLines } from '../common/utils' 4 | 5 | interface FileAppenderOptions { 6 | cwd?: string 7 | } 8 | 9 | export class FileAppender { 10 | private lines: string[] = [] 11 | private options: FileAppenderOptions = {} 12 | 13 | constructor(opts: FileAppenderOptions = {}) { 14 | this.options = opts 15 | } 16 | 17 | loadFile(relPath: string) { 18 | let filePath = this.options.cwd ? path.resolve(this.options.cwd, relPath) : relPath 19 | if (!fs.existsSync(filePath)) { 20 | throw new Error(`File not found ${filePath}`) 21 | } 22 | 23 | const fileContent = fs.readFileSync(filePath, 'utf-8') 24 | this.lines = splitStringIntoLines(fileContent) 25 | } 26 | 27 | append(content: string) { 28 | this.lines.push(content) 29 | } 30 | 31 | writeFile(relPath: string) { 32 | let filePath = this.options.cwd ? path.resolve(this.options.cwd, relPath) : relPath 33 | fs.writeFileSync(filePath, this.getContent(), { encoding: 'utf-8' }) 34 | } 35 | 36 | public getContent() { 37 | return this.lines.join('\r\n') 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /release-notes-appender/action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Notes Appender 3 | description: Automatically append a PR to the next release's release notes 4 | inputs: 5 | token: 6 | description: GitHub token with issue, comment, and label read/write permissions 7 | default: ${{ github.token }} 8 | title: 9 | description: Title for the PR to be created 10 | default: "[Release Notes Appender] Add PR #{{pullRequestNumber}}: {{originalTitle}}" 11 | releaseNotesFile: 12 | description: Path to the files to append to 13 | required: true 14 | labelsToAdd: 15 | description: Comma separated list of labels to add to the release notes PR. 16 | required: false 17 | metricsWriteAPIKey: 18 | description: Grafana Cloud metrics api key 19 | required: false 20 | runs: 21 | using: 'node20' 22 | main: 'index.js' 23 | -------------------------------------------------------------------------------- /release-notes-appender/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.getLabelsToAdd = void 0; 4 | const core_1 = require("@actions/core"); 5 | const github_1 = require("@actions/github"); 6 | const Action_1 = require("../common/Action"); 7 | const release_1 = require("./release"); 8 | class ReleaseNotesAppender extends Action_1.Action { 9 | constructor() { 10 | super(...arguments); 11 | this.id = 'ReleaseNotesAppender'; 12 | } 13 | async onClosed(issue) { 14 | return this.release(issue); 15 | } 16 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 17 | async onLabeled(issue, _label) { 18 | return this.release(issue); 19 | } 20 | async release(issue) { 21 | try { 22 | await (0, release_1.release)({ 23 | labelsToAdd: (0, exports.getLabelsToAdd)((0, core_1.getInput)('labelsToAdd')), 24 | payload: github_1.context.payload, 25 | titleTemplate: (0, core_1.getInput)('title'), 26 | releaseNotesFile: (0, core_1.getInput)('releaseNotesFile'), 27 | github: issue.octokit, 28 | token: this.getToken(), 29 | sender: github_1.context.payload.sender, 30 | }); 31 | } 32 | catch (error) { 33 | if (error instanceof Error) { 34 | (0, core_1.error)(error); 35 | (0, core_1.setFailed)(error.message); 36 | } 37 | } 38 | } 39 | } 40 | const getLabelsToAdd = (input) => { 41 | if (input === undefined || input === '') { 42 | return []; 43 | } 44 | const labels = input.split(','); 45 | return labels.map((v) => v.trim()).filter((v) => v !== ''); 46 | }; 47 | exports.getLabelsToAdd = getLabelsToAdd; 48 | new ReleaseNotesAppender().run(); // eslint-disable-line 49 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /release-notes-appender/index.ts: -------------------------------------------------------------------------------- 1 | import { error as logError, getInput, setFailed } from '@actions/core' 2 | import { context } from '@actions/github' 3 | import { EventPayloads } from '@octokit/webhooks' 4 | import { OctoKitIssue } from '../api/octokit' 5 | import { Action } from '../common/Action' 6 | import { release } from './release' 7 | 8 | class ReleaseNotesAppender extends Action { 9 | id = 'ReleaseNotesAppender' 10 | 11 | async onClosed(issue: OctoKitIssue) { 12 | return this.release(issue) 13 | } 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | async onLabeled(issue: OctoKitIssue, _label: string) { 16 | return this.release(issue) 17 | } 18 | 19 | async release(issue: OctoKitIssue) { 20 | try { 21 | await release({ 22 | labelsToAdd: getLabelsToAdd(getInput('labelsToAdd')), 23 | payload: context.payload as EventPayloads.WebhookPayloadPullRequest, 24 | titleTemplate: getInput('title'), 25 | releaseNotesFile: getInput('releaseNotesFile'), 26 | github: issue.octokit, 27 | token: this.getToken(), 28 | sender: context.payload.sender as EventPayloads.PayloadSender, 29 | }) 30 | } catch (error) { 31 | if (error instanceof Error) { 32 | logError(error) 33 | setFailed(error.message) 34 | } 35 | } 36 | } 37 | } 38 | 39 | export const getLabelsToAdd = (input: string | undefined): string[] => { 40 | if (input === undefined || input === '') { 41 | return [] 42 | } 43 | 44 | const labels = input.split(',') 45 | return labels.map((v) => v.trim()).filter((v) => v !== '') 46 | } 47 | 48 | new ReleaseNotesAppender().run() // eslint-disable-line 49 | -------------------------------------------------------------------------------- /remove-milestone/action.yml: -------------------------------------------------------------------------------- 1 | name: Remove milestone 2 | description: Remove any open issues and pull requests from the current milestone 3 | inputs: 4 | token: 5 | description: GitHub token with issue, comment, and label read/write permissions. 6 | default: ${{ github.token }} 7 | version_call: 8 | description: Version number invoked from workflow 9 | runs: 10 | using: 'node20' 11 | main: 'index.js' 12 | -------------------------------------------------------------------------------- /remove-milestone/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const github_1 = require("@actions/github"); 4 | const Action_1 = require("../common/Action"); 5 | class RemoveMilestone extends Action_1.Action { 6 | constructor() { 7 | super(...arguments); 8 | this.id = 'RemoveMilestone'; 9 | } 10 | async onTriggered(octokit) { 11 | const { owner, repo } = github_1.context.repo; 12 | const version = this.getVersion(); 13 | for (const issue of await getIssuesForVersion(octokit, version)) { 14 | await octokit.octokit.issues.update({ 15 | owner, 16 | repo, 17 | issue_number: issue.number, 18 | milestone: null, 19 | }); 20 | await octokit.octokit.issues.createComment({ 21 | body: `This issue was removed from the ${version} milestone because ${version} is currently being released.`, 22 | issue_number: issue.number, 23 | owner, 24 | repo, 25 | }); 26 | } 27 | for (const issue of await getPullRequestsForVersion(octokit, version)) { 28 | await octokit.octokit.issues.update({ 29 | owner, 30 | repo, 31 | issue_number: issue.number, 32 | milestone: null, 33 | }); 34 | await octokit.octokit.issues.createComment({ 35 | body: `This pull request was removed from the ${version} milestone because ${version} is currently being released.`, 36 | issue_number: issue.number, 37 | owner, 38 | repo, 39 | }); 40 | } 41 | } 42 | } 43 | async function getIssuesForVersion(octokit, version) { 44 | const issueList = []; 45 | for await (const page of octokit.query({ q: `is:issue is:open milestone:${version}` })) { 46 | for (const issue of page) { 47 | issueList.push(await issue.getIssue()); 48 | } 49 | } 50 | return issueList; 51 | } 52 | async function getPullRequestsForVersion(octokit, version) { 53 | const issueList = []; 54 | for await (const page of octokit.query({ q: `is:pr is:open milestone:${version} base:main` })) { 55 | for (const issue of page) { 56 | issueList.push(await issue.getIssue()); 57 | } 58 | } 59 | return issueList; 60 | } 61 | new RemoveMilestone().run(); // eslint-disable-line 62 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /remove-milestone/index.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import { Action } from '../common/Action' 3 | import { OctoKit } from '../api/octokit' 4 | import { Issue } from '../api/api' 5 | 6 | class RemoveMilestone extends Action { 7 | id = 'RemoveMilestone' 8 | 9 | async onTriggered(octokit: OctoKit) { 10 | const { owner, repo } = context.repo 11 | const version = this.getVersion() 12 | 13 | for (const issue of await getIssuesForVersion(octokit, version)) { 14 | await octokit.octokit.issues.update({ 15 | owner, 16 | repo, 17 | issue_number: issue.number, 18 | milestone: null, 19 | }) 20 | 21 | await octokit.octokit.issues.createComment({ 22 | body: `This issue was removed from the ${version} milestone because ${version} is currently being released.`, 23 | issue_number: issue.number, 24 | owner, 25 | repo, 26 | }) 27 | } 28 | 29 | for (const issue of await getPullRequestsForVersion(octokit, version)) { 30 | await octokit.octokit.issues.update({ 31 | owner, 32 | repo, 33 | issue_number: issue.number, 34 | milestone: null, 35 | }) 36 | 37 | await octokit.octokit.issues.createComment({ 38 | body: `This pull request was removed from the ${version} milestone because ${version} is currently being released.`, 39 | issue_number: issue.number, 40 | owner, 41 | repo, 42 | }) 43 | } 44 | } 45 | } 46 | 47 | async function getIssuesForVersion(octokit: OctoKit, version: any): Promise { 48 | const issueList = [] 49 | 50 | for await (const page of octokit.query({ q: `is:issue is:open milestone:${version}` })) { 51 | for (const issue of page) { 52 | issueList.push(await issue.getIssue()) 53 | } 54 | } 55 | 56 | return issueList 57 | } 58 | 59 | async function getPullRequestsForVersion(octokit: OctoKit, version: any): Promise { 60 | const issueList = [] 61 | 62 | for await (const page of octokit.query({ q: `is:pr is:open milestone:${version} base:main` })) { 63 | for (const issue of page) { 64 | issueList.push(await issue.getIssue()) 65 | } 66 | } 67 | 68 | return issueList 69 | } 70 | 71 | new RemoveMilestone().run() // eslint-disable-line 72 | -------------------------------------------------------------------------------- /repository-dispatch/action.yml: -------------------------------------------------------------------------------- 1 | name: Repository dispatch 2 | description: Create a repository dispatch event 3 | inputs: 4 | token: 5 | description: GitHub token with repo permissions 6 | default: ${{ github.token }} 7 | repository: 8 | description: Full name of the repository to send the dispatch event to 9 | default: ${{ github.repository }} 10 | event_type: 11 | description: Custom webhook event name 12 | require: true 13 | client_payload: 14 | description: JSON payload with extra information that your action or workflow may use 15 | default: '{}' 16 | runs: 17 | using: 'node20' 18 | main: 'index.js' 19 | -------------------------------------------------------------------------------- /repository-dispatch/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const github_1 = require("@actions/github"); 4 | const Action_1 = require("../common/Action"); 5 | const octokit_1 = require("../api/octokit"); 6 | const core_1 = require("@actions/core"); 7 | class RepositoryDispatch extends Action_1.Action { 8 | constructor() { 9 | const token = (0, core_1.getInput)('token'); 10 | const eventType = (0, core_1.getInput)('event_type'); 11 | if (!token && eventType === 'oss-pull-request') { 12 | console.log("Token is empty, can't dispatch event. This is expected for PRs coming from forks, please check that the changes are compatible with Enterprise before merging this PR."); 13 | } 14 | super(); 15 | this.id = 'RepositoryDispatch'; 16 | } 17 | async runAction() { 18 | const repository = (0, core_1.getInput)('repository'); 19 | if (!repository) { 20 | throw new Error('Missing repository'); 21 | } 22 | const api = new octokit_1.OctoKit(this.getToken(), github_1.context.repo); 23 | const [owner, repo] = repository.split('/'); 24 | await api.octokit.repos.createDispatchEvent({ 25 | owner: owner, 26 | repo: repo, 27 | event_type: (0, core_1.getInput)('event_type'), 28 | client_payload: JSON.parse((0, core_1.getInput)('client_payload')), 29 | }); 30 | } 31 | } 32 | new RepositoryDispatch().run(); // eslint-disable-line 33 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /repository-dispatch/index.ts: -------------------------------------------------------------------------------- 1 | import { context } from '@actions/github' 2 | import { Action } from '../common/Action' 3 | import { OctoKit } from '../api/octokit' 4 | import { getInput } from '@actions/core' 5 | 6 | class RepositoryDispatch extends Action { 7 | id = 'RepositoryDispatch' 8 | 9 | constructor() { 10 | const token = getInput('token') 11 | const eventType = getInput('event_type') 12 | if (!token && eventType === 'oss-pull-request') { 13 | console.log( 14 | "Token is empty, can't dispatch event. This is expected for PRs coming from forks, please check that the changes are compatible with Enterprise before merging this PR.", 15 | ) 16 | } 17 | 18 | super() 19 | } 20 | 21 | async runAction(): Promise { 22 | const repository = getInput('repository') 23 | if (!repository) { 24 | throw new Error('Missing repository') 25 | } 26 | 27 | const api = new OctoKit(this.getToken(), context.repo) 28 | 29 | const [owner, repo] = repository.split('/') 30 | 31 | await api.octokit.repos.createDispatchEvent({ 32 | owner: owner, 33 | repo: repo, 34 | event_type: getInput('event_type'), 35 | client_payload: JSON.parse(getInput('client_payload')), 36 | }) 37 | } 38 | } 39 | 40 | new RepositoryDispatch().run() // eslint-disable-line 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "strict": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "removeComments": false, 8 | "esModuleInterop": true, 9 | "resolveJsonModule": true, 10 | "sourceMap": true, 11 | "lib": ["es2021"], 12 | "skipLibCheck": true, 13 | }, 14 | "exclude": ["**/*.test.ts", "**/vm-filesystem/**", "__mocks__/**/*"] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "resolveJsonModule": true, 7 | "noEmit": true, 8 | "lib": [ 9 | "es2020" 10 | ] 11 | }, 12 | "include": [ 13 | "**/*.test.ts", 14 | "__mocks__/**/*.ts" 15 | ] 16 | } -------------------------------------------------------------------------------- /update-changelog/ChangelogBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import { Query } from '../api/api' 3 | import { OctoKit } from '../api/octokit' 4 | import { Testbed, TestbedIssueConstructorArgs } from '../api/testbed' 5 | import { 6 | ChangelogBuilder, 7 | BREAKING_SECTION_START, 8 | DEPRECATION_SECTION_START, 9 | BUG_LABEL, 10 | CHANGELOG_LABEL, 11 | GRAFANA_UI_LABEL, 12 | } from './ChangelogBuilder' 13 | 14 | describe('ChangelogBuilder', () => { 15 | it('Should build correct release notes', async () => { 16 | const issues: TestbedIssueConstructorArgs[] = [ 17 | { 18 | issue: { 19 | title: 'Has no labels', 20 | }, 21 | }, 22 | { 23 | issue: { 24 | title: 'Alerting: Fixed really bad issue', 25 | }, 26 | labels: [CHANGELOG_LABEL], 27 | }, 28 | { 29 | issue: { 30 | title: 'Login: New feature', 31 | }, 32 | labels: [CHANGELOG_LABEL], 33 | }, 34 | { 35 | issue: { 36 | title: 'Auth: Prevent errors from happening', 37 | }, 38 | labels: [CHANGELOG_LABEL, BUG_LABEL], 39 | }, 40 | { 41 | issue: { 42 | title: 'API: Fixed api issue', 43 | isPullRequest: true, 44 | author: { 45 | name: 'torkelo', 46 | }, 47 | }, 48 | labels: [CHANGELOG_LABEL], 49 | }, 50 | { 51 | issue: { 52 | title: 'Button: Changed prop name for button', 53 | isPullRequest: true, 54 | author: { 55 | name: 'torkelo', 56 | }, 57 | }, 58 | labels: [CHANGELOG_LABEL, GRAFANA_UI_LABEL], 59 | }, 60 | { 61 | issue: { 62 | title: 'Dashboard: Issue with deprecation notice', 63 | body: ` 64 | asdasd 65 | asdasd 66 | 67 | ### ${BREAKING_SECTION_START} 68 | Here is the content of this breaking change notice.`, 69 | isPullRequest: true, 70 | author: { 71 | name: 'torkelo', 72 | }, 73 | }, 74 | labels: [CHANGELOG_LABEL], 75 | }, 76 | { 77 | issue: { 78 | title: 'Variables: Variables deprecated', 79 | body: ` 80 | asdasd 81 | asdasd 82 | 83 | ### ${DEPRECATION_SECTION_START} 84 | Variables have been deprecated`, 85 | isPullRequest: true, 86 | author: { 87 | name: 'torkelo', 88 | }, 89 | }, 90 | labels: [CHANGELOG_LABEL], 91 | }, 92 | ] 93 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 94 | const queryRunner = async function* (_: Query): AsyncIterableIterator { 95 | yield issues 96 | } 97 | 98 | const testbed = new Testbed({ 99 | queryRunner, 100 | milestone: { closed_at: '2020-11-11T17:15:26Z', number: 123, title: '7.3.3' }, 101 | }) 102 | 103 | const builder = new ChangelogBuilder(testbed, '7.3.3') 104 | const text = await builder.buildChangelog({ useDocsHeader: false }) 105 | expect(text).toMatchSnapshot('v1') 106 | }) 107 | 108 | it('Should be able to get notes with docs header', async () => { 109 | const issues: TestbedIssueConstructorArgs[] = [ 110 | { 111 | issue: { 112 | title: 'Alerting: Fixed really bad issue', 113 | }, 114 | labels: [CHANGELOG_LABEL], 115 | }, 116 | ] 117 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 118 | const queryRunner = async function* (_: Query): AsyncIterableIterator { 119 | yield issues 120 | } 121 | 122 | const testbed = new Testbed({ 123 | queryRunner, 124 | milestone: { closed_at: '2020-11-11T17:15:26Z' }, 125 | }) 126 | 127 | const builder = new ChangelogBuilder(testbed, '7.3.3') 128 | // Calling this first as this is how it's used from the action 129 | await builder.buildChangelog({ useDocsHeader: false }) 130 | const text = await builder.buildChangelog({ useDocsHeader: true }) 131 | expect(text).toMatchInlineSnapshot(` 132 | "# Release notes for Grafana 7.3.3 133 | 134 | ### Bug fixes 135 | 136 | - **Alerting:** Fixed really bad issue. [#1](https://github.com/grafana/grafana/issues/1) 137 | - **Alerting:** Fixed really bad issue. (Enterprise) 138 | " 139 | `) 140 | 141 | expect(builder.getTitle()).toBe('Release notes for Grafana 7.3.3') 142 | }) 143 | 144 | it.skip('integration test', async () => { 145 | dotenv.config() 146 | 147 | const token = process.env.TOKEN 148 | const repo = process.env.REPO 149 | const owner = process.env.OWNER 150 | 151 | const octokit = new OctoKit(token, { repo, owner }) 152 | const builder = new ChangelogBuilder(octokit, '1.0.0') 153 | const text = await builder.buildChangelog({}) 154 | console.log(text) 155 | expect(text).toEqual('asd') 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /update-changelog/FileUpdater.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.FileUpdater = void 0; 7 | const fs_1 = __importDefault(require("fs")); 8 | const lodash_1 = require("lodash"); 9 | const utils_1 = require("../common/utils"); 10 | const semver_1 = __importDefault(require("semver")); 11 | class FileUpdater { 12 | constructor() { 13 | this.lines = []; 14 | } 15 | loadFile(filePath) { 16 | if (!fs_1.default.existsSync(filePath)) { 17 | throw new Error(`File not found ${filePath}`); 18 | } 19 | const fileContent = fs_1.default.readFileSync(filePath, 'utf-8'); 20 | this.lines = (0, utils_1.splitStringIntoLines)(fileContent); 21 | } 22 | getLines() { 23 | return this.lines; 24 | } 25 | update({ version, content }) { 26 | const startMarker = new RegExp(``, '', ...newLines, ``]); 53 | } 54 | else { 55 | // remove the lines between the markers and add the updates lines 56 | this.lines.splice(startIndex, endIndex - startIndex, '', ...newLines); 57 | } 58 | } 59 | writeFile(filePath) { 60 | fs_1.default.writeFileSync(filePath, this.getContent(), { encoding: 'utf-8' }); 61 | } 62 | getContent() { 63 | return this.lines.join('\r\n'); 64 | } 65 | } 66 | exports.FileUpdater = FileUpdater; 67 | //# sourceMappingURL=FileUpdater.js.map -------------------------------------------------------------------------------- /update-changelog/FileUpdater.test.ts: -------------------------------------------------------------------------------- 1 | import { FileUpdater } from './FileUpdater' 2 | 3 | describe('FileUpdater', () => { 4 | describe('Can load file', () => { 5 | it('should have lines', () => { 6 | const updater = new FileUpdater() 7 | updater.loadFile(`${__dirname}/testdata/changelog1.md`) 8 | 9 | expect(updater.getLines().length).toBeGreaterThan(17) 10 | }) 11 | }) 12 | 13 | describe('When adding new minor release', () => { 14 | it('should add to beginning of file', () => { 15 | const updater = new FileUpdater() 16 | updater.loadFile(`${__dirname}/testdata/changelog1.md`) 17 | 18 | updater.update({ 19 | version: '8.1.0-beta1', 20 | content: `Updated content\n`, 21 | }) 22 | 23 | expect(updater.getContent()).toMatchInlineSnapshot(` 24 | " 25 | 26 | 27 | Updated content 28 | 29 | 30 | 31 | 32 | 33 | # 8.0.0 (unreleased) 34 | 35 | 36 | 37 | 38 | 39 | # 7.3.2 (2020-11-11) 40 | 41 | 42 | 43 | 44 | 45 | # 7.3.1 (2020-10-30) 46 | 47 | 48 | " 49 | `) 50 | }) 51 | }) 52 | 53 | describe('When adding a new patch release with newer minor release at the top', () => { 54 | it('should add after last patch', () => { 55 | const updater = new FileUpdater() 56 | updater.loadFile(`${__dirname}/testdata/changelog1.md`) 57 | 58 | updater.update({ 59 | version: '7.3.3', 60 | content: `Updated content\n`, 61 | }) 62 | 63 | expect(updater.getContent()).toMatchInlineSnapshot(` 64 | " 65 | 66 | 67 | # 8.0.0 (unreleased) 68 | 69 | 70 | 71 | 72 | 73 | Updated content 74 | 75 | 76 | 77 | 78 | 79 | # 7.3.2 (2020-11-11) 80 | 81 | 82 | 83 | 84 | 85 | # 7.3.1 (2020-10-30) 86 | 87 | 88 | " 89 | `) 90 | }) 91 | }) 92 | 93 | describe('When updating a release', () => { 94 | it('should only update the correct release', () => { 95 | const updater = new FileUpdater() 96 | updater.loadFile(`${__dirname}/testdata/changelog1.md`) 97 | 98 | updater.update({ 99 | version: '7.3.2', 100 | content: `Updated content\n`, 101 | }) 102 | 103 | expect(updater.getContent()).toMatchInlineSnapshot(` 104 | " 105 | 106 | 107 | # 8.0.0 (unreleased) 108 | 109 | 110 | 111 | 112 | 113 | Updated content 114 | 115 | 116 | 117 | 118 | 119 | # 7.3.1 (2020-10-30) 120 | 121 | 122 | " 123 | `) 124 | }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /update-changelog/FileUpdater.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { escapeRegExp } from 'lodash' 3 | import { splitStringIntoLines } from '../common/utils' 4 | import semver from 'semver' 5 | 6 | export class FileUpdater { 7 | private lines: string[] = [] 8 | 9 | loadFile(filePath: string) { 10 | if (!fs.existsSync(filePath)) { 11 | throw new Error(`File not found ${filePath}`) 12 | } 13 | 14 | const fileContent = fs.readFileSync(filePath, 'utf-8') 15 | this.lines = splitStringIntoLines(fileContent) 16 | } 17 | 18 | getLines() { 19 | return this.lines 20 | } 21 | 22 | update({ version, content }: { version: string; content: string }) { 23 | const startMarker = new RegExp(``, '', ...newLines, ``], 59 | ) 60 | } else { 61 | // remove the lines between the markers and add the updates lines 62 | this.lines.splice(startIndex, endIndex - startIndex, '', ...newLines) 63 | } 64 | } 65 | 66 | writeFile(filePath: string) { 67 | fs.writeFileSync(filePath, this.getContent(), { encoding: 'utf-8' }) 68 | } 69 | 70 | public getContent() { 71 | return this.lines.join('\r\n') 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /update-changelog/action.yml: -------------------------------------------------------------------------------- 1 | name: Update changelog 2 | description: Update changelog 3 | inputs: 4 | token: 5 | description: GitHub token with issue, comment, and label read/write permissions 6 | default: ${{ github.token }} 7 | metricsWriteAPIKey: 8 | description: Grafana Cloud metrics api key 9 | required: false 10 | grafanabotForumKey: 11 | description: API key so Grot can publish changelog to community.grafana.com 12 | required: false 13 | version_call: 14 | description: Version number invoked from workflow 15 | runs: 16 | using: 'node20' 17 | main: 'index.js' 18 | -------------------------------------------------------------------------------- /update-changelog/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | // import { error as logError, getInput, setFailed } from '@actions/core' 7 | const github_1 = require("@actions/github"); 8 | // import { EventPayloads } from '@octokit/webhooks' 9 | // import { OctoKitIssue } from '../api/octokit' 10 | const Action_1 = require("../common/Action"); 11 | const exec_1 = require("@actions/exec"); 12 | const git_1 = require("../common/git"); 13 | const FileUpdater_1 = require("./FileUpdater"); 14 | const ChangelogBuilder_1 = require("./ChangelogBuilder"); 15 | const utils_1 = require("../common/utils"); 16 | const axios_1 = __importDefault(require("axios")); 17 | class UpdateChangelog extends Action_1.Action { 18 | constructor() { 19 | super(...arguments); 20 | this.id = 'UpdateChangelog'; 21 | } 22 | async onTriggered(octokit) { 23 | const { owner, repo } = github_1.context.repo; 24 | const token = this.getToken(); 25 | const version = this.getVersion(); 26 | const versionSplitted = version.split('.'); 27 | const versionMajorBranch = 'v' + versionSplitted[0] + '.' + versionSplitted[1] + '.' + 'x'; 28 | await (0, git_1.cloneRepo)({ token, owner, repo }); 29 | await (0, git_1.setConfig)('grafana-delivery-bot'); 30 | process.chdir(repo); 31 | const fileUpdater = new FileUpdater_1.FileUpdater(); 32 | const builder = new ChangelogBuilder_1.ChangelogBuilder(octokit, version); 33 | const changelogFile = './CHANGELOG.md'; 34 | const branchName = 'update-changelog'; 35 | const changelog = await builder.buildChangelog({ useDocsHeader: false }); 36 | const title = `Changelog: Updated changelog for ${version}`; 37 | // Update main changelog 38 | fileUpdater.loadFile(changelogFile); 39 | fileUpdater.update({ 40 | version: version, 41 | content: changelog, 42 | }); 43 | fileUpdater.writeFile(changelogFile); 44 | await npx('prettier', '--no-config', '--trailing-comma', 'es5', '--single-quote', '--print-width', '120', '--list-different', '**/*.md', '--write'); 45 | // look for the branch 46 | let branchExists; 47 | try { 48 | await git('ls-remote', '--heads', '--exit-code', `https://github.com/${owner}/${repo}.git`, branchName); 49 | branchExists = true; 50 | } 51 | catch (e) { 52 | branchExists = false; 53 | } 54 | // we delete the branch which also will delete the associated PR 55 | if (branchExists) { 56 | // check if there are open PR's 57 | const pulls = await octokit.octokit.pulls.list({ 58 | owner, 59 | repo, 60 | head: `${owner}:${branchName}`, 61 | }); 62 | // close open PRs 63 | for (const pull of pulls.data) { 64 | // leave a comment explaining why we're closing this PR 65 | await octokit.octokit.issues.createComment({ 66 | body: `This pull request has been closed because an updated changelog and release notes have been generated.`, 67 | issue_number: pull.number, 68 | owner, 69 | repo, 70 | }); 71 | // close pr 72 | await octokit.octokit.pulls.update({ 73 | owner, 74 | repo, 75 | pull_number: pull.number, 76 | state: 'closed', 77 | }); 78 | } 79 | // delete the branch 80 | await git('push', 'origin', '--delete', branchName); 81 | } 82 | await git('switch', '--create', branchName); 83 | await git('add', '-A'); 84 | await git('commit', '-m', `${title}`); 85 | await git('push', '--set-upstream', 'origin', branchName); 86 | const pr = await octokit.octokit.pulls.create({ 87 | base: 'main', 88 | body: 'This exciting! So much has changed!\nDO NOT CHANGE THE TITLES DIRECTLY IN THIS PR, everything in the PR is auto-generated.', 89 | head: branchName, 90 | owner, 91 | repo, 92 | title, 93 | }); 94 | if (pr.data.number) { 95 | await octokit.octokit.issues.addLabels({ 96 | issue_number: pr.data.number, 97 | owner, 98 | repo, 99 | labels: ['backport ' + versionMajorBranch, 'no-changelog'], 100 | }); 101 | } 102 | // publish changelog to community.grafana.com 103 | try { 104 | const apiKey = (0, utils_1.getInput)('grafanabotForumKey'); 105 | const forumData = { 106 | title: `Changelog: Updates in Grafana ${version}`, 107 | raw: `${changelog}`, 108 | category: 9, 109 | }; 110 | await (0, axios_1.default)({ 111 | url: 'https://community.grafana.com/posts.json', 112 | method: 'POST', 113 | headers: { 114 | 'Content-Type': 'application/json', 115 | 'Api-Key': `${apiKey}`, 116 | 'Api-Username': 'grafanabot', 117 | }, 118 | data: JSON.stringify(forumData), 119 | }); 120 | } 121 | catch (e) { 122 | console.log(e); 123 | } 124 | } 125 | } 126 | const git = async (...args) => { 127 | // await exec('git', args, { cwd: repo }) 128 | await (0, exec_1.exec)('git', args); 129 | }; 130 | const npx = async (...args) => { 131 | await (0, exec_1.exec)('npx', args); 132 | }; 133 | new UpdateChangelog().run(); // eslint-disable-line 134 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /update-changelog/index.ts: -------------------------------------------------------------------------------- 1 | // import { error as logError, getInput, setFailed } from '@actions/core' 2 | import { context } from '@actions/github' 3 | // import { EventPayloads } from '@octokit/webhooks' 4 | // import { OctoKitIssue } from '../api/octokit' 5 | import { Action } from '../common/Action' 6 | import { exec } from '@actions/exec' 7 | import { cloneRepo, setConfig } from '../common/git' 8 | import { OctoKit } from '../api/octokit' 9 | import { FileUpdater } from './FileUpdater' 10 | import { ChangelogBuilder } from './ChangelogBuilder' 11 | import { getInput } from '../common/utils' 12 | import axios from 'axios' 13 | 14 | class UpdateChangelog extends Action { 15 | id = 'UpdateChangelog' 16 | 17 | async onTriggered(octokit: OctoKit) { 18 | const { owner, repo } = context.repo 19 | const token = this.getToken() 20 | const version = this.getVersion() 21 | const versionSplitted = version.split('.') 22 | const versionMajorBranch = 'v' + versionSplitted[0] + '.' + versionSplitted[1] + '.' + 'x' 23 | 24 | await cloneRepo({ token, owner, repo }) 25 | await setConfig('grafana-delivery-bot') 26 | 27 | process.chdir(repo) 28 | 29 | const fileUpdater = new FileUpdater() 30 | const builder = new ChangelogBuilder(octokit, version) 31 | const changelogFile = './CHANGELOG.md' 32 | const branchName = 'update-changelog' 33 | const changelog = await builder.buildChangelog({ useDocsHeader: false }) 34 | const title = `Changelog: Updated changelog for ${version}` 35 | 36 | // Update main changelog 37 | fileUpdater.loadFile(changelogFile) 38 | fileUpdater.update({ 39 | version: version, 40 | content: changelog, 41 | }) 42 | fileUpdater.writeFile(changelogFile) 43 | 44 | await npx( 45 | 'prettier', 46 | '--no-config', 47 | '--trailing-comma', 48 | 'es5', 49 | '--single-quote', 50 | '--print-width', 51 | '120', 52 | '--list-different', 53 | '**/*.md', 54 | '--write', 55 | ) 56 | 57 | // look for the branch 58 | let branchExists 59 | try { 60 | await git( 61 | 'ls-remote', 62 | '--heads', 63 | '--exit-code', 64 | `https://github.com/${owner}/${repo}.git`, 65 | branchName, 66 | ) 67 | branchExists = true 68 | } catch (e) { 69 | branchExists = false 70 | } 71 | 72 | // we delete the branch which also will delete the associated PR 73 | if (branchExists) { 74 | // check if there are open PR's 75 | const pulls = await octokit.octokit.pulls.list({ 76 | owner, 77 | repo, 78 | head: `${owner}:${branchName}`, 79 | }) 80 | 81 | // close open PRs 82 | for (const pull of pulls.data) { 83 | // leave a comment explaining why we're closing this PR 84 | await octokit.octokit.issues.createComment({ 85 | body: `This pull request has been closed because an updated changelog and release notes have been generated.`, 86 | issue_number: pull.number, 87 | owner, 88 | repo, 89 | }) 90 | 91 | // close pr 92 | await octokit.octokit.pulls.update({ 93 | owner, 94 | repo, 95 | pull_number: pull.number, 96 | state: 'closed', 97 | }) 98 | } 99 | // delete the branch 100 | await git('push', 'origin', '--delete', branchName) 101 | } 102 | 103 | await git('switch', '--create', branchName) 104 | await git('add', '-A') 105 | await git('commit', '-m', `${title}`) 106 | await git('push', '--set-upstream', 'origin', branchName) 107 | 108 | const pr = await octokit.octokit.pulls.create({ 109 | base: 'main', 110 | body: 'This exciting! So much has changed!\nDO NOT CHANGE THE TITLES DIRECTLY IN THIS PR, everything in the PR is auto-generated.', 111 | head: branchName, 112 | owner, 113 | repo, 114 | title, 115 | }) 116 | 117 | if (pr.data.number) { 118 | await octokit.octokit.issues.addLabels({ 119 | issue_number: pr.data.number, 120 | owner, 121 | repo, 122 | labels: ['backport ' + versionMajorBranch, 'no-changelog'], 123 | }) 124 | } 125 | 126 | // publish changelog to community.grafana.com 127 | try { 128 | const apiKey = getInput('grafanabotForumKey') 129 | const forumData = { 130 | title: `Changelog: Updates in Grafana ${version}`, 131 | raw: `${changelog}`, 132 | category: 9, 133 | } 134 | await axios({ 135 | url: 'https://community.grafana.com/posts.json', 136 | method: 'POST', 137 | headers: { 138 | 'Content-Type': 'application/json', 139 | 'Api-Key': `${apiKey}`, 140 | 'Api-Username': 'grafanabot', 141 | }, 142 | data: JSON.stringify(forumData), 143 | }) 144 | } catch (e) { 145 | console.log(e) 146 | } 147 | } 148 | } 149 | 150 | const git = async (...args: string[]) => { 151 | // await exec('git', args, { cwd: repo }) 152 | await exec('git', args) 153 | } 154 | 155 | const npx = async (...args: string[]) => { 156 | await exec('npx', args) 157 | } 158 | 159 | new UpdateChangelog().run() // eslint-disable-line 160 | -------------------------------------------------------------------------------- /update-changelog/testdata/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Release notes" 3 | weight = 10000 4 | +++ 5 | 6 | # Release notes 7 | 8 | Here you can find detailed release notes that list everything that is included in every release as well as notices 9 | about deprecations, breaking changes as well as changes that relate to plugin development. 10 | 11 | - [Release notes for 8.4.0-beta1]({{< relref "release-notes-8-4-0-beta1" >}}) 12 | - [Release notes for 8.3.4]({{< relref "release-notes-8-3-3" >}}) 13 | - [Release notes for 8.3.3]({{< relref "release-notes-8-3-3" >}}) 14 | - [Release notes for 8.3.2]({{< relref "release-notes-8-3-2" >}}) 15 | - [Release notes for 8.3.1]({{< relref "release-notes-8-3-1" >}}) 16 | - [Release notes for 8.3.0]({{< relref "release-notes-8-3-0" >}}) 17 | - [Release notes for 8.3.0-beta2]({{< relref "release-notes-8-3-0-beta2" >}}) 18 | - [Release notes for 8.3.0-beta1]({{< relref "release-notes-8-3-0-beta1" >}}) 19 | - [Release notes for 8.2.7]({{< relref "release-notes-8-2-7" >}}) 20 | - [Release notes for 8.2.6]({{< relref "release-notes-8-2-6" >}}) 21 | - [Release notes for 8.2.5]({{< relref "release-notes-8-2-5" >}}) 22 | - [Release notes for 8.2.4]({{< relref "release-notes-8-2-4" >}}) 23 | - [Release notes for 8.2.3]({{< relref "release-notes-8-2-3" >}}) 24 | - [Release notes for 8.2.2]({{< relref "release-notes-8-2-2" >}}) 25 | - [Release notes for 8.2.1]({{< relref "release-notes-8-2-1" >}}) 26 | - [Release notes for 8.2.0]({{< relref "release-notes-8-2-0" >}}) 27 | - [Release notes for 8.2.0-beta2]({{< relref "release-notes-8-2-0-beta2" >}}) 28 | - [Release notes for 8.2.0-beta1]({{< relref "release-notes-8-2-0-beta1" >}}) 29 | - [Release notes for 8.1.8]({{< relref "release-notes-8-1-8" >}}) 30 | - [Release notes for 8.1.7]({{< relref "release-notes-8-1-7" >}}) 31 | - [Release notes for 8.1.6]({{< relref "release-notes-8-1-6" >}}) 32 | - [Release notes for 8.1.5]({{< relref "release-notes-8-1-5" >}}) 33 | - [Release notes for 8.1.4]({{< relref "release-notes-8-1-4" >}}) 34 | - [Release notes for 8.1.3]({{< relref "release-notes-8-1-3" >}}) 35 | - [Release notes for 8.1.2]({{< relref "release-notes-8-1-2" >}}) 36 | - [Release notes for 8.1.1]({{< relref "release-notes-8-1-1" >}}) 37 | - [Release notes for 8.1.0]({{< relref "release-notes-8-1-0" >}}) 38 | - [Release notes for 8.1.0-beta3]({{< relref "release-notes-8-1-0-beta3" >}}) 39 | - [Release notes for 8.1.0-beta2]({{< relref "release-notes-8-1-0-beta2" >}}) 40 | - [Release notes for 8.1.0-beta1]({{< relref "release-notes-8-1-0-beta1" >}}) 41 | - [Release notes for 8.0.7]({{< relref "release-notes-8-0-7" >}}) 42 | - [Release notes for 8.0.6]({{< relref "release-notes-8-0-6" >}}) 43 | - [Release notes for 8.0.5]({{< relref "release-notes-8-0-5" >}}) 44 | - [Release notes for 8.0.4]({{< relref "release-notes-8-0-4" >}}) 45 | - [Release notes for 8.0.3]({{< relref "release-notes-8-0-3" >}}) 46 | - [Release notes for 8.0.2]({{< relref "release-notes-8-0-2" >}}) 47 | - [Release notes for 8.0.1]({{< relref "release-notes-8-0-1" >}}) 48 | - [Release notes for 8.0.0]({{< relref "release-notes-8-0-0" >}}) 49 | - [Release notes for 8.0.0-beta3]({{< relref "release-notes-8-0-0-beta3" >}}) 50 | - [Release notes for 8.0.0-beta2]({{< relref "release-notes-8-0-0-beta2" >}}) 51 | - [Release notes for 8.0.0-beta1]({{< relref "release-notes-8-0-0-beta1" >}}) 52 | - [Release notes for 7.5.12]({{< relref "release-notes-7-5-12" >}}) 53 | - [Release notes for 7.5.11]({{< relref "release-notes-7-5-11" >}}) 54 | - [Release notes for 7.5.10]({{< relref "release-notes-7-5-10" >}}) 55 | - [Release notes for 7.5.9]({{< relref "release-notes-7-5-9" >}}) 56 | - [Release notes for 7.5.8]({{< relref "release-notes-7-5-8" >}}) 57 | - [Release notes for 7.5.7]({{< relref "release-notes-7-5-7" >}}) 58 | - [Release notes for 7.5.6]({{< relref "release-notes-7-5-6" >}}) 59 | - [Release notes for 7.5.5]({{< relref "release-notes-7-5-5" >}}) 60 | - [Release notes for 7.5.4]({{< relref "release-notes-7-5-4" >}}) 61 | - [Release notes for 7.5.3]({{< relref "release-notes-7-5-3" >}}) 62 | - [Release notes for 7.5.2]({{< relref "release-notes-7-5-2" >}}) 63 | - [Release notes for 7.5.1]({{< relref "release-notes-7-5-1" >}}) 64 | - [Release notes for 7.5.0]({{< relref "release-notes-7-5-0" >}}) 65 | - [Release notes for 7.5.0-beta2]({{< relref "release-notes-7-5-0-beta2" >}}) 66 | - [Release notes for 7.5.0-beta1]({{< relref "release-notes-7-5-0-beta1" >}}) 67 | - [Release notes for 7.4.5]({{< relref "release-notes-7-4-5" >}}) 68 | - [Release notes for 7.4.3]({{< relref "release-notes-7-4-3" >}}) 69 | - [Release notes for 7.4.2]({{< relref "release-notes-7-4-2" >}}) 70 | - [Release notes for 7.4.1]({{< relref "release-notes-7-4-1" >}}) 71 | - [Release notes for 7.4.0]({{< relref "release-notes-7-4-0" >}}) 72 | - [Release notes for 7.3.10]({{< relref "release-notes-7-3-10" >}}) 73 | - [Release notes for 7.3.7]({{< relref "release-notes-7-3-7" >}}) 74 | - [Release notes for 7.3.6]({{< relref "release-notes-7-3-6" >}}) 75 | - [Release notes for 7.3.5]({{< relref "release-notes-7-3-5" >}}) 76 | - [Release notes for 7.3.4]({{< relref "release-notes-7-3-4" >}}) 77 | - [Release notes for 7.3.3]({{< relref "release-notes-7-3-3" >}}) 78 | - [Release notes for 7.3.2]({{< relref "release-notes-7-3-2" >}}) 79 | - [Release notes for 7.3.1]({{< relref "release-notes-7-3-1" >}}) 80 | - [Release notes for 7.3.0]({{< relref "release-notes-7-3-0" >}}) 81 | -------------------------------------------------------------------------------- /update-changelog/testdata/changelog1.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # 8.0.0 (unreleased) 5 | 6 | 7 | 8 | 9 | 10 | # 7.3.2 (2020-11-11) 11 | 12 | 13 | 14 | 15 | 16 | # 7.3.1 (2020-10-30) 17 | 18 | 19 | -------------------------------------------------------------------------------- /update-project-epic/action.yml: -------------------------------------------------------------------------------- 1 | name: Update epic trending field 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | project_owner: 7 | description: GitHub Project Owner 8 | type: string 9 | default: 'grafana' 10 | project_number: 11 | description: GitHub Project Number 12 | type: number 13 | required: true 14 | field_name: 15 | description: GitHub Project Field Name 16 | type: string 17 | default: 'Trending' 18 | github_token: 19 | description: GitHub Token 20 | type: string 21 | required: true 22 | 23 | jobs: 24 | extract-trend: 25 | name: Extract trend 26 | runs-on: ubuntu-latest 27 | outputs: 28 | trend: ${{ steps.extract-trend.outputs.group1 }} 29 | trend_id: ${{ steps.convert-trend.outputs.trend_id }} 30 | error: ${{ steps.convert-trend.outputs.error }} 31 | result: ${{ steps.convert-trend.outputs.result }} 32 | steps: 33 | - id: extract-trend 34 | uses: kaisugi/action-regex-match@v1.0.1 35 | with: 36 | regex: '([\s\S]*?)' 37 | text: ${{ github.event.comment.body }} 38 | - id: convert-trend 39 | uses: actions/github-script@v7 40 | if: ${{ steps.extract-trend.outputs.group1 != '' }} 41 | with: 42 | github-token: ${{ inputs.github_token }} 43 | script: | 44 | const variables = { 45 | trend: `${{ steps.extract-trend.outputs.group1 }}`.trim(), 46 | org: '${{ inputs.project_owner }}', 47 | field_name: '${{ inputs.field_name }}', 48 | project_number: ${{ inputs.project_number }} 49 | }; 50 | 51 | const query = ` 52 | query($org: String!, $field_name: String!, $project_number:Int!, $trend: String!) { 53 | organization(login: $org) { 54 | projectV2(number: $project_number) { 55 | field(name: $field_name) { 56 | ... on ProjectV2SingleSelectField { 57 | options(names: [$trend]) { 58 | id 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | `; 66 | 67 | var result = {}; 68 | 69 | try { 70 | result = await github.graphql(query, variables); 71 | } catch (error) { 72 | core.setOutput("error", error.message) 73 | if (error.data) { 74 | result = error.data; 75 | core.setOutput("error", JSON.stringify(result)) 76 | } 77 | } 78 | 79 | core.setOutput("result", JSON.stringify(result)); 80 | 81 | const trend_id = result.organization.projectV2.field.options[0].id; 82 | 83 | core.setOutput("trend_id", trend_id); 84 | 85 | return; 86 | update-trending: 87 | name: Update epic trending field 88 | runs-on: ubuntu-latest 89 | needs: extract-trend 90 | if: ${{ needs.extract-trend.outputs.trend_id != '' }} 91 | steps: 92 | - id: get-project-id 93 | uses: monry/actions-get-project-id@v2 94 | with: 95 | # Personal Access Token that with `org:read` are granted. 96 | github-token: ${{ inputs.github_token }} 97 | 98 | # Owner name of project 99 | project-owner: ${{ inputs.project_owner }} 100 | 101 | # Number of project 102 | # 103 | # The project number is the number shown in the URL or list 104 | # https://github.com/users/monry/projects/123 105 | # ^^^ 106 | project-number: ${{ inputs.project_number }} 107 | - id: get-item-id 108 | uses: monry/actions-get-project-item-id@v2 109 | with: 110 | # Personal Access Token that with `repo` and `org:read` are granted. 111 | github-token: ${{ inputs.github_token }} 112 | project-id: ${{ steps.get-project-id.outputs.project-id }} 113 | issue-id: ${{ github.event.issue.node_id }} 114 | - uses: titoportas/update-project-fields@v0.1.0 115 | with: 116 | project-url: https://github.com/orgs/grafana/projects/${{ inputs.project_number }} 117 | github-token: ${{ inputs.github_token }} 118 | item-id: ${{ steps.get-item-id.outputs.project-item-id }} 119 | field-keys: Trending 120 | field-values: ${{ needs.extract-trend.outputs.trend_id }} 121 | 122 | --------------------------------------------------------------------------------