diff --git a/.github/scripts/extract-social-posts.js b/.github/scripts/extract-social-posts.js index ba17922f1..c306d1ce9 100644 --- a/.github/scripts/extract-social-posts.js +++ b/.github/scripts/extract-social-posts.js @@ -1,20 +1,22 @@ /** * Extracts social media posts from PR body. - * Supports both new format (separate GitAuto/Wes sections) and legacy single section. + * Matrix of author × platform: GitAuto/Wes × X/LinkedIn, plus an optional HN title. * * @param {string} body - PR body text - * @returns {{ gitauto: string, wes: string }} Extracted posts (empty string if not found) + * @returns {{ gitautoX: string, gitautoLinkedIn: string, wesX: string, wesLinkedIn: string, hnTitle: string }} */ function extractSocialPosts(body) { - const gitautoMatch = body.match(/## Social Media Post \(GitAuto\)\s*\n([\s\S]*?)(?=\n## |\n$|$)/i); - const wesMatch = body.match(/## Social Media Post \(Wes\)\s*\n([\s\S]*?)(?=\n## |\n$|$)/i); - - // Fall back to single "## Social Media Post" for backward compatibility - const fallbackMatch = body.match(/## Social Media Post\s*\n([\s\S]*?)(?=\n##|\n$|$)/i); + const section = (label) => { + const pattern = new RegExp(`## Social Media Post \\(${label}\\)\\s*\\n([\\s\\S]*?)(?=\\n## |\\n$|$)`, "i"); + return body.match(pattern)?.[1]?.trim() || ""; + }; return { - gitauto: (gitautoMatch?.[1] || fallbackMatch?.[1] || "").trim(), - wes: (wesMatch?.[1] || fallbackMatch?.[1] || "").trim(), + gitautoX: section("GitAuto on X"), + gitautoLinkedIn: section("GitAuto on LinkedIn"), + wesX: section("Wes on X"), + wesLinkedIn: section("Wes on LinkedIn"), + hnTitle: section("HN Title"), }; } diff --git a/.github/scripts/post-hackernews.js b/.github/scripts/post-hackernews.js index c9ec559cf..95a7d193d 100644 --- a/.github/scripts/post-hackernews.js +++ b/.github/scripts/post-hackernews.js @@ -28,10 +28,15 @@ async function postHackerNews({ context }) { // Extract social media post from PR body if present const description = context.payload.pull_request.body || ""; - const { gitauto: socialPost } = extractSocialPosts(description); + const { hnTitle } = extractSocialPosts(description); - // Use GitAuto post if available, otherwise PR title - const title = (socialPost || context.payload.pull_request.title).substring(0, 80); + if (!hnTitle) { + console.log("No HN Title section found in PR body, skipping HN post"); + await browser.close(); + return; + } + + const title = hnTitle.substring(0, 80); const url = "https://gitauto.ai?utm_source=hackernews&utm_medium=referral" await page.fill('input[name="title"]', title); await page.fill('input[name="url"]', url); diff --git a/.github/scripts/post-linkedin.js b/.github/scripts/post-linkedin.js index 20f6e10eb..013d8dd21 100644 --- a/.github/scripts/post-linkedin.js +++ b/.github/scripts/post-linkedin.js @@ -12,13 +12,11 @@ async function postLinkedIn({ context }) { const accessToken = process.env.LINKEDIN_ACCESS_TOKEN; const description = context.payload.pull_request.body || ""; - const url = "https://gitauto.ai?utm_source=linkedin&utm_medium=referral" - const { gitauto: gitautoText, wes: wesText } = extractSocialPosts(description); + const { gitautoLinkedIn: gitautoText, wesLinkedIn: wesText } = extractSocialPosts(description); - if (!gitautoText && !wesText) { - console.log("No Social Media Post section found in PR body, skipping LinkedIn post"); - return; - } + if (!gitautoText) console.log("No 'GitAuto on LinkedIn' section, skipping GitAuto post"); + if (!wesText) console.log("No 'Wes on LinkedIn' section, skipping Wes post"); + if (!gitautoText && !wesText) return; // Helper function for random delay between 5-15 seconds const getRandomDelay = () => Math.floor(Math.random() * 10000 + 5000); @@ -37,15 +35,6 @@ async function postLinkedIn({ context }) { targetEntities: [], thirdPartyDistributionChannels: [], }, - - // https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/advertising-targeting/version/article-ads-integrations?view=li-lms-2024-11&tabs=http#workflow - content: { - article: { - source: url, - title: text, - description: description || `Check out our latest release!`, - }, - }, lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: false, }, @@ -64,18 +53,13 @@ async function postLinkedIn({ context }) { }); }; - // Post from both accounts + // Post each account independently based on which sections exist const companyPost = gitautoText ? await createPost(gitautoUrn, gitautoText) : null; const companyPostUrn = companyPost?.headers["x-restli-id"]; const wesPost = wesText ? await createPost(wesUrn, wesText) : null; const wesPostUrn = wesPost?.headers["x-restli-id"]; - if (!companyPostUrn && !wesPostUrn) { - console.log("Both posts are empty, skipping"); - return; - } - - // Wait and like each other's posts + // Wait and like each other's posts only when both were posted if (companyPostUrn && wesPostUrn) { await sleep(getRandomDelay()); await likePost(gitautoUrn, wesPostUrn); // Company likes Wes's post diff --git a/.github/scripts/post-twitter.js b/.github/scripts/post-x.js similarity index 71% rename from .github/scripts/post-twitter.js rename to .github/scripts/post-x.js index c972cc6d9..f2fb2ac00 100644 --- a/.github/scripts/post-twitter.js +++ b/.github/scripts/post-x.js @@ -4,7 +4,7 @@ const { TwitterApi } = require("twitter-api-v2"); /** * @see https://developer.x.com/en/docs/x-api/tweets/manage-tweets/api-reference/post-tweets */ -async function postTwitter({ context }) { +async function postX({ context }) { // Company account: https://console.x.com/accounts/1868157094207295489/apps const clientCompany = new TwitterApi({ appKey: process.env.X_OAUTH1_CONSUMER_KEY_GITAUTO, @@ -22,12 +22,11 @@ async function postTwitter({ context }) { }); const description = context.payload.pull_request.body || ""; - const { gitauto: gitautoTweet, wes: wesTweet } = extractSocialPosts(description); + const { gitautoX: gitautoPost, wesX: wesPost } = extractSocialPosts(description); - if (!gitautoTweet && !wesTweet) { - console.log("No Social Media Post section found in PR body, skipping Twitter post"); - return; - } + if (!gitautoPost) console.log("No 'GitAuto on X' section, skipping GitAuto tweet"); + if (!wesPost) console.log("No 'Wes on X' section, skipping Wes tweet"); + if (!gitautoPost && !wesPost) return; // Senders have to be in the community // https://x.com/hnishio0105/communities @@ -39,9 +38,9 @@ async function postTwitter({ context }) { "1498737511241158657", // Startup founders & friends ]; - // Post tweets and get their IDs + // GitAuto is on X free tier (280 char hard limit). Wes has X Premium so long-form is allowed. // https://github.com/PLhery/node-twitter-api-v2/blob/master/doc/v2.md#create-a-tweet - const postTweet = async (client, text) => { + const postFree = async (client, text) => { try { return await client.v2.tweet(text); } catch (error) { @@ -53,16 +52,13 @@ async function postTwitter({ context }) { } }; - const companyTweetResult = gitautoTweet ? await postTweet(clientCompany, gitautoTweet) : null; - const wesTweetResult = wesTweet ? await postTweet(clientWes, wesTweet) : null; + const postPremium = async (client, text) => client.v2.tweet(text); - if (!companyTweetResult && !wesTweetResult) { - console.log("Both posts are empty, skipping"); - return; - } + const companyResult = gitautoPost ? await postFree(clientCompany, gitautoPost) : null; + const wesResult = wesPost ? await postPremium(clientWes, wesPost) : null; // https://docs.x.com/x-api/posts/creation-of-a-post - // const communityTweets = await Promise.all( + // const communityPosts = await Promise.all( // communityIds.map(async (communityId) => { // const response = await fetch("https://api.x.com/2/tweets", { // method: "POST", @@ -70,7 +66,7 @@ async function postTwitter({ context }) { // Authorization: `Bearer ${process.env.TWITTER_BEARER_TOKEN_WES}`, // "Content-Type": "application/json", // }, - // body: JSON.stringify({ text: tweet, community_id: communityId }), + // body: JSON.stringify({ text: post, community_id: communityId }), // }); // if (!response.ok) { @@ -87,24 +83,24 @@ async function postTwitter({ context }) { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); await sleep(getRandomDelay()); - // Like each other's tweets (requires paid X API access) + // Like each other's posts (only when both were posted, requires paid X API access) // https://github.com/PLhery/node-twitter-api-v2/blob/master/doc/v2.md#like-a-tweet - if (companyTweetResult && wesTweetResult) { + if (companyResult && wesResult) { try { const userCompany = await clientCompany.v2.me(); - await clientCompany.v2.like(userCompany.data.id, wesTweetResult.data.id); + await clientCompany.v2.like(userCompany.data.id, wesResult.data.id); const userWes = await clientWes.v2.me(); - await clientWes.v2.like(userWes.data.id, companyTweetResult.data.id); + await clientWes.v2.like(userWes.data.id, companyResult.data.id); } catch (error) { - console.log("Failed to like tweets (free tier):", error.message); + console.log("Failed to like posts (free tier):", error.message); } } // Send to Slack if (process.env.SLACK_BOT_TOKEN) { const links = [ - companyTweetResult ? `https://x.com/gitautoai/status/${companyTweetResult.data.id}` : null, - wesTweetResult ? `https://x.com/hiroshinishio/status/${wesTweetResult.data.id}` : null, + companyResult ? `https://x.com/gitautoai/status/${companyResult.data.id}` : null, + wesResult ? `https://x.com/hiroshinishio/status/${wesResult.data.id}` : null, ].filter(Boolean).join(" and "); await fetch("https://slack.com/api/chat.postMessage", { method: "POST", @@ -117,4 +113,4 @@ async function postTwitter({ context }) { } -module.exports = postTwitter; +module.exports = postX; diff --git a/.github/workflows/post-sns.yml b/.github/workflows/post-sns.yml index 916ebbd16..bbae63644 100644 --- a/.github/workflows/post-sns.yml +++ b/.github/workflows/post-sns.yml @@ -52,8 +52,8 @@ jobs: uses: actions/github-script@v8 with: script: | - const postTwitter = require('.github/scripts/post-twitter.js'); - await postTwitter({ context }); + const postX = require('.github/scripts/post-x.js'); + await postX({ context }); env: X_OAUTH1_CONSUMER_KEY_GITAUTO: ${{ secrets.X_OAUTH1_CONSUMER_KEY_GITAUTO }} X_OAUTH1_CONSUMER_KEY_SECRET_GITAUTO: ${{ secrets.X_OAUTH1_CONSUMER_KEY_SECRET_GITAUTO }} @@ -90,7 +90,8 @@ jobs: LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }} post-hackernews: - if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'sns') + needs: check-python-changes + if: needs.check-python-changes.outputs.has_python_changes == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/CLAUDE.md b/CLAUDE.md index 6fd626310..30f5cb551 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,11 +142,13 @@ assert find_test_files("foo.ts", all_files, None) == ["foo.test.ts"] 6. Check recent posts: `scripts/git/recent_social_posts.sh` 7. `gh pr edit --body "..."` — add summary and social posts after checking recent posts - Technical, descriptive title. **No `## Test plan`**. - - **Two posts** (last section, customer-facing only): GitAuto (changelog) + Wes (personal voice, don't emphasize "GitAuto") - - Format: `## Social Media Post (GitAuto)` and `## Social Media Post (Wes)` headers (parsed by `extract-social-posts.js`) - - **GitAuto post**: Changelog format — one-liner headline + customer-facing feature bullets (no test/internal changes). Each feature on one line. Include items mentioned in the PR title. - - **Wes post**: Honest stories. Vary openers — check recent posts first. - - Guidelines: No em dashes (—). Under 280 chars. No marketing keywords. No negative framing. No internal names. No small numbers — use relative language. + - **Five posts** (last section, customer-facing only) — four author × platform cells plus an HN title. See `## Social Media Rules` below for the voice, length, and style rules for each. Headers (parsed by `extract-social-posts.js`): + - `## Social Media Post (GitAuto on X)` + - `## Social Media Post (GitAuto on LinkedIn)` + - `## Social Media Post (Wes on X)` + - `## Social Media Post (Wes on LinkedIn)` + - `## Social Media Post (HN Title)` + - **Each section is independent.** If a section exists, that post fires; if it's missing, only that post is skipped. No all-or-nothing coupling. 8. If Sentry issue: `python3 scripts/sentry/get_issue.py AGENT-XXX` then `python3 scripts/sentry/resolve_issue.py AGENT-XXX ...` 9. **Blog post** in `../website/app/blog/posts/`: - `YYYY-MM-DD-kebab-case-title.mdx`. Universal dev lesson, not GitAuto internals (exception: deep technical content). @@ -180,6 +182,59 @@ assert find_test_files("foo.ts", all_files, None) == ["foo.test.ts"] - Dev.to crops to 1000x420 — keep important content centered. 10. **Docs page** in `../website/app/docs/`: Create new or update existing. Browse for best-fit category. New pages: 3 files (`page.tsx`, `layout.tsx`, `jsonld.ts`). +## Social Media Rules + +Five posts per PR — four cells of {GitAuto, Wes} × {X, LinkedIn}, plus one HN title. Don't copy text across platforms — each reader and format is different. Before writing, run `scripts/git/recent_social_posts.sh` and vary openers. + +**Shared rules (apply to all five):** + +- No em dashes (—). No marketing keywords. No negative framing. No internal names. +- No small absolute numbers — use relative language ("30% faster", not "2s faster"). +- Honest, technical tone. Specify model names (e.g., "Claude Opus 4.6"). +- Customer-facing only — skip test/internal changes. +- **URLs are optional.** Most posts should be pure text. Include a URL only when the post genuinely points somewhere (blog post, docs page, demo). When included, put it inline in the text — X and LinkedIn both auto-expand naked URLs into preview cards from the destination's OG tags. + +### GitAuto on X + +- **Reader**: devs scrolling fast. First line decides everything. +- **Voice**: product changelog. Terse, factual. +- **Length**: **hard limit 280 chars** (free tier, truncated at 277 + `...` if over). +- **Format**: one-liner headline + tight feature bullets. Each feature on one line. Include items from the PR title. +- **Avoid**: hashtag stuffing, 🧵 threads, self-promotion beyond the changelog fact. + +### GitAuto on LinkedIn + +- **Reader**: engineering managers, founders, buyers. Slower scroll, will click "see more". +- **Voice**: product credibility. "We shipped X because customers hit Y." +- **Length**: **400-800 chars**. Line breaks between sentences for scannability. +- **Format**: lead with the user-facing outcome, then the technical specifics, then the link. Concrete lessons from real incidents land well. +- **Avoid**: hashtag spam, emoji walls, tweet-style copy without line breaks, thought-leadership platitudes. + +### Wes on X + +- **Reader**: devs following a build-in-public founder. Looking for signal, humor, and real stories. +- **Voice**: personal. First person. Don't emphasize "GitAuto" — the post is about what you built or broke, not the product. +- **Length**: Wes has **X Premium** so long-form is allowed (up to 25k chars). Use **first 280 chars as a hook** (this is what shows above "Show more"). Expand below the fold with the full story if the lesson deserves it. +- **Format**: hot takes, counterintuitive findings, specific before/after snippets, self-deprecating failure stories work best. +- **Avoid**: corporate voice, LinkedIn-style "I learned that…" openers, generic advice, starting with the same opener as recent posts. + +### Wes on LinkedIn + +- **Reader**: professional network. Engineering leaders who know Wes as a technical founder. +- **Voice**: personal, narrative. Don't emphasize "GitAuto". +- **Length**: **600-1200 chars**. Line breaks between thoughts. +- **Format**: problem → discovery → fix arc. Honest stories of what broke and what you learned. +- **Avoid**: hashtags, selling, tweet-style copy, overly polished "lesson learned" framing. + +### Hacker News + +- **Reader**: senior engineers, skeptical, allergic to marketing. Title is ~90% of the click. +- **Header**: `## Social Media Post (HN Title)` — one line, no body. If missing, the HN job skips. +- **Length**: **≤80 chars**, hard cap (truncated in `post-hackernews.js`). +- **Voice**: technical and specific. Postmortem framing ("Why X broke and the 4-line fix") or "Show HN:" for tools. Never changelog-style, never promotional. +- **Avoid**: product names as the subject, marketing adjectives ("revolutionary", "powerful", "simple"), vague claims. The HN crowd downvotes anything that smells like content marketing. +- **Why separate from GitAuto X**: changelog voice (280 char) trims poorly to 80, and HN's audience wants a different framing than a product announcement. + ## CRITICAL: Fixing Foxquilt PRs **NEVER run checkout_pr_branch_and_get_circleci_logs.py multiple times in parallel!** Each run overwrites the previous checkout. Work ONE PR at a time: get logs → fix → commit → push → next. diff --git a/pyproject.toml b/pyproject.toml index 8827ecc90..737bad730 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "GitAuto" -version = "1.55.2" +version = "1.55.3" requires-python = ">=3.14" dependencies = [ "annotated-doc==0.0.4", diff --git a/utils/files/fixtures/gitauto.txt b/utils/files/fixtures/gitauto.txt index f767d4859..dbc650b9f 100644 --- a/utils/files/fixtures/gitauto.txt +++ b/utils/files/fixtures/gitauto.txt @@ -8,7 +8,7 @@ .github/scripts/extract-social-posts.js .github/scripts/post-hackernews.js .github/scripts/post-linkedin.js -.github/scripts/post-twitter.js +.github/scripts/post-x.js .github/scripts/vacuum_table.sh .github/workflows/check-database-size.yml .github/workflows/claude.yml diff --git a/uv.lock b/uv.lock index a0462a279..091c90d6e 100644 --- a/uv.lock +++ b/uv.lock @@ -596,7 +596,7 @@ wheels = [ [[package]] name = "gitauto" -version = "1.55.2" +version = "1.55.3" source = { virtual = "." } dependencies = [ { name = "annotated-doc" },