feat: stack visualization in PR comment (#65)

This commit is contained in:
Long Tran
2025-07-13 19:47:15 +10:00
committed by GitHub
parent 66853b49b9
commit fea06c3ad4
11 changed files with 566 additions and 403 deletions
+63 -44
View File
@@ -6,7 +6,7 @@
</picture>
</p>
# Git Town Action V1
# Git Town Action v1
This action visualizes your stacked changes when proposing pull requests on GitHub:
@@ -96,48 +96,25 @@ be able to use it again to update the visualization:
[ ] Baz
```
## Manual Configuration
If you are using Git Town v11 and below, or are setting up the action for a repository
that doesn't have a `.git-branches.toml`, you will need to tell the 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'
```
The 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. The action will merge the perennial
branches given into a single, de-duplicated list.
## Customization
### Visualization Location
The location of the stack visualization can be customized using the `location` input.
Valid options for this input include:
- `description`: This is the default option. The stack visualization will appear within the
pull request description. This will require granting `pull-requests: write` permissions to the
action.
- `comment`: The stack visualization will appear in a separate comment. No additional permissions
are required for this option.
```yaml
- uses: git-town/action@v1
with:
location: comment
```
### Skip Single Stacks
If you don't want the stack visualization to appear on pull requests which are **not** part
@@ -160,12 +137,12 @@ and closed pull requests. However, this can increase the runtime of the action f
larger/older repositories.
If you're experiencing long runtimes, the `history-limit` input can be configured to
limit the total number of closed pull requests fetched by the action:
limit the total number of pull requests fetched by the action:
```yaml
- uses: git-town/action@v1
with:
history-limit: '500' # Only fetch the latest 500 closed pull requests
history-limit: 500 # Only fetch the latest 500 pull requests
```
> [!WARNING]
@@ -195,6 +172,46 @@ it into the actions's `github-token` input to grant it sufficient permissions:
github-token: ${{ secrets.GIT_TOWN_PAT }} # 👈 Add this to `git-town.yml`
```
## Manual Configuration
If you are using Git Town v11 and below, or are setting up the action for a repository
that doesn't have a `.git-branches.toml`, you will need to tell the 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
```
The 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. The action will merge the perennial
branches given into a single, de-duplicated list.
## Reference
```yaml
@@ -211,6 +228,9 @@ inputs:
perennial-regex:
required: false
default: ''
location:
required: false
default: 'description'
skip-single-stacks:
required: false
default: false
@@ -219,7 +239,6 @@ inputs:
default: '0'
```
## License
The scripts and documentation in this project are released under the [MIT License](LICENSE).
+3
View File
@@ -18,6 +18,9 @@ inputs:
perennial-regex:
required: false
default: ''
location:
required: false
default: 'description'
skip-single-stacks:
required: false
default: false
+408 -348
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -3,6 +3,7 @@ import * as github from '@actions/github'
import { main } from './main'
import { inputs } from './inputs'
import { config } from './config'
import type { Context } from './types'
void run()
@@ -19,6 +20,8 @@ async function run() {
const octokit = github.getOctokit(inputs.getToken())
const location = inputs.getLocation()
const skipSingleStacks = inputs.getSkipSingleStacks()
const historyLimit = inputs.getHistoryLimit()
const [mainBranch, remoteBranches, pullRequests] = await Promise.all([
inputs.getMainBranch(octokit, config, github.context),
@@ -32,10 +35,10 @@ async function run() {
currentPullRequest: inputs.getCurrentPullRequest(github.context),
pullRequests,
mainBranch,
remoteBranches,
perennialBranches,
skipSingleStacks: inputs.getSkipSingleStacks(),
}
skipSingleStacks,
location,
} satisfies Context
void main(context)
} catch (error) {
+12
View File
@@ -4,12 +4,24 @@ import type { Endpoints } from '@octokit/types'
import { pullRequestSchema } from './types'
import type { PullRequest, Octokit } from './types'
import type { Config } from './config'
import { locationInputSchema, type LocationInput } from './locations/types'
export const inputs = {
getToken() {
return core.getInput('github-token', { required: true, trimWhitespace: true })
},
getLocation(): LocationInput {
const location = core.getInput('location', { required: false, trimWhitespace: true })
try {
return locationInputSchema.parse(location)
} catch {
core.setFailed(`Invalid 'location' input: ${location}`)
process.exit(1)
}
},
getSkipSingleStacks() {
const input = core.getBooleanInput('skip-single-stacks', {
required: false,
+46
View File
@@ -0,0 +1,46 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { ANCHOR, injectVisualization } from '../renderer'
import type { Context, Octokit, PullRequest } from '../types'
import type { AbstractLocationAdapter } from './types'
export class CommentLocationAdapter implements AbstractLocationAdapter {
private octokit: Octokit
constructor(context: Context) {
this.octokit = context.octokit
}
async update(pullRequest: PullRequest, visualization: string) {
core.startGroup(`Update: PR #${pullRequest.number} (COMMENT)`)
core.info('Visualization:')
core.info(visualization)
const { data: comments } = await this.octokit.rest.issues.listComments({
...github.context.repo,
issue_number: pullRequest.number,
})
const existingComment = comments.find((comment) => comment.body?.includes(ANCHOR))
if (existingComment) {
const content = injectVisualization(visualization, existingComment.body ?? '')
await this.octokit.rest.issues.updateComment({
...github.context.repo,
comment_id: existingComment.id,
issue_number: pullRequest.number,
body: content,
})
} else {
const content = injectVisualization(visualization, '')
await this.octokit.rest.issues.createComment({
...github.context.repo,
issue_number: pullRequest.number,
body: content,
})
}
core.endGroup()
}
}
+3 -3
View File
@@ -2,9 +2,9 @@ import * as core from '@actions/core'
import * as github from '@actions/github'
import { injectVisualization } from '../renderer'
import type { Context, Octokit, PullRequest } from '../types'
import type { Location } from './types'
import type { AbstractLocationAdapter } from './types'
export class DescriptionLocation implements Location {
export class DescriptionLocationAdapter implements AbstractLocationAdapter {
private octokit: Octokit
constructor(context: Context) {
@@ -12,7 +12,7 @@ export class DescriptionLocation implements Location {
}
async update(pullRequest: PullRequest, visualization: string) {
core.startGroup(`Update: PR #${pullRequest.number}`)
core.startGroup(`Update: PR #${pullRequest.number} (DESCRIPTION)`)
core.info('Visualization:')
core.info(visualization)
+13
View File
@@ -0,0 +1,13 @@
import type { Context } from '../types'
import { CommentLocationAdapter } from './comment'
import { DescriptionLocationAdapter } from './description'
import type { AbstractLocationAdapter } from './types'
export function createLocationAdapter(context: Context): AbstractLocationAdapter {
switch (context.location) {
case 'description':
return new DescriptionLocationAdapter(context)
case 'comment':
return new CommentLocationAdapter(context)
}
}
+7 -2
View File
@@ -1,5 +1,10 @@
import type { infer as InferType } from 'zod'
import { z } from 'zod'
import type { PullRequest } from '../types'
export type Location = {
update: (pullRequest: PullRequest, visualization: string) => Promise<void>
export const locationInputSchema = z.enum(['description', 'comment'])
export type LocationInput = InferType<typeof locationInputSchema>
export abstract class AbstractLocationAdapter {
abstract update(pullRequest: PullRequest, visualization: string): Promise<void>
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { DirectedGraph } from 'graphology'
import { bfsFromNode, dfsFromNode } from 'graphology-traversal'
import type { PullRequest, Context, StackNodeAttributes } from './types'
import { renderVisualization } from './renderer'
import { DescriptionLocation } from './locations/description'
import { createLocationAdapter } from './locations/factory'
export async function main(context: Context) {
const {
@@ -103,8 +103,8 @@ export async function main(context: Context) {
const stackGraph = getStackGraph(stackNode, repoGraph)
const visualization = renderVisualization(stackGraph, terminatingRefs)
const target = new DescriptionLocation(context)
await target.update(stackNode, visualization)
const location = createLocationAdapter(context)
await location.update(stackNode, visualization)
})
})
+2
View File
@@ -1,6 +1,7 @@
import type { getOctokit } from '@actions/github'
import type { infer as InferType } from 'zod'
import { object, number, string } from 'zod'
import type { LocationInput } from './locations/types'
export type Octokit = ReturnType<typeof getOctokit>
@@ -24,6 +25,7 @@ export type Context = {
pullRequests: PullRequest[]
perennialBranches: string[]
skipSingleStacks: boolean
location: LocationInput
}
export type StackNode =