404 lines
17 KiB
YAML
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'),
|
|
});
|