Every Friday afternoon, engineers across the world open Jira, then GitHub, then dig through notes — spending 30 minutes or more compiling a progress report that people skim for 10 seconds. I did this for about a year before finally asking: why am I doing this manually?
Weekly progress reports matter. They keep stakeholders informed, help teams reflect on what they accomplished, and surface blockers early. But the actual work of collecting data and formatting it has zero creative value. That's exactly the kind of task Claude API was built for.
This guide walks through a complete Node.js system that pulls data from GitHub (commits and merged PRs) and Linear (completed issues), sends it to Claude API for intelligent formatting, and posts the result to Slack — fully automated via GitHub Actions every Friday.
System Overview
The flow is straightforward:
GitHub API → Fetch this week's commits and merged PRs
Linear API → Fetch this week's completed issues
↓
Claude API → Format data into a readable, human-sounding report
↓
Slack Webhook → Post to the designated channel
This runs as a scheduled GitHub Actions workflow (every Friday at 5 PM JST). No server required, and it fits comfortably within GitHub's free tier.
The key design decision: instead of using a fixed template, we let Claude API decide how to frame the week's work. This means the report adapts to what actually happened — a quiet week sounds different from a week with a major launch.
Setup and Prerequisites
You'll need Node.js v18+ and API keys for all four services:
- Anthropic API key: Get one at console.anthropic.com
- GitHub Personal Access Token:
reposcope only - Linear API key: Settings → API in your Linear workspace
- Slack Incoming Webhook URL: Create one via your Slack App configuration
Install dependencies:
npm init -y
npm install @anthropic-ai/sdk node-fetch dotenvCreate a .env file locally (use GitHub Secrets in production):
ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY
GITHUB_TOKEN=YOUR_GITHUB_TOKEN
LINEAR_API_KEY=YOUR_LINEAR_API_KEY
SLACK_WEBHOOK_URL=YOUR_SLACK_WEBHOOK_URL
GITHUB_OWNER=your-org-name
GITHUB_REPO=your-repo-nameFetching GitHub Data
We use the GitHub REST API to grab the past 7 days of activity:
// src/github.js
import { Octokit } from '@octokit/rest';
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
export async function getWeeklyGitHubData() {
const since = new Date();
since.setDate(since.getDate() - 7);
const sinceISO = since.toISOString();
const owner = process.env.GITHUB_OWNER;
const repo = process.env.GITHUB_REPO;
// Fetch merged PRs from the past week
const { data: pulls } = await octokit.pulls.list({
owner,
repo,
state: 'closed',
sort: 'updated',
direction: 'desc',
per_page: 50,
});
const mergedPRs = pulls
.filter(pr => pr.merged_at && new Date(pr.merged_at) >= since)
.map(pr => ({
title: pr.title,
number: pr.number,
author: pr.user.login,
mergedAt: pr.merged_at,
labels: pr.labels.map(l => l.name),
}));
// Fetch commits (excluding bots)
const { data: commits } = await octokit.repos.listCommits({
owner,
repo,
since: sinceISO,
per_page: 100,
});
const humanCommits = commits.filter(c =>
c.author && !c.author.login.includes('[bot]')
);
return {
mergedPRs,
commitCount: humanCommits.length,
activeContributors: [...new Set(humanCommits.map(c => c.author?.login).filter(Boolean))],
};
}The bot-filtering step is easy to miss. Without it, Dependabot and GitHub Actions commits inflate your numbers significantly — I've seen "50 commits this week!" turn out to be mostly automated dependency updates.
Fetching Linear Issues
Linear uses a GraphQL API, which feels unfamiliar if you're used to REST, but it's actually quite expressive once you write it out:
// src/linear.js
const LINEAR_API_URL = 'https://api.linear.app/graphql';
export async function getWeeklyLinearData() {
const since = new Date();
since.setDate(since.getDate() - 7);
const sinceISO = since.toISOString();
const query = `
query WeeklyIssues($since: DateTime!) {
issues(
filter: {
completedAt: { gte: $since }
state: { type: { eq: "completed" } }
}
orderBy: updatedAt
first: 50
) {
nodes {
title
identifier
assignee { name }
labels { nodes { name } }
completedAt
estimate
}
}
}
`;
const response = await fetch(LINEAR_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: process.env.LINEAR_API_KEY,
},
body: JSON.stringify({ query, variables: { since: sinceISO } }),
});
const { data, errors } = await response.json();
if (errors) {
console.error('Linear API error:', errors);
throw new Error('Failed to fetch Linear data');
}
return data.issues.nodes.map(issue => ({
title: issue.title,
identifier: issue.identifier,
assignee: issue.assignee?.name ?? 'Unassigned',
labels: issue.labels.nodes.map(l => l.name),
estimate: issue.estimate ?? null,
}));
}The estimate field captures story points, which lets Claude reference velocity in the report — "the team completed 20 story points this week" carries more weight than a raw issue count.
Generating the Report with Claude API
This is where the system earns its value. We're not filling a template — we're asking Claude to read the data and decide how to communicate it:
// src/report.js
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
export async function generateWeeklyReport(githubData, linearIssues) {
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const prompt = `
You're writing a weekly engineering progress report for Slack (${today}).
## GitHub Activity
- Merged PRs: ${githubData.mergedPRs.length}
- Total commits: ${githubData.commitCount}
- Active contributors: ${githubData.activeContributors.join(', ')}
### Merged PRs this week:
${githubData.mergedPRs.map(pr => `- #${pr.number}: ${pr.title} (by ${pr.author})`).join('\n')}
## Linear Issues Completed
- Issues closed: ${linearIssues.length}
- Total story points: ${linearIssues.reduce((sum, i) => sum + (i.estimate ?? 0), 0)}
### Completed issues:
${linearIssues.map(issue => `- [${issue.identifier}] ${issue.title} (${issue.assignee})`).join('\n')}
## Formatting rules:
- Use Slack mrkdwn format (*bold*, :emoji:, etc.)
- Start with a 2-3 sentence highlight that *reads from the data* (don't just list numbers — tell the story of what happened this week)
- Briefly summarize both PRs and Linear issues
- End with a brief forward-looking note
- Keep it to 250-350 words total
- Sound like a human, not a bot
`;
const message = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }],
});
return message.content[0].text;
}The critical instruction is "reads from the data — tell the story of what happened." Without it, Claude defaults to listing information rather than interpreting it. With it, you get openings like "This week saw a focused push on the authentication layer, with four related PRs landing back to back" — which is the kind of context that makes a report worth reading.
Posting to Slack and Wiring Everything Together
// src/slack.js
export async function postToSlack(text) {
const response = await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, unfurl_links: false }),
});
if (!response.ok) {
throw new Error(`Slack post failed: ${response.status}`);
}
}// src/index.js
import 'dotenv/config';
import { getWeeklyGitHubData } from './github.js';
import { getWeeklyLinearData } from './linear.js';
import { generateWeeklyReport } from './report.js';
import { postToSlack } from './slack.js';
async function main() {
console.log('Generating weekly report...');
try {
const [githubData, linearIssues] = await Promise.all([
getWeeklyGitHubData(),
getWeeklyLinearData(),
]);
console.log(`GitHub: ${githubData.mergedPRs.length} PRs, ${githubData.commitCount} commits`);
console.log(`Linear: ${linearIssues.length} issues completed`);
const report = await generateWeeklyReport(githubData, linearIssues);
await postToSlack(report);
console.log('✅ Weekly report posted');
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
}
main();Scheduling with GitHub Actions
Create .github/workflows/weekly-report.yml:
name: Weekly Progress Report
on:
schedule:
# Every Friday at 08:00 UTC (5 PM JST)
- cron: '0 8 * * 5'
workflow_dispatch: # Allow manual runs during development
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Generate and post weekly report
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
GITHUB_OWNER: ${{ github.repository_owner }}
GITHUB_REPO: ${{ github.event.repository.name }}
run: node src/index.jsThe workflow_dispatch trigger is worth including — it lets you run the workflow manually from GitHub's UI without waiting for Friday. This is how you'll test it the first time.
Notes From Running This in Production
After three months of running this system, a few things stood out.
Claude's output never feels copy-paste. Even with identical data structures, each report reads slightly differently. This is a meaningful improvement over templates — team members actually read them.
Not using Linear? The getWeeklyLinearData function can be replaced with Jira (REST API), GitHub Issues, or any project management tool. The return format just needs to match what the report generator expects.
Cost is negligible. Using claude-sonnet-4-6 once per week costs a few cents per month at current pricing. Extended Thinking isn't necessary here — the standard model produces reports that are genuinely good.
The best way to start is to run it manually with workflow_dispatch, see what Claude generates with your actual data, and adjust the prompt from there. The first report is usually surprisingly usable.