Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions .github/scripts/extract-social-posts.js
Original file line number Diff line number Diff line change
@@ -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"),
};
}

Expand Down
11 changes: 8 additions & 3 deletions .github/scripts/post-hackernews.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 6 additions & 22 deletions .github/scripts/post-linkedin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
},
Expand All @@ -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
Expand Down
44 changes: 20 additions & 24 deletions .github/scripts/post-twitter.js → .github/scripts/post-x.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -53,24 +52,21 @@ 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",
// headers: {
// 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) {
Expand All @@ -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",
Expand All @@ -117,4 +113,4 @@ async function postTwitter({ context }) {

}

module.exports = postTwitter;
module.exports = postX;
7 changes: 4 additions & 3 deletions .github/workflows/post-sns.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
65 changes: 60 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number> --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).
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion utils/files/fixtures/gitauto.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.