|
| 1 | +/** |
| 2 | + * Generates a Contentful-ready markdown description for an approved Code Exchange submission. |
| 3 | + * Triggered when a reviewer checks the approval checkbox in ZiggyBot's evaluation comment. |
| 4 | + * |
| 5 | + * Posts the generated description as a new comment on the issue for the reviewer to copy |
| 6 | + * into Contentful. |
| 7 | + */ |
| 8 | + |
| 9 | +import Anthropic from "@anthropic-ai/sdk"; |
| 10 | + |
| 11 | +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; |
| 12 | +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; |
| 13 | +const ISSUE_NUMBER = process.env.ISSUE_NUMBER; |
| 14 | +const REPO = process.env.GITHUB_REPOSITORY; |
| 15 | + |
| 16 | +function parseIssueBody(body) { |
| 17 | + const sections = {}; |
| 18 | + const lines = (body || "").split("\n"); |
| 19 | + let currentSection = null; |
| 20 | + let currentLines = []; |
| 21 | + for (const line of lines) { |
| 22 | + const headerMatch = line.match(/^###\s+(.+)$/); |
| 23 | + if (headerMatch) { |
| 24 | + if (currentSection) sections[currentSection] = currentLines.join("\n").trim(); |
| 25 | + currentSection = headerMatch[1].trim(); |
| 26 | + currentLines = []; |
| 27 | + } else if (currentSection) { |
| 28 | + currentLines.push(line); |
| 29 | + } |
| 30 | + } |
| 31 | + if (currentSection) sections[currentSection] = currentLines.join("\n").trim(); |
| 32 | + return sections; |
| 33 | +} |
| 34 | + |
| 35 | +function extractGitHubUrl(text) { |
| 36 | + const match = (text || "").match(/https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)/); |
| 37 | + if (!match) return null; |
| 38 | + return { url: match[0], owner: match[1], repo: match[2].replace(/\.git$/, "") }; |
| 39 | +} |
| 40 | + |
| 41 | +async function fetchGitHub(path) { |
| 42 | + try { |
| 43 | + const res = await fetch(`https://api.github.com${path}`, { |
| 44 | + headers: { |
| 45 | + Authorization: `Bearer ${GITHUB_TOKEN}`, |
| 46 | + Accept: "application/vnd.github+json", |
| 47 | + "X-GitHub-Api-Version": "2022-11-28", |
| 48 | + }, |
| 49 | + }); |
| 50 | + if (!res.ok) return null; |
| 51 | + return res.json(); |
| 52 | + } catch { |
| 53 | + return null; |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +async function postComment(body) { |
| 58 | + const [owner, repo] = REPO.split("/"); |
| 59 | + const res = await fetch( |
| 60 | + `https://api.github.com/repos/${owner}/${repo}/issues/${ISSUE_NUMBER}/comments`, |
| 61 | + { |
| 62 | + method: "POST", |
| 63 | + headers: { |
| 64 | + Authorization: `Bearer ${GITHUB_TOKEN}`, |
| 65 | + Accept: "application/vnd.github+json", |
| 66 | + "X-GitHub-Api-Version": "2022-11-28", |
| 67 | + "Content-Type": "application/json", |
| 68 | + }, |
| 69 | + body: JSON.stringify({ body }), |
| 70 | + } |
| 71 | + ); |
| 72 | + if (!res.ok) { |
| 73 | + const text = await res.text(); |
| 74 | + throw new Error(`Failed to post comment: ${res.status} ${text}`); |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +async function main() { |
| 79 | + // Fetch the full issue to get its body |
| 80 | + const issue = await fetchGitHub(`/repos/${REPO}/issues/${ISSUE_NUMBER}`); |
| 81 | + if (!issue) throw new Error("Could not fetch issue data"); |
| 82 | + |
| 83 | + const sections = parseIssueBody(issue.body); |
| 84 | + const projectLinkSection = |
| 85 | + sections["Project link"] || sections["Project Link"] || Object.values(sections)[0] || ""; |
| 86 | + const parsed = extractGitHubUrl(projectLinkSection); |
| 87 | + if (!parsed) throw new Error("No GitHub URL found in issue"); |
| 88 | + |
| 89 | + const { owner, repo } = parsed; |
| 90 | + |
| 91 | + const [repoData, readmeData] = await Promise.all([ |
| 92 | + fetchGitHub(`/repos/${owner}/${repo}`), |
| 93 | + fetchGitHub(`/repos/${owner}/${repo}/readme`), |
| 94 | + ]); |
| 95 | + |
| 96 | + let readmeContent = ""; |
| 97 | + if (readmeData?.content) { |
| 98 | + readmeContent = Buffer.from(readmeData.content, "base64").toString("utf-8"); |
| 99 | + if (readmeContent.length > 10000) { |
| 100 | + readmeContent = readmeContent.slice(0, 10000) + "\n\n[... README truncated ...]"; |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + const shortDesc = |
| 105 | + sections["Short description (max 256 chars)"] || sections["Short description"] || ""; |
| 106 | + const longDesc = sections["Long Description"] || sections["Long description"] || ""; |
| 107 | + const language = sections["Language"] || ""; |
| 108 | + const authors = sections["Author(s)"] || sections["Authors"] || ""; |
| 109 | + |
| 110 | + const prompt = `You are writing the long description for an entry in Temporal's Code Exchange — a curated directory of community-built Temporal projects on temporal.io/code-exchange. |
| 111 | +
|
| 112 | +The description will be stored as markdown in Contentful and rendered on the website. Write it to be clear, accurate, and useful to developers evaluating whether this project is relevant to them. Avoid marketing language. Do not use superlatives. Be technical but approachable. |
| 113 | +
|
| 114 | +## Project Details |
| 115 | +
|
| 116 | +**Title:** ${issue.title.replace(/^\[Submission\]\s*/i, "").trim()} |
| 117 | +**GitHub URL:** ${parsed.url} |
| 118 | +**Language(s):** ${language} |
| 119 | +**License:** ${repoData?.license?.name || "unknown"} |
| 120 | +**Stars:** ${repoData?.stargazers_count ?? "unknown"} |
| 121 | +**Submitter description (short):** ${shortDesc} |
| 122 | +**Submitter description (long):** ${longDesc} |
| 123 | +**Author(s):** ${authors} |
| 124 | +
|
| 125 | +**README:** |
| 126 | +\`\`\` |
| 127 | +${readmeContent || "No README available."} |
| 128 | +\`\`\` |
| 129 | +
|
| 130 | +## Output Format |
| 131 | +
|
| 132 | +Write the Contentful markdown description using exactly this structure. Output only the markdown — no preamble, no explanation: |
| 133 | +
|
| 134 | +### What it does |
| 135 | +
|
| 136 | +[2–3 sentences describing what the project is and what problem it solves. Be specific.] |
| 137 | +
|
| 138 | +### How it uses Temporal |
| 139 | +
|
| 140 | +[2–3 sentences describing which Temporal concepts or patterns this project demonstrates or relies on — e.g. durable execution, activity retries, signals/queries, schedules, child workflows, sagas, etc. Be concrete.] |
| 141 | +
|
| 142 | +### Who it's for |
| 143 | +
|
| 144 | +[1–2 sentences describing the intended audience — e.g. developers building X, teams who need Y.] |
| 145 | +
|
| 146 | +### Getting started |
| 147 | +
|
| 148 | +[Brief summary of how to run or use the project, based on the README. If the README has clear setup steps, summarize them. If not, say so.]`; |
| 149 | + |
| 150 | + const client = new Anthropic({ apiKey: ANTHROPIC_API_KEY }); |
| 151 | + const message = await client.messages.create({ |
| 152 | + model: "claude-opus-4-5", |
| 153 | + max_tokens: 1024, |
| 154 | + messages: [{ role: "user", content: prompt }], |
| 155 | + }); |
| 156 | + |
| 157 | + const description = message.content[0].text.trim(); |
| 158 | + |
| 159 | + const comment = |
| 160 | + `## 📋 Contentful Description\n\n` + |
| 161 | + `Here's a ready-to-paste markdown description for this entry. Copy the block below into Contentful:\n\n` + |
| 162 | + `---\n\n` + |
| 163 | + description + |
| 164 | + `\n\n---\n\n` + |
| 165 | + `*Generated by ZiggyBot based on the project README and submission details.*`; |
| 166 | + |
| 167 | + await postComment(comment); |
| 168 | +} |
| 169 | + |
| 170 | +main().catch(async (err) => { |
| 171 | + console.error(err); |
| 172 | + await postComment( |
| 173 | + `## 📋 Contentful Description\n\n` + |
| 174 | + `ZiggyBot ran into an error generating the description. Please write it manually.\n\n` + |
| 175 | + `\`\`\`\n${err.message}\n\`\`\`` |
| 176 | + ).catch(() => {}); |
| 177 | + process.exit(1); |
| 178 | +}); |
0 commit comments