Skip to content

Auto Worktrees

Auto Worktrees #4493

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)
});