feat(workflows): add openclaw.release tweet + post workflows
This commit is contained in:
parent
7dac9e39f1
commit
ef048d044c
@ -1,7 +1,10 @@
|
||||
import { workflowRegistry } from '../../workflows/registry.js';
|
||||
import { runGithubPrMonitorWorkflow, runGithubPrMonitorNotifyWorkflow } from '../../workflows/github_pr_monitor.js';
|
||||
import { runOpenclawReleasePostWorkflow, runOpenclawReleaseTweetWorkflow } from '../../workflows/openclaw_release.js';
|
||||
|
||||
const runners = {
|
||||
'openclaw.release.tweet': runOpenclawReleaseTweetWorkflow,
|
||||
'openclaw.release.post': runOpenclawReleasePostWorkflow,
|
||||
'github.pr.monitor': runGithubPrMonitorWorkflow,
|
||||
'github.pr.monitor.notify': runGithubPrMonitorNotifyWorkflow,
|
||||
};
|
||||
|
||||
118
src/workflows/openclaw_release.ts
Normal file
118
src/workflows/openclaw_release.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { parsePipeline } from '../parser.js';
|
||||
import { runPipeline } from '../runtime.js';
|
||||
|
||||
function assertStyle(value: unknown): 'sassy' | 'professional' | 'drybread' {
|
||||
const v = String(value ?? 'professional').trim().toLowerCase();
|
||||
if (v === 'sassy' || v === 'professional' || v === 'drybread') return v;
|
||||
return 'professional';
|
||||
}
|
||||
|
||||
function jsonEscape(s: string) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
function buildContextShell({ repoDir, sinceRef, maxCommits, link, style }: any) {
|
||||
// Produce a single JSON object on stdout.
|
||||
// Uses jq for safe JSON construction.
|
||||
return [
|
||||
`cd '${jsonEscape(repoDir)}'`,
|
||||
`SINCE='${jsonEscape(sinceRef || `HEAD~${Number(maxCommits) || 30}`)}'`,
|
||||
`COMMITS=$(git log --no-merges --pretty=format:'%h %s (%an)' "$SINCE..HEAD" | head -n 80 | jq -R -s -c 'split("\\n")|map(select(length>0))')`,
|
||||
// Grab the topmost changelog section (first "## " header block)
|
||||
`CHANGELOG=$(node -e "const fs=require('fs'); const t=fs.readFileSync('CHANGELOG.md','utf8'); const parts=t.split(/\\n## /); const top=(parts.length>1?('## '+parts[1]).split(/\\n## /)[0]:t); process.stdout.write(top.slice(0,6000));")`,
|
||||
`jq -n --arg repo 'openclaw' --arg style '${jsonEscape(style)}' --arg since "$SINCE" --arg link '${jsonEscape(link)}' --argjson commits "$COMMITS" --arg changelog "$CHANGELOG" '{repo:$repo,style:$style,since:$since,link:$link,commits:$commits,changelog:$changelog}'`,
|
||||
].join(' && ');
|
||||
}
|
||||
|
||||
function buildTweetPrompt() {
|
||||
return (
|
||||
`You are writing a release tweet for OpenClaw.\n\n` +
|
||||
`Input JSON has: style (sassy|professional|drybread), since, link, commits[], changelog.\n\n` +
|
||||
`Write ONE tweet in the requested style.\n` +
|
||||
`Constraints:\n` +
|
||||
`- <= 260 characters\n` +
|
||||
`- Include the link exactly once (use the provided link field)\n` +
|
||||
`- Don\'t hallucinate features\n` +
|
||||
`- No hashtags unless truly helpful (max 1)\n\n` +
|
||||
`Return JSON: {"tweet":"...","style":"..."}.\n\n` +
|
||||
`INPUT:\n{{.}}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function runOpenclawReleaseTweetWorkflow({ args, ctx }: any) {
|
||||
const repoDir = String(args.repo_dir ?? args.repoDir ?? '../openclaw');
|
||||
const style = assertStyle(args.style);
|
||||
const sinceRef = String(args.since_ref ?? args.sinceRef ?? '').trim();
|
||||
const maxCommits = Number(args.max_commits ?? args.maxCommits ?? 30);
|
||||
const link = String(args.link ?? 'https://openclaw.dev');
|
||||
|
||||
const contextShell = buildContextShell({ repoDir, sinceRef, maxCommits, link, style });
|
||||
|
||||
const pipelineString = [
|
||||
`exec --json --shell '${jsonEscape(contextShell)}'`,
|
||||
`llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"},"style":{"type":"string"}},"required":["tweet","style"],"additionalProperties":false}' --prompt '${jsonEscape(buildTweetPrompt())}'`,
|
||||
// Human approval (interactive by default; emits approval_request in tool/non-tty)
|
||||
`approve --preview-from-stdin --limit 1 --prompt 'Post this release tweet?'`,
|
||||
// Output tweet object (for display or piping)
|
||||
`pick tweet,style`,
|
||||
].join(' | ');
|
||||
|
||||
const pipeline = parsePipeline(pipelineString);
|
||||
const output = await runPipeline({
|
||||
pipeline,
|
||||
registry: ctx.registry,
|
||||
input: [],
|
||||
stdin: ctx.stdin,
|
||||
stdout: ctx.stdout,
|
||||
stderr: ctx.stderr,
|
||||
env: ctx.env,
|
||||
mode: ctx.mode,
|
||||
});
|
||||
|
||||
// In human mode, approve passes items through. In tool mode, approve halts; cli will wrap.
|
||||
return {
|
||||
kind: 'openclaw.release.tweet',
|
||||
style,
|
||||
since: sinceRef || `HEAD~${maxCommits}`,
|
||||
items: output.items,
|
||||
halted: output.halted,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runOpenclawReleasePostWorkflow({ args, ctx }: any) {
|
||||
const repoDir = String(args.repo_dir ?? args.repoDir ?? '../openclaw');
|
||||
const style = assertStyle(args.style);
|
||||
const sinceRef = String(args.since_ref ?? args.sinceRef ?? '').trim();
|
||||
const maxCommits = Number(args.max_commits ?? args.maxCommits ?? 30);
|
||||
const link = String(args.link ?? 'https://openclaw.dev');
|
||||
|
||||
const contextShell = buildContextShell({ repoDir, sinceRef, maxCommits, link, style });
|
||||
|
||||
const pipelineString = [
|
||||
`exec --json --shell '${jsonEscape(contextShell)}'`,
|
||||
`llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"},"style":{"type":"string"}},"required":["tweet","style"],"additionalProperties":false}' --prompt '${jsonEscape(buildTweetPrompt())}'`,
|
||||
`approve --preview-from-stdin --limit 1 --prompt 'Post this release tweet to X?'`,
|
||||
// Post to X via bird; uses stdin json array from previous stage.
|
||||
`exec --stdin json --shell 'T=$(cat | jq -r ".[0].tweet"); bird post --text "$T"; echo "{\\"posted\\":true}"' --json`,
|
||||
].join(' | ');
|
||||
|
||||
const pipeline = parsePipeline(pipelineString);
|
||||
const output = await runPipeline({
|
||||
pipeline,
|
||||
registry: ctx.registry,
|
||||
input: [],
|
||||
stdin: ctx.stdin,
|
||||
stdout: ctx.stdout,
|
||||
stderr: ctx.stderr,
|
||||
env: ctx.env,
|
||||
mode: ctx.mode,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: 'openclaw.release.post',
|
||||
style,
|
||||
since: sinceRef || `HEAD~${maxCommits}`,
|
||||
items: output.items,
|
||||
halted: output.halted,
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,50 @@
|
||||
export const workflowRegistry = {
|
||||
'openclaw.release.tweet': {
|
||||
name: 'openclaw.release.tweet',
|
||||
description:
|
||||
'Generate an OpenClaw release tweet (sassy/professional/drybread) from commits + changelog, with approval.',
|
||||
argsSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
repo_dir: { type: 'string', description: 'Path to OpenClaw repo (default: ../openclaw)' },
|
||||
since_ref: { type: 'string', description: 'Start ref (tag/sha). Default: HEAD~max_commits' },
|
||||
max_commits: { type: 'number', description: 'Commit window when since_ref is empty (default: 30)' },
|
||||
link: { type: 'string', description: 'Release notes link to include (default: https://openclaw.dev)' },
|
||||
style: { type: 'string', description: 'sassy|professional|drybread (default: professional)' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
args: { style: 'sassy', since_ref: 'HEAD~30', link: 'https://openclaw.dev' },
|
||||
description: 'Generate a sassy tweet from last 30 commits.',
|
||||
},
|
||||
],
|
||||
sideEffects: [],
|
||||
},
|
||||
'openclaw.release.post': {
|
||||
name: 'openclaw.release.post',
|
||||
description:
|
||||
'Generate an OpenClaw release tweet and (after approval) post to X via bird CLI.',
|
||||
argsSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
repo_dir: { type: 'string', description: 'Path to OpenClaw repo (default: ../openclaw)' },
|
||||
since_ref: { type: 'string', description: 'Start ref (tag/sha). Default: HEAD~max_commits' },
|
||||
max_commits: { type: 'number', description: 'Commit window when since_ref is empty (default: 30)' },
|
||||
link: { type: 'string', description: 'Release notes link to include (default: https://openclaw.dev)' },
|
||||
style: { type: 'string', description: 'sassy|professional|drybread (default: professional)' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
args: { style: 'professional', since_ref: 'HEAD~30', link: 'https://openclaw.dev' },
|
||||
description: 'Approve and post a professional tweet.',
|
||||
},
|
||||
],
|
||||
sideEffects: ['local_exec'],
|
||||
},
|
||||
'github.pr.monitor': {
|
||||
name: 'github.pr.monitor',
|
||||
description: 'Fetch PR state via gh, diff against last run, emit only on change.',
|
||||
|
||||
@ -22,5 +22,10 @@ test('workflows.list returns known workflows', async () => {
|
||||
for await (const it of res.output) items.push(it);
|
||||
|
||||
const names = items.map((x) => x.name).sort();
|
||||
assert.deepEqual(names, ['github.pr.monitor', 'github.pr.monitor.notify']);
|
||||
assert.deepEqual(names, [
|
||||
'github.pr.monitor',
|
||||
'github.pr.monitor.notify',
|
||||
'openclaw.release.post',
|
||||
'openclaw.release.tweet',
|
||||
]);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user