Auto Worktrees #4493
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auto Worktrees | |
| description: Automatically create worktrees for open PRs. | |
| on: | |
| # run every hour | |
| schedule: | |
| - cron: '0 * * * *' | |
| workflow_dispatch: | |
| inputs: | |
| {} | |
| jobs: | |
| check_config: | |
| runs-on: [self-hosted, cmms] | |
| outputs: | |
| config: ${{ steps.read_config.outputs.config }} | |
| steps: | |
| - uses: actions/checkout@v3 | |
| - run: npm i | |
| - uses: actions/github-script@v7 | |
| id: read_config | |
| env: | |
| BOLT_API: ${{ secrets.BOLT_API }} | |
| with: | |
| script: | | |
| const boltApi = process.env.BOLT_API; | |
| const url = new URL(`${boltApi}/worktree_configs`); | |
| const configs = await fetch(url.toString()) | |
| .then(res => res.json()) | |
| .then(({data}) => data) | |
| .catch(() => []); | |
| core.setOutput('config', JSON.stringify(configs)); | |
| update_workspace_worktree: | |
| needs: check_config | |
| runs-on: [self-hosted, cmms] | |
| if: ${{ needs.check_config.outputs.config != '[]' }} | |
| strategy: | |
| max-parallel: 2 | |
| matrix: | |
| config: ${{ fromJson(needs.check_config.outputs.config) }} | |
| steps: | |
| - name: update workspace worktree | |
| uses: actions/github-script@v7 | |
| env: | |
| NAME: ${{ matrix.config.name }} | |
| VSCODE_PROFILE: ${{ matrix.config.vscode_profile }} | |
| WORKTREES_PATH: ${{ matrix.config.path }} | |
| MAIN_BRANCH: ${{ matrix.config.main_branch }} | |
| BOLT_API: ${{ secrets.BOLT_API }} | |
| with: | |
| script: | | |
| const {join} = require('path'); | |
| const name = process.env.NAME; | |
| const boltApi = process.env.BOLT_API; | |
| const worktreesPath = process.env.WORKTREES_PATH; | |
| const mainBranch = process.env.MAIN_BRANCH; | |
| const vscodeProfile = process.env.VSCODE_PROFILE || 'default'; | |
| const worktreePath = join(worktreesPath, 'workspace'); | |
| const workspaceFile = join(worktreesPath, 'context', 'workspaces', 'workspace.code-workspace'); | |
| const branch = await exec.getExecOutput('git branch --show-current', [], {cwd: worktreePath}) | |
| .then((result) => result.stdout.trim()); | |
| console.log(`Current branch: ${branch}`); | |
| const url = new URL("https://vscode.dev"); | |
| url.pathname = join('tunnel', 'cmms', workspaceFile); | |
| url.searchParams.set('payload', JSON.stringify([['profile', vscodeProfile]])); | |
| const worktree = await fetch(`${boltApi}/worktrees/${name}/0`) | |
| .then(res => res.json()) | |
| .then(({data}) => data) | |
| .catch(() => false); | |
| console.log(`Worktree: ${JSON.stringify(worktree)}`); | |
| if (!worktree) { | |
| await fetch(`${boltApi}/worktrees`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| worktree: { | |
| name, | |
| number: 0, | |
| title: branch, | |
| worktreePath, | |
| worktreeName: 'workspace', | |
| workspaceFile, | |
| workspaceUrl: url.toString(), | |
| headRefName: branch, | |
| baseRefName: mainBranch | |
| } | |
| }) | |
| }); | |
| } else { | |
| await fetch(`${boltApi}/worktrees/${worktree.id}`, { | |
| method: 'PATCH', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| worktree: { | |
| title: branch, | |
| headRefName: branch | |
| } | |
| }) | |
| }); | |
| } | |
| check_branches: | |
| needs: check_config | |
| runs-on: [self-hosted, cmms] | |
| if: ${{ needs.check_config.outputs.config != '[]' }} | |
| strategy: | |
| max-parallel: 2 | |
| matrix: | |
| config: ${{ fromJson(needs.check_config.outputs.config) }} | |
| steps: | |
| - name: fetch branches | |
| working-directory: ${{ matrix.config.path }}/${{ matrix.config.main_branch }} | |
| run: git fetch --all | |
| - name: fetch prs | |
| id: fetch_prs | |
| working-directory: ${{ matrix.config.path }}/${{ matrix.config.main_branch }} | |
| run: | | |
| gh pr list --limit 50 --search "-author:@me -author:app/dependabot" --state all --json number,title,body,headRefName,baseRefName,author,createdAt,url,state,reviewDecision,isDraft > ../context/wttw-prs.json | |
| - name: sync worktrees status | |
| uses: actions/github-script@v7 | |
| env: | |
| NAME: ${{ matrix.config.name }} | |
| WORKTREES_PATH: ${{ matrix.config.path }} | |
| BOLT_API: ${{ secrets.BOLT_API }} | |
| with: | |
| script: | | |
| const {join} = require('path'); | |
| const name = process.env.NAME; | |
| const worktreesPath = process.env.WORKTREES_PATH; | |
| const boltApi = process.env.BOLT_API; | |
| const prs = require(join(worktreesPath, 'context/wttw-prs.json')); | |
| await fetch(`${boltApi}/worktrees/sync`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({name, worktrees: prs}) | |
| }); | |
| - uses: actions/github-script@v7 | |
| id: target_worktrees | |
| env: | |
| NAME: ${{ matrix.config.name }} | |
| WORKTREES_PATH: ${{ matrix.config.path }} | |
| MAIN_BRANCH: ${{ matrix.config.main_branch }} | |
| VSCODE_PROFILE: ${{ matrix.config.vscode_profile }} | |
| AFTER_CREATE_HOOK: ${{ matrix.config.after_create_hook }} | |
| ME: ${{ github.actor }} | |
| with: | |
| script: | | |
| const {join} = require('path'); | |
| const {existsSync, writeFileSync} = require('fs'); | |
| const name = process.env.NAME; | |
| const worktreesPath = process.env.WORKTREES_PATH; | |
| const mainBranch = process.env.MAIN_BRANCH; | |
| const vscodeProfile = process.env.VSCODE_PROFILE || 'default'; | |
| const afterCreateHook = process.env.AFTER_CREATE_HOOK || ''; | |
| const prs = require(join(worktreesPath, 'context/wttw-prs.json')); | |
| const wtd = join(worktreesPath, mainBranch); | |
| const targetWorktrees = prs | |
| .filter(({state}) => state === 'OPEN') | |
| .filter(({author, number}) => { | |
| const worktreePath = join(worktreesPath, `pr-${number}`); | |
| return !existsSync(worktreePath); | |
| }) | |
| .map(pr => { | |
| const worktreeName = `pr-${pr.number}`; | |
| const workspaceFile = join(worktreesPath, 'context', 'workspaces', `${worktreeName}.code-workspace`); | |
| const url = new URL("https://vscode.dev"); | |
| url.pathname = join('tunnel', 'cmms', workspaceFile); | |
| url.searchParams.set('payload', JSON.stringify([['profile', vscodeProfile]])); | |
| const workspaceUrl = url.toString(); | |
| return { | |
| name, | |
| worktreePath: join(worktreesPath, worktreeName), | |
| worktreeName, | |
| workspaceFile, | |
| workspaceUrl, | |
| wtd, | |
| afterCreateHook, | |
| ...pr, | |
| // remove body (may contains secrets) | |
| body: '' | |
| } | |
| }); | |
| writeFileSync(join(worktreesPath, 'context', 'wttw-worktrees.json'), JSON.stringify(targetWorktrees, null, 2)); | |
| aggregate_worktrees: | |
| needs: check_branches | |
| runs-on: [self-hosted, cmms] | |
| outputs: | |
| targetWorktrees: ${{ steps.aggregate_worktrees.outputs.targetWorktrees }} | |
| steps: | |
| - name: aggregate worktrees | |
| id: aggregate_worktrees | |
| uses: actions/github-script@v7 | |
| env: | |
| BOLT_API: ${{ secrets.BOLT_API }} | |
| with: | |
| script: | | |
| const {join} = require('path'); | |
| const {readFileSync, existsSync} = require('fs'); | |
| const boltApi = process.env.BOLT_API; | |
| const url = new URL(`${boltApi}/worktree_configs`); | |
| const configs = await fetch(url.toString()) | |
| .then(res => res.json()) | |
| .then(({data}) => data) | |
| .catch(() => []); | |
| const targetWorktrees = configs.map(({path}) => { | |
| const worktreesPath = join(path, 'context', 'wttw-worktrees.json'); | |
| if (existsSync(worktreesPath)) { | |
| const worktrees = JSON.parse(readFileSync(worktreesPath, 'utf8')); | |
| return worktrees; | |
| } | |
| return []; | |
| }) | |
| .flat(); | |
| core.setOutput('targetWorktrees', JSON.stringify(targetWorktrees)); | |
| create_worktrees: | |
| needs: aggregate_worktrees | |
| runs-on: [self-hosted, cmms] | |
| if: ${{ needs.aggregate_worktrees.outputs.targetWorktrees != '[]' }} | |
| strategy: | |
| max-parallel: 2 | |
| matrix: | |
| worktree: ${{ fromJson(needs.aggregate_worktrees.outputs.targetWorktrees) }} | |
| steps: | |
| - name: create worktree | |
| working-directory: ${{ matrix.worktree.wtd }} | |
| run: | | |
| echo "creating worktree for ${{ matrix.worktree.headRefName }}" | |
| echo "worktree path: ${{ matrix.worktree.worktreePath }}" | |
| echo "worktree name: ${{ matrix.worktree.name }}" | |
| echo "worktree worktree name: ${{ matrix.worktree.worktreeName }}" | |
| echo "workspace url: ${{ matrix.worktree.workspaceUrl }}" | |
| echo "workspace hook: ${{ matrix.worktree.afterCreateHook }}" | |
| wttw new ${{ matrix.worktree.worktreeName }} \ | |
| --session worktrees \ | |
| --base-ref origin/${{ matrix.worktree.headRefName }} \ | |
| --no-hook | |
| - name: run after create worktree hook | |
| working-directory: ${{ matrix.worktree.worktreePath }} | |
| if: ${{ matrix.worktree.afterCreateHook }} | |
| continue-on-error: true | |
| run: ${{ matrix.worktree.afterCreateHook }} | |
| - name: add worktree to bolt | |
| uses: actions/github-script@v7 | |
| env: | |
| WORKTREE: ${{ toJSON(matrix.worktree) }} | |
| BOLT_API: ${{ secrets.BOLT_API }} | |
| with: | |
| script: | | |
| const {resolve} = require('path'); | |
| const worktree = JSON.parse(process.env.WORKTREE); | |
| const boltApi = process.env.BOLT_API; | |
| const prs = require(resolve(worktree.worktreePath, '../context/wttw-prs.json')); | |
| const {body} = prs.filter(pr => pr.number === worktree.number)[0] || {}; | |
| await fetch(`${boltApi}/worktrees`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({worktree: {...worktree, body}}) | |
| }); | |
| - name: send notification | |
| uses: actions/github-script@v7 | |
| env: | |
| WORKTREE: ${{ toJSON(matrix.worktree) }} | |
| ZAPIER_WEBHOOK_URL: ${{ secrets.ZAPIER_WEBHOOK_URL }} | |
| with: | |
| script: | | |
| const {resolve} = require('path'); | |
| const {existsSync, readFileSync} = require('fs'); | |
| const worktree = JSON.parse(process.env.WORKTREE); | |
| const zapierWebhookUrl = process.env.ZAPIER_WEBHOOK_URL; | |
| await fetch(zapierWebhookUrl, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(worktree) | |
| }); |