mirror of
https://github.com/stoatchat/action-git-town.git
synced 2026-06-30 21:47:56 -04:00
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"Version": "v3.0.0",
|
||||
"Verbose": false,
|
||||
"Format": "",
|
||||
"Debug": false,
|
||||
"IgnoreDefaults": false,
|
||||
"SpacesAftertabs": false,
|
||||
"NoColor": false,
|
||||
"Exclude": [],
|
||||
"AllowedContentTypes": [],
|
||||
"PassedFiles": [],
|
||||
"Disable": {
|
||||
"EndOfLine": false,
|
||||
"Indentation": false,
|
||||
"InsertFinalNewline": false,
|
||||
"TrimTrailingWhitespace": false,
|
||||
"IndentSize": false,
|
||||
"MaxLineLength": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const { resolve } = require('node:path')
|
||||
|
||||
const project = resolve(process.cwd(), 'tsconfig.json')
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
jest: false,
|
||||
},
|
||||
ignorePatterns: [".eslintrc.cjs"],
|
||||
extends: [
|
||||
require.resolve('@vercel/style-guide/eslint/node'),
|
||||
require.resolve('@vercel/style-guide/eslint/typescript'),
|
||||
'plugin:prettier/recommended'
|
||||
],
|
||||
parserOptions: {
|
||||
project,
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project
|
||||
},
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx']
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||
'@typescript-eslint/no-shadow': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'import/no-extraneous-dependencies': 'off'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
# Git Town configuration file
|
||||
|
||||
push-hook = true
|
||||
push-new-branches = false
|
||||
ship-delete-tracking-branch = false
|
||||
sync-before-ship = false
|
||||
sync-upstream = true
|
||||
|
||||
[branches]
|
||||
main = "main"
|
||||
|
||||
[sync-strategy]
|
||||
feature-branches = "merge"
|
||||
perennial-branches = "rebase"
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Setup Env
|
||||
description: Setup Node.js and install dependencies
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: npm
|
||||
|
||||
- name: Install Dependencies
|
||||
id: install
|
||||
shell: bash
|
||||
run: npm ci
|
||||
@@ -0,0 +1,26 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
actions-minor:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
npm-development:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
npm-production:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- patch
|
||||
@@ -0,0 +1,3 @@
|
||||
## Stack
|
||||
|
||||
<!-- branch-stack -->
|
||||
@@ -0,0 +1,60 @@
|
||||
# In TypeScript actions, `dist/` is a special directory. When you reference
|
||||
# an action with the `uses:` property, `dist/index.js` is the code that will be
|
||||
# run. For this project, the `dist/index.js` file is transpiled from other
|
||||
# source files. This workflow ensures the `dist/` directory contains the
|
||||
# expected transpiled code.
|
||||
#
|
||||
# If this workflow is run from a feature branch, it will act as an additional CI
|
||||
# check and fail if the checked-in `dist/` directory does not match what is
|
||||
# expected from the build.
|
||||
name: Check Transpiled JavaScript
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: check-dist-${{ github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-dist:
|
||||
name: Check dist/
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Env
|
||||
id: setup-env
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Build dist/ Directory
|
||||
id: build
|
||||
run: npm run build
|
||||
|
||||
- name: Compare Directories
|
||||
id: diff
|
||||
run: |
|
||||
if [ "$(git diff --ignore-space-at-eol --text dist/ | wc -l)" -gt "0" ]; then
|
||||
echo "Detected uncommitted changes after build. See status below:"
|
||||
git diff --ignore-space-at-eol --text dist/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- if: ${{ failure() && steps.diff.outcome == 'failure' }}
|
||||
name: Upload Artifact
|
||||
id: upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
@@ -0,0 +1,69 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
name: Lint - ESLint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Env
|
||||
id: setup-env
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Lint
|
||||
id: lint
|
||||
run: |
|
||||
npm run lint:eslint
|
||||
|
||||
editorconfig:
|
||||
name: Lint - EditorConfig
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Env
|
||||
id: setup-env
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Lint
|
||||
id: lint
|
||||
run: npm run lint:ec
|
||||
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Env
|
||||
id: setup-env
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Tests
|
||||
id: tests
|
||||
run: npm run test:ci
|
||||
@@ -0,0 +1,48 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: '31 7 * * 3'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: write
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language:
|
||||
- TypeScript
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
id: initialize
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
source-root: src
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@v3
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Git Town
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
concurrency:
|
||||
group: git-town-${{ github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
branch-stack:
|
||||
name: Display the Branch Stack
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Git Town
|
||||
uses: ./
|
||||
@@ -0,0 +1,3 @@
|
||||
!dist
|
||||
node_modules
|
||||
.DS_Store
|
||||
@@ -0,0 +1,3 @@
|
||||
npm exec lint-staged
|
||||
npm run lint:ec
|
||||
npm run test:ci
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
"src/**/*.{ts,tsx}": "npm exec eslint --fix --max-warnings=0"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
20.5.1
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @type {import("prettier").Config} */
|
||||
module.exports = {
|
||||
experimentalTernaries: true,
|
||||
printWidth: 90,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
quoteProps: 'as-needed',
|
||||
trailingComma: 'es5',
|
||||
arrowParens: 'always',
|
||||
endOfLine: 'lf',
|
||||
singleAttributePerLine: false
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# How to contribute
|
||||
|
||||
We invite everybody to help make Git Town better. Every contribution is welcome,
|
||||
no matter how big or small.
|
||||
|
||||
#### I want to report a bug or need a feature
|
||||
|
||||
please [open an issue](https://github.com/git-town/action/issues/new)
|
||||
|
||||
#### I want to fix a bug or add a feature
|
||||
|
||||
- see our [developer guidelines](DEVELOPMENT.md) to get started
|
||||
- if you plan a larger change or need guidance, connect with the maintainers by
|
||||
[opening an issue](https://github.com/git-town/action/issues/new)
|
||||
- otherwise, hack away and
|
||||
[send a pull request](https://help.github.com/articles/using-pull-requests)
|
||||
|
||||
#### I want to say thank you
|
||||
|
||||
Please star this repo on GitHub and tweet or blog about us!
|
||||
@@ -0,0 +1,15 @@
|
||||
# Developing the Git Town action source code
|
||||
|
||||
This page helps you get started hacking on the Git Town GitHub Action codebase.
|
||||
|
||||
## setup
|
||||
|
||||
1. install Node.js v20+
|
||||
2. fork the repository
|
||||
3. run `npm install` to install dependencies
|
||||
4. make your changes
|
||||
5. Use [act](https://github.com/nektos/act) to test your changes locally
|
||||
6. update tests
|
||||
7. run `npm run test` to run tests
|
||||
8. run `npm run build` to update the action entrypoint
|
||||
9. commit and make a pull request
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2018 the Git Town creators
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,2 +1,149 @@
|
||||
# action
|
||||
GitHub Action for code bases using Git Town's stashed commits feature
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/git-town/git-town/main/website/src/logo.svg">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/git-town/git-town/main/website/src/logo-dark.svg">
|
||||
<img alt="Git Town logo" src="https://raw.githubusercontent.com/git-town/git-town/main/website/src/logo.svg">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
# Git Town Action V1
|
||||
|
||||
This action visualizes your stacked changes when proposing pull requests on GitHub:
|
||||
|
||||
- `main`
|
||||
- ["git town park" command #3181](https://github.com/git-town/git-town/pull/3181) :point_left:
|
||||
- ["git town observe" command" #3186](https://github.com/git-town/git-town/pull/3186)
|
||||
|
||||
This allows you to easily see all related PRs for a given pull request, where
|
||||
you are in the stack, as well as navigate between PRs in a stack.
|
||||
|
||||
It is designed to work out of the box with [Git Town](https://github.com/git-town/git-town) v12+,
|
||||
but also supports previous versions via [manual configuration](#manual-configuration).
|
||||
|
||||
## What's New
|
||||
|
||||
Please refer to the [release page](https://github.com/git-town/action/releases/latest) for
|
||||
the latest release notes.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Create the GitHub Actions Workflow File
|
||||
|
||||
Create a workflow file called `git-town.yml` under `.github/workflows` with the following
|
||||
contents:
|
||||
|
||||
```yaml
|
||||
name: Git Town
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
git-town:
|
||||
name: Display the branch stack
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: git-town/action@v1
|
||||
```
|
||||
|
||||
Once this workflow is committed, the action will visualize your stacked changes
|
||||
whenever a pull request is created or updated. It also will automatically read
|
||||
your `.git-branches.toml` file to determine the main and perennial branches for
|
||||
your repository.
|
||||
|
||||
### Modify the Pull Request Template
|
||||
|
||||
By default, this action will append the visualization to the bottom of the PR description.
|
||||
If you are using a [pull request template](https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository),
|
||||
you can specify the location of the visualization in the template by adding a [HTML comment](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#hiding-content-with-comments)
|
||||
that contains `branch-stack` inside of it:
|
||||
|
||||
```md
|
||||
## Stack
|
||||
|
||||
<!-- branch-stack -->
|
||||
|
||||
## Checklist
|
||||
|
||||
[ ] Foo
|
||||
[ ] Bar
|
||||
[ ] Baz
|
||||
```
|
||||
|
||||
This action will look for this comment and insert the visualization underneath the comment
|
||||
when it runs. It will also leave behind the comment, so that the next time it runs, it will
|
||||
be able to use it again to update the visualization:
|
||||
|
||||
```md
|
||||
## Stack
|
||||
|
||||
<!-- branch-stack --> 👈 Still there!
|
||||
|
||||
- `main`
|
||||
- \#1 :point_left:
|
||||
- \#2
|
||||
|
||||
## Checklist
|
||||
|
||||
[ ] Foo
|
||||
[ ] Bar
|
||||
[ ] Baz
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Be careful not to add content between the comment and the
|
||||
> visualization, as this action will replace that content each time it
|
||||
> updates your PR. Adding content above the tag, or below the list is
|
||||
> safe though!
|
||||
|
||||
### Manual Configuration
|
||||
|
||||
If you are using Git Town v11 and below, or are setting up this action for a repository
|
||||
that doesn't have a `.git-branches.toml`, you will need to tell this action what the
|
||||
main branch and perennial branches are for your repository.
|
||||
|
||||
#### Main Branch
|
||||
|
||||
The main branch is the default parent branch for new feature branches, and can be
|
||||
specified using the `main-branch` input:
|
||||
|
||||
```yaml
|
||||
- uses: git-town/action@v1
|
||||
with:
|
||||
main-branch: 'main'
|
||||
```
|
||||
|
||||
This action will default to your repository's default branch, which it fetches via
|
||||
the GitHub REST API.
|
||||
|
||||
#### Perennial Branches
|
||||
|
||||
Perennial branches are long lived branches and are never shipped.
|
||||
|
||||
There are two ways to specify perennial branches: explicitly or via regex. This can
|
||||
be done with the `perennial-branches` and `perennial-regex` inputs respectively:
|
||||
|
||||
```yaml
|
||||
- uses: git-town/action@v1
|
||||
with:
|
||||
perennial-branches: |
|
||||
dev
|
||||
staging
|
||||
prod
|
||||
perennial-regex: '^release-.*$'
|
||||
```
|
||||
|
||||
Both inputs can be used at the same time. This action will merge the perennial
|
||||
branches given into a single, de-duplicated list.
|
||||
|
||||
## License
|
||||
|
||||
The scripts and documentation in this project are released under the [MIT License](LICENSE).
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
name: Git Town - GitHub Action
|
||||
description: Visualizes your stacked changes when proposing pull requests on GitHub
|
||||
|
||||
branding:
|
||||
icon: 'home'
|
||||
color: 'orange'
|
||||
|
||||
inputs:
|
||||
github-token:
|
||||
required: true
|
||||
default: ${{ github.token }}
|
||||
main-branch:
|
||||
required: false
|
||||
default: ''
|
||||
perennial-branches:
|
||||
required: false
|
||||
default: ''
|
||||
perennial-regex:
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'dist/index.js'
|
||||
Vendored
+112
File diff suppressed because one or more lines are too long
Generated
+8751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@git-town/action",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"main": "dist/index.js",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"scripts": {
|
||||
"dev": "npm run build --watch",
|
||||
"build": "esbuild src/index.ts --outfile=dist/index.js --bundle --minify --platform=node --target=node20",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest run",
|
||||
"lint:eslint": "eslint src --max-warnings=0",
|
||||
"lint:ec": "ec -config .editorconfig-checker",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^6.0.0",
|
||||
"graphology": "^0.25.4",
|
||||
"graphology-traversal": "^0.3.1",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"toml": "^3.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.24",
|
||||
"@vercel/style-guide": "^5.2.0",
|
||||
"editorconfig-checker": "^5.1.5",
|
||||
"esbuild": "^0.20.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"prettier": "^3.2.5",
|
||||
"vitest": "^1.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as core from '@actions/core'
|
||||
import * as toml from 'toml'
|
||||
import * as z from 'zod'
|
||||
|
||||
const { object, array, string } = z
|
||||
|
||||
const configSchema = object({
|
||||
branches: object({
|
||||
main: string().optional(),
|
||||
perennials: array(string()).optional(),
|
||||
perennialRegex: string().optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export type Config = z.infer<typeof configSchema>
|
||||
|
||||
let configFile
|
||||
|
||||
try {
|
||||
configFile = fs.readFileSync('.git-branches.toml').toString()
|
||||
} catch {
|
||||
configFile = undefined
|
||||
}
|
||||
|
||||
const parsed = configSchema.safeParse(toml.parse(configFile ?? ''))
|
||||
|
||||
if (!parsed.success) {
|
||||
core.warning(
|
||||
'Failed to parse Git Town config. If this is a mistake, ensure that `.git-branches.toml` is valid.'
|
||||
)
|
||||
}
|
||||
|
||||
const config: Config | undefined = configFile && parsed.success ? parsed.data : undefined
|
||||
|
||||
export { config }
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import { main } from './main'
|
||||
import { inputs } from './inputs'
|
||||
import { config } from './config'
|
||||
|
||||
void run()
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const validTriggers = ['pull_request', 'pull_request_target']
|
||||
|
||||
if (!validTriggers.includes(github.context.eventName)) {
|
||||
core.setFailed(
|
||||
`Action only supports the following triggers: ${validTriggers.map((trigger) => `\`${trigger}\``).join(', ')}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const octokit = github.getOctokit(inputs.getToken())
|
||||
|
||||
const [mainBranch, perennialBranches, pullRequests] = await Promise.all([
|
||||
inputs.getMainBranch(octokit, config, github.context),
|
||||
inputs.getPerennialBranches(octokit, config, github.context),
|
||||
inputs.getPullRequests(octokit, github.context),
|
||||
])
|
||||
|
||||
const context = {
|
||||
octokit,
|
||||
currentPullRequest: inputs.getCurrentPullRequest(github.context),
|
||||
pullRequests,
|
||||
mainBranch,
|
||||
perennialBranches,
|
||||
}
|
||||
|
||||
void main(context)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
core.setFailed(error.message)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, beforeEach, it, expect, vi } from 'vitest'
|
||||
import type * as github from '@actions/github'
|
||||
import { inputs } from './inputs'
|
||||
import type { Octokit } from './types'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('getMainBranch', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('should default to default branch', async () => {
|
||||
const octokit = {
|
||||
rest: {
|
||||
repos: {
|
||||
get: async () => ({
|
||||
data: {
|
||||
default_branch: 'master',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit
|
||||
const config = {}
|
||||
const context = {
|
||||
repo: {},
|
||||
} as unknown as typeof github.context
|
||||
|
||||
const mainBranch = await inputs.getMainBranch(octokit, config, context)
|
||||
|
||||
expect(mainBranch).toBe('master')
|
||||
})
|
||||
|
||||
it('should override default with config', async () => {
|
||||
const octokit = {
|
||||
rest: {
|
||||
repos: {
|
||||
get: async () => ({
|
||||
data: {
|
||||
default_branch: 'master',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit
|
||||
const config = {
|
||||
branches: {
|
||||
main: 'main',
|
||||
},
|
||||
}
|
||||
const context = {
|
||||
repo: {},
|
||||
} as unknown as typeof github.context
|
||||
|
||||
const mainBranch = await inputs.getMainBranch(octokit, config, context)
|
||||
|
||||
expect(mainBranch).toBe('main')
|
||||
})
|
||||
|
||||
it('should override config with inputs', async () => {
|
||||
vi.stubEnv('INPUT_MAIN-BRANCH', 'prod')
|
||||
|
||||
const octokit = {
|
||||
rest: {
|
||||
repos: {
|
||||
get: async () => ({
|
||||
data: {
|
||||
default_branch: 'master',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit
|
||||
const config = {
|
||||
branches: {
|
||||
main: 'main',
|
||||
},
|
||||
}
|
||||
const context = {
|
||||
repo: {},
|
||||
} as unknown as typeof github.context
|
||||
|
||||
const mainBranch = await inputs.getMainBranch(octokit, config, context)
|
||||
|
||||
expect(mainBranch).toBe('prod')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPerennialBranches', () => {
|
||||
const octokit = {
|
||||
rest: {
|
||||
repos: {
|
||||
listBranches: async () => ({
|
||||
data: [
|
||||
{
|
||||
name: 'main',
|
||||
commit: { sha: '', url: '' },
|
||||
protection: false,
|
||||
},
|
||||
{
|
||||
name: 'release-v1.0.0',
|
||||
commit: { sha: '', url: '' },
|
||||
protection: false,
|
||||
},
|
||||
{
|
||||
name: 'v1.0.0',
|
||||
commit: { sha: '', url: '' },
|
||||
protection: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit
|
||||
const config = {
|
||||
branches: {
|
||||
perennials: ['dev', 'staging', 'prod'],
|
||||
perennialRegex: '^release-.*$',
|
||||
},
|
||||
}
|
||||
const context = {
|
||||
repo: {},
|
||||
} as unknown as typeof github.context
|
||||
|
||||
it('should default to no branches', async () => {
|
||||
const perennialBranches = await inputs.getPerennialBranches(
|
||||
octokit,
|
||||
undefined,
|
||||
context
|
||||
)
|
||||
|
||||
expect(perennialBranches).toStrictEqual([])
|
||||
})
|
||||
|
||||
it('should override default with config', async () => {
|
||||
const perennialBranches = await inputs.getPerennialBranches(octokit, config, context)
|
||||
|
||||
expect(perennialBranches).toStrictEqual(['dev', 'staging', 'prod', 'release-v1.0.0'])
|
||||
})
|
||||
|
||||
it('should override config with inputs', async () => {
|
||||
vi.stubEnv(
|
||||
'INPUT_PERENNIAL-BRANCHES',
|
||||
`
|
||||
test
|
||||
uat
|
||||
live
|
||||
`
|
||||
)
|
||||
vi.stubEnv('INPUT_PERENNIAL-REGEX', '^v.*$')
|
||||
|
||||
const perennialBranches = await inputs.getPerennialBranches(octokit, config, context)
|
||||
|
||||
expect(perennialBranches).toStrictEqual(['test', 'uat', 'live', 'v1.0.0'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCurrentPullRequest', () => {
|
||||
it('should return current pull request from action payload', () => {
|
||||
const validContext = {
|
||||
payload: {
|
||||
pull_request: {
|
||||
number: 100,
|
||||
base: { ref: 'main' },
|
||||
head: { ref: 'feat/git-town-action' },
|
||||
},
|
||||
},
|
||||
} as unknown as typeof github.context
|
||||
|
||||
const currentPullRequest = inputs.getCurrentPullRequest(validContext)
|
||||
|
||||
expect(currentPullRequest).toStrictEqual({
|
||||
number: 100,
|
||||
baseRefName: 'main',
|
||||
headRefName: 'feat/git-town-action',
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error when current pull request not found in action payload', () => {
|
||||
const invalidContext = {
|
||||
payload: {},
|
||||
} as unknown as typeof github.context
|
||||
|
||||
expect(() => inputs.getCurrentPullRequest(invalidContext)).toThrow()
|
||||
})
|
||||
})
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import * as core from '@actions/core'
|
||||
import type * as github from '@actions/github'
|
||||
import { pullRequestSchema } from './types'
|
||||
import type { PullRequest, Octokit } from './types'
|
||||
import type { Config } from './config'
|
||||
|
||||
export const inputs = {
|
||||
getToken() {
|
||||
return core.getInput('github-token', { required: true, trimWhitespace: true })
|
||||
},
|
||||
|
||||
async getMainBranch(
|
||||
octokit: Octokit,
|
||||
config: Config | undefined,
|
||||
context: typeof github.context
|
||||
): Promise<string> {
|
||||
const {
|
||||
data: { default_branch: defaultBranch },
|
||||
} = await octokit.rest.repos.get({
|
||||
...context.repo,
|
||||
})
|
||||
|
||||
const mainBranchInput = core.getInput('main-branch', {
|
||||
required: false,
|
||||
trimWhitespace: true,
|
||||
})
|
||||
|
||||
let mainBranch = defaultBranch
|
||||
mainBranch = config?.branches?.main ?? mainBranch
|
||||
mainBranch = mainBranchInput !== '' ? mainBranchInput : mainBranch
|
||||
|
||||
return mainBranch
|
||||
},
|
||||
|
||||
async getPerennialBranches(
|
||||
octokit: Octokit,
|
||||
config: Config | undefined,
|
||||
context: typeof github.context
|
||||
): Promise<string[]> {
|
||||
const { data } = await octokit.rest.repos.listBranches({ ...context.repo })
|
||||
const repoBranches = data.map((branch) => branch.name)
|
||||
|
||||
let explicitBranches: string[] = []
|
||||
explicitBranches = config?.branches?.perennials ?? explicitBranches
|
||||
const perennialBranchesInput = core.getMultilineInput('perennial-branches', {
|
||||
required: false,
|
||||
trimWhitespace: true,
|
||||
})
|
||||
explicitBranches =
|
||||
perennialBranchesInput.length > 0 ? perennialBranchesInput : explicitBranches
|
||||
|
||||
let perennialRegex: string | undefined
|
||||
perennialRegex = config?.branches?.perennialRegex ?? perennialRegex
|
||||
const perennialRegexInput = core.getInput('perennial-regex', {
|
||||
required: false,
|
||||
trimWhitespace: true,
|
||||
})
|
||||
perennialRegex = perennialRegexInput !== '' ? perennialRegexInput : perennialRegex
|
||||
|
||||
const perennialBranches = [
|
||||
...explicitBranches,
|
||||
...repoBranches.filter((branch) =>
|
||||
perennialRegex ? RegExp(perennialRegex).test(branch) : false
|
||||
),
|
||||
]
|
||||
|
||||
// De-dupes return value
|
||||
return [...new Set(perennialBranches)]
|
||||
},
|
||||
|
||||
getCurrentPullRequest(context: typeof github.context) {
|
||||
try {
|
||||
const pullRequest:
|
||||
| {
|
||||
number: number
|
||||
base?: { ref?: string }
|
||||
head?: { ref?: string }
|
||||
}
|
||||
| undefined = context.payload.pull_request
|
||||
|
||||
return pullRequestSchema.parse({
|
||||
number: pullRequest?.number,
|
||||
baseRefName: pullRequest?.base?.ref,
|
||||
headRefName: pullRequest?.head?.ref,
|
||||
})
|
||||
} catch (error) {
|
||||
core.setFailed(`Unable to determine current pull request from action payload`)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async getPullRequests(octokit: Octokit, context: typeof github.context) {
|
||||
return octokit.paginate(
|
||||
'GET /repos/{owner}/{repo}/pulls',
|
||||
{
|
||||
...context.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
},
|
||||
(response) =>
|
||||
response.data.map(
|
||||
(item): PullRequest => ({
|
||||
number: item.number,
|
||||
baseRefName: item.base.ref,
|
||||
headRefName: item.head.ref,
|
||||
body: item.body ?? undefined,
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest'
|
||||
import { updateDescription } from './main'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('updateDescription', () => {
|
||||
it('should correctly update pull request body', () => {
|
||||
const description = `
|
||||
## Description
|
||||
|
||||
## Stack
|
||||
|
||||
<!-- branch-stack -->
|
||||
- main
|
||||
- \\#1
|
||||
`
|
||||
const output = ['- main', ' - \\#2'].join('\n')
|
||||
|
||||
const actual = updateDescription({ description, output })
|
||||
const expected = [
|
||||
'## Description',
|
||||
'',
|
||||
'## Stack',
|
||||
'',
|
||||
'<!-- branch-stack -->',
|
||||
'',
|
||||
'- main',
|
||||
' - \\#2',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(actual).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should append output to description if body regex fails', () => {
|
||||
const description = '## Description'
|
||||
const output = ['- main', ' - \\#2'].join('\n')
|
||||
|
||||
const actual = updateDescription({ description, output })
|
||||
const expected = [
|
||||
'## Description',
|
||||
'',
|
||||
'<!-- branch-stack -->',
|
||||
'',
|
||||
'- main',
|
||||
' - \\#2',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(actual).toEqual(expected)
|
||||
})
|
||||
})
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import { MultiDirectedGraph } from 'graphology'
|
||||
import { bfsFromNode, dfs, dfsFromNode } from 'graphology-traversal'
|
||||
import type { PullRequest, Context, StackNodeAttributes } from './types'
|
||||
import { remark } from './remark'
|
||||
|
||||
export async function main({
|
||||
octokit,
|
||||
mainBranch,
|
||||
perennialBranches,
|
||||
currentPullRequest,
|
||||
pullRequests,
|
||||
}: Context) {
|
||||
const repoGraph = new MultiDirectedGraph<StackNodeAttributes>()
|
||||
|
||||
repoGraph.addNode(mainBranch, {
|
||||
type: 'perennial',
|
||||
ref: mainBranch,
|
||||
})
|
||||
|
||||
perennialBranches.forEach((perennialBranch) => {
|
||||
repoGraph.addNode(perennialBranch, {
|
||||
type: 'perennial',
|
||||
ref: perennialBranch,
|
||||
})
|
||||
})
|
||||
|
||||
pullRequests.forEach((pullRequest) => {
|
||||
repoGraph.addNode(pullRequest.headRefName, {
|
||||
type: 'pull-request',
|
||||
...pullRequest,
|
||||
})
|
||||
})
|
||||
|
||||
pullRequests.forEach((pullRequest) => {
|
||||
repoGraph.addDirectedEdge(pullRequest.baseRefName, pullRequest.headRefName)
|
||||
})
|
||||
|
||||
const getStackGraph = (pullRequest: PullRequest) => {
|
||||
const stackGraph = repoGraph.copy() as MultiDirectedGraph<StackNodeAttributes>
|
||||
stackGraph.setNodeAttribute(pullRequest.headRefName, 'isCurrent', true)
|
||||
|
||||
bfsFromNode(
|
||||
stackGraph,
|
||||
pullRequest.headRefName,
|
||||
(ref, attributes) => {
|
||||
stackGraph.setNodeAttribute(ref, 'shouldPrint', true)
|
||||
return attributes.type === 'perennial'
|
||||
},
|
||||
{
|
||||
mode: 'inbound',
|
||||
}
|
||||
)
|
||||
|
||||
dfsFromNode(
|
||||
stackGraph,
|
||||
pullRequest.headRefName,
|
||||
(ref) => {
|
||||
stackGraph.setNodeAttribute(ref, 'shouldPrint', true)
|
||||
},
|
||||
{ mode: 'outbound' }
|
||||
)
|
||||
|
||||
return stackGraph
|
||||
}
|
||||
|
||||
const getOutput = (graph: MultiDirectedGraph<StackNodeAttributes>) => {
|
||||
const lines: string[] = []
|
||||
const terminatingRefs = [mainBranch, ...perennialBranches]
|
||||
|
||||
dfs(
|
||||
graph,
|
||||
(_, stackNode, depth) => {
|
||||
if (!stackNode.shouldPrint) return
|
||||
|
||||
const tabSize = depth * 2
|
||||
const indentation = new Array(tabSize).fill(' ').join('')
|
||||
|
||||
let line = indentation
|
||||
|
||||
if (stackNode.type === 'perennial' && terminatingRefs.includes(stackNode.ref)) {
|
||||
line += `- \`${stackNode.ref}\``
|
||||
}
|
||||
|
||||
if (stackNode.type === 'pull-request') {
|
||||
line += `- #${stackNode.number}`
|
||||
}
|
||||
|
||||
if (stackNode.isCurrent) {
|
||||
line += ' :point_left:'
|
||||
}
|
||||
|
||||
lines.push(line)
|
||||
},
|
||||
{ mode: 'directed' }
|
||||
)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const jobs: Array<() => Promise<void>> = []
|
||||
|
||||
getStackGraph(currentPullRequest).forEachNode((_, stackNode) => {
|
||||
if (stackNode.type !== 'pull-request' || !stackNode.shouldPrint) {
|
||||
return
|
||||
}
|
||||
|
||||
jobs.push(async () => {
|
||||
core.info(`Updating stack details for PR #${stackNode.number}`)
|
||||
|
||||
const stackGraph = getStackGraph(stackNode)
|
||||
const output = getOutput(stackGraph)
|
||||
|
||||
let description = stackNode.body ?? ''
|
||||
description = updateDescription({
|
||||
description,
|
||||
output,
|
||||
})
|
||||
|
||||
await octokit.rest.pulls.update({
|
||||
...github.context.repo,
|
||||
pull_number: stackNode.number,
|
||||
body: description,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.allSettled(jobs.map((job) => job()))
|
||||
}
|
||||
|
||||
export function updateDescription({
|
||||
description,
|
||||
output,
|
||||
}: {
|
||||
description: string
|
||||
output: string
|
||||
}) {
|
||||
const ANCHOR = '<!-- branch-stack -->'
|
||||
|
||||
const descriptionAst = remark.parse(description)
|
||||
const outputAst = remark.parse(`${ANCHOR}\n${output}`)
|
||||
|
||||
const anchorIndex = descriptionAst.children.findIndex(
|
||||
(node) => node.type === 'html' && node.value === ANCHOR
|
||||
)
|
||||
|
||||
const isMissingAnchor = anchorIndex === -1
|
||||
if (isMissingAnchor) {
|
||||
descriptionAst.children.push(...outputAst.children)
|
||||
|
||||
return remark.stringify(descriptionAst)
|
||||
}
|
||||
|
||||
let nearestListIndex = anchorIndex
|
||||
|
||||
for (let i = anchorIndex; i < descriptionAst.children.length; i += 1) {
|
||||
const node = descriptionAst.children[i]
|
||||
|
||||
if (node?.type === 'list') {
|
||||
nearestListIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
descriptionAst.children.splice(
|
||||
anchorIndex,
|
||||
nearestListIndex - anchorIndex + 1,
|
||||
...outputAst.children
|
||||
)
|
||||
|
||||
return remark.stringify(descriptionAst)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { remark as createRemark } from 'remark'
|
||||
import gfm from 'remark-gfm'
|
||||
|
||||
export const remark = createRemark().use(gfm).data('settings', {
|
||||
bullet: '-',
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { getOctokit } from '@actions/github'
|
||||
import type { infer as InferType } from 'zod'
|
||||
import { object, number, string } from 'zod'
|
||||
|
||||
export type Octokit = ReturnType<typeof getOctokit>
|
||||
|
||||
export const pullRequestSchema = object({
|
||||
number: number(),
|
||||
baseRefName: string(),
|
||||
headRefName: string(),
|
||||
body: string().optional(),
|
||||
})
|
||||
export type PullRequest = InferType<typeof pullRequestSchema>
|
||||
|
||||
export type Context = {
|
||||
octokit: Octokit
|
||||
mainBranch: string
|
||||
currentPullRequest: PullRequest
|
||||
pullRequests: PullRequest[]
|
||||
perennialBranches: string[]
|
||||
}
|
||||
|
||||
export type StackNode =
|
||||
| {
|
||||
type: 'perennial'
|
||||
ref: string
|
||||
}
|
||||
| ({
|
||||
type: 'pull-request'
|
||||
} & PullRequest)
|
||||
|
||||
export type StackNodeAttributes = StackNode & {
|
||||
isCurrent?: true
|
||||
shouldPrint?: true
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "Node10",
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2022"],
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user