Files
2026-07-13 12:58:18 +08:00

404 lines
17 KiB
YAML

name: social / copy-generator
on:
pull_request:
types: [opened]
issue_comment:
types: [edited]
workflow_dispatch:
inputs:
pr_number:
description: "PR number to generate social copies for"
required: true
type: number
permissions:
contents: read
jobs:
post-comment:
if: >-
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Post initial comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.pull_request.number }}
with:
script: |
const prNumber = Number(process.env.PR_NUMBER);
// For workflow_dispatch, verify the PR isn't from a fork
if (context.eventName === 'workflow_dispatch') {
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.data.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
console.log('PR is from a fork, skipping');
return;
}
}
// Check if there's already a social-copy-generator comment on this PR
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.data.find(c => c.body.includes('<!-- social-copy-generator -->'));
if (existing) {
console.log(`Comment already exists (id: ${existing.id}), skipping`);
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'<!-- social-copy-generator -->',
'### 📣 Social Copy Generator',
'',
'Generate social media copies (Twitter/X, LinkedIn, Blog Post) for this PR using Claude.',
'',
'- [ ] **Generate social media copies**',
].join('\n'),
});
generate:
if: >-
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '<!-- social-copy-generator -->') &&
contains(github.event.comment.body, '- [x]')
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: social-copy-${{ github.event.issue.number }}
cancel-in-progress: true
steps:
- name: Verify checkbox transition and permissions
id: verify
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Check that the sender has write access (not an external collaborator or random user)
const sender = context.payload.sender.login;
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: sender,
});
const allowed = ['admin', 'write'];
if (!allowed.includes(permissionLevel.permission)) {
core.setOutput('should_run', 'false');
console.log(`User ${sender} has '${permissionLevel.permission}' permission, skipping`);
return;
}
} catch (e) {
core.setOutput('should_run', 'false');
console.log(`Could not verify permissions for ${sender}, skipping`);
return;
}
const oldBody = context.payload.changes?.body?.from || '';
const newBody = context.payload.comment.body;
const wasUnchecked = oldBody.includes('- [ ]');
const isNowChecked = newBody.includes('- [x]');
if (!wasUnchecked || !isNowChecked) {
core.setOutput('should_run', 'false');
console.log('Not a checkbox transition, skipping');
return;
}
core.setOutput('should_run', 'true');
core.setOutput('comment_id', context.payload.comment.id);
- name: Update comment to generating state
if: steps.verify.outputs.should_run == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
COMMENT_ID: ${{ steps.verify.outputs.comment_id }}
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: Number(process.env.COMMENT_ID),
body: [
'<!-- social-copy-generator -->',
'### 📣 Social Copy Generator',
'',
`⏳ **Analyzing PR changes with Claude...** This may take a minute. [View progress](${runUrl})`,
].join('\n'),
});
- name: Get PR details
if: steps.verify.outputs.should_run == 'true'
id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
core.setOutput('head_ref', pr.data.head.ref);
core.setOutput('head_sha', pr.data.head.sha);
core.setOutput('base_ref', pr.data.base.ref);
core.setOutput('title', pr.data.title);
core.setOutput('body', pr.data.body || '(no description)');
core.setOutput('number', String(context.issue.number));
- name: Checkout repo
if: steps.verify.outputs.should_run == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ steps.pr.outputs.head_sha }}
fetch-depth: 0
persist-credentials: false
- name: Remove repository Claude settings
if: steps.verify.outputs.should_run == 'true'
run: rm -f .claude/settings.json .claude/settings.local.json .mcp.json
- name: Get PR diff
if: steps.verify.outputs.should_run == 'true'
id: diff
env:
BASE_REF: ${{ steps.pr.outputs.base_ref }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
GH_TOKEN: ${{ github.token }}
run: |
# Write diff files inside the repo so Claude's sandbox can access them
mkdir -p .claude-tmp
# Try git merge-base first (works for open PRs and merged PRs whose base is fetchable)
MERGE_BASE=$(git merge-base "origin/$BASE_REF" HEAD 2>/dev/null) || true
if [ -n "$MERGE_BASE" ]; then
git diff --stat "$MERGE_BASE"..HEAD > .claude-tmp/diff-stat.txt
git diff "$MERGE_BASE"..HEAD > .claude-tmp/diff-full.txt
else
# Fallback: use GitHub API diff (works for merged PRs where branch was deleted)
echo "Using GitHub API for diff (merge-base not found)"
gh pr diff "$PR_NUMBER" --patch > .claude-tmp/diff-full.txt
gh pr diff "$PR_NUMBER" | head -200 > .claude-tmp/diff-stat.txt
fi
- name: Setup pnpm
if: steps.verify.outputs.should_run == 'true'
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node.js
if: steps.verify.outputs.should_run == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- name: Install Claude Code
if: steps.verify.outputs.should_run == 'true'
# @anthropic-ai/claude-code is pinned as a root devDependency and installed
# from the frozen lockfile — no ad-hoc `npm install -g`. `--ignore-scripts`
# keeps install-time scripts from running against the PR-head checkout; the
# `cli-wrapper.cjs` entrypoint (used below) resolves the native binary from
# the installed optionalDependency without needing the postinstall step.
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Write prompt file
if: steps.verify.outputs.should_run == 'true'
env:
PR_TITLE: ${{ steps.pr.outputs.title }}
PR_BRANCH: ${{ steps.pr.outputs.head_ref }}
PR_BODY: ${{ steps.pr.outputs.body }}
run: |
cat << 'PROMPT_EOF' > .claude-tmp/prompt.txt
You are a developer advocate and social media copywriter for CopilotKit — an open-source AI agent framework that lets developers add AI copilots to their products with React hooks, UI components, and agent infrastructure. CopilotKit connects frontend (React/Angular), runtime (Express/Hono), and AI agents (LangGraph, CrewAI, custom) via the AG-UI protocol.
A PR has been opened. Your job is to analyze the changes and generate social media copy that the team can use to announce this work.
## PR Information
PROMPT_EOF
echo "- **Title:** $PR_TITLE" >> .claude-tmp/prompt.txt
echo "- **Branch:** $PR_BRANCH" >> .claude-tmp/prompt.txt
echo "- **Description:**" >> .claude-tmp/prompt.txt
echo "$PR_BODY" >> .claude-tmp/prompt.txt
cat << 'PROMPT_EOF' >> .claude-tmp/prompt.txt
## Instructions
1. Read the diff stat file at `.claude-tmp/diff-stat.txt` to understand the scope of changes.
2. Read the full diff at `.claude-tmp/diff-full.txt` to understand the details.
3. If needed, read relevant source files to understand context.
4. Generate the following three pieces of content:
### Output Format
Produce your output in EXACTLY this format (including the markdown headers).
Do NOT include any preamble, analysis summary, or commentary before or after the three sections.
Start directly with the first header:
### 🐦 Twitter/X Post
(A concise, engaging tweet under 280 characters. No hashtags. Focus on the user benefit.)
### 💼 LinkedIn Post
(A professional post of 3-5 short paragraphs. Lead with the value proposition. Include a call to action. Add relevant hashtags. No length limit.)
### 📝 Blog Post Draft
(A blog-style announcement. Include a title, explain what changed, why it matters, and how to get started. No length limit — be as thorough as needed.)
## Guidelines
- Focus on user-facing impact, not implementation details
- Be enthusiastic but authentic — avoid hype
- Mention CopilotKit by name and link to https://github.com/CopilotKit/CopilotKit
- If the changes are internal/minor, still generate copy but note it's a maintenance/improvement update
PROMPT_EOF
- name: Run Claude Code
if: steps.verify.outputs.should_run == 'true'
id: claude
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
PROMPT=$(cat .claude-tmp/prompt.txt)
# Invoke the workspace-installed Claude Code via its cli-wrapper entrypoint.
# We installed with --ignore-scripts (PR-head safety), so the postinstall
# that copies the native binary over the `claude` bin stub did not run;
# cli-wrapper.cjs is the package's documented fallback that resolves and
# spawns the native binary from the installed optionalDependency.
node node_modules/@anthropic-ai/claude-code/cli-wrapper.cjs -p "$PROMPT" \
--output-format json \
--max-turns 10 \
--allowedTools "Read,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(cat:*)" \
--disallowedTools "TodoWrite,Edit,MultiEdit,Write,NotebookEdit,WebFetch,WebSearch,Task" \
> .claude-tmp/claude-output.json
- name: Extract result to markdown
if: steps.verify.outputs.should_run == 'true' && always() && steps.claude.outcome != 'skipped'
id: extract
run: |
node << 'EXTRACT_SCRIPT' > .claude-tmp/pr-social-copy.md
const fs = require('fs');
const file = '.claude-tmp/claude-output.json';
if (!fs.existsSync(file)) {
process.exit(0);
}
const raw = fs.readFileSync(file, 'utf8').trim();
let result = '';
// With --output-format json, output is a single JSON object with a "result" field
try {
const obj = JSON.parse(raw);
result = obj.result || '';
} catch {
// Fallback: try JSONL format (one JSON object per line)
for (const line of raw.split('\n')) {
try {
const obj = JSON.parse(line);
if (obj.type === 'result' && obj.result) {
result = obj.result;
}
if (!result && obj.type === 'assistant' && obj.message?.content) {
for (const block of obj.message.content) {
if (block.type === 'text' && block.text && block.text.includes('### ')) {
result = block.text;
}
}
}
} catch {}
}
}
// Strip any preamble before the first ### header
const idx = result.indexOf('### ');
const clean = idx >= 0 ? result.slice(idx) : result;
process.stdout.write(clean);
EXTRACT_SCRIPT
- name: Upload social copy artifact
if: steps.verify.outputs.should_run == 'true' && always() && steps.extract.outcome == 'success'
id: artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: social-copy-pr${{ github.event.issue.number }}
path: .claude-tmp/pr-social-copy.md
retention-days: 90
- name: Update comment with results
if: steps.verify.outputs.should_run == 'true' && always() && steps.extract.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
COMMENT_ID: ${{ steps.verify.outputs.comment_id }}
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
with:
script: |
const fs = require('fs');
const output = fs.readFileSync('.claude-tmp/pr-social-copy.md', 'utf8').trim() || '_Claude did not produce output. Please try again._';
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const artifactUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${process.env.ARTIFACT_ID}`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: Number(process.env.COMMENT_ID),
body: [
'<!-- social-copy-generator -->',
'### 📣 Social Copy Generator',
'',
output,
'',
'---',
`_Generated at ${timestamp} from commit \`${process.env.HEAD_SHA}\` | [Workflow run](${runUrl}) | [Download markdown](${artifactUrl})_`,
'',
'- [ ] **Regenerate social media copies**',
].join('\n'),
});
- name: Update comment on failure
if: steps.verify.outputs.should_run == 'true' && always() && steps.extract.outcome != 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
COMMENT_ID: ${{ steps.verify.outputs.comment_id }}
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: Number(process.env.COMMENT_ID),
body: [
'<!-- social-copy-generator -->',
'### 📣 Social Copy Generator',
'',
`❌ **Generation failed.** [View workflow logs](${runUrl}) for details.`,
'',
'- [ ] **Regenerate social media copies**',
].join('\n'),
});