feat: v1 (#1)

* feat: v1
This commit is contained in:
Long Tran
2024-03-27 22:30:12 +11:00
committed by GitHub
parent 953ca43534
commit fd2156e6c0
33 changed files with 10160 additions and 2 deletions
+9
View File
@@ -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
+20
View File
@@ -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
}
}
+38
View File
@@ -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'
}
};
+14
View File
@@ -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"
+17
View File
@@ -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
+26
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
## Stack
<!-- branch-stack -->
+60
View File
@@ -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/
+69
View File
@@ -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
+48
View File
@@ -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
+24
View File
@@ -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: ./
+3
View File
@@ -0,0 +1,3 @@
!dist
node_modules
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
npm exec lint-staged
npm run lint:ec
npm run test:ci
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
"src/**/*.{ts,tsx}": "npm exec eslint --fix --max-warnings=0"
}
+1
View File
@@ -0,0 +1 @@
20.5.1
+14
View File
@@ -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
}
+20
View File
@@ -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!
+15
View File
@@ -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
+21
View File
@@ -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.
+149 -2
View File
@@ -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
View File
@@ -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'
+112
View File
File diff suppressed because one or more lines are too long
+8751
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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"
}
}
+36
View File
@@ -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 }
+44
View File
@@ -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
}
}
+189
View File
@@ -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
View File
@@ -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,
})
)
)
},
}
+54
View File
@@ -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
View File
@@ -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)
}
+6
View File
@@ -0,0 +1,6 @@
import { remark as createRemark } from 'remark'
import gfm from 'remark-gfm'
export const remark = createRemark().use(gfm).data('settings', {
bullet: '-',
})
+35
View File
@@ -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
}
+26
View File
@@ -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"]
}