◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-05-05Intermediate

Stop Writing Weekly Reports Manually — Automate Them with Claude API, GitHub, Linear, and Slack

Automate your team's weekly Slack progress reports using Claude API. This guide walks through a Node.js system that pulls GitHub and Linear data, formats it with Claude API, and posts it to Slack automatically.

Claude API128automation109GitHub7Slack3Node.js4weekly reportLinear

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: repo scope 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 dotenv

Create 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-name

Fetching 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.js

The 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-05-15
Automating Wallpaper Classification with Claude Vision API — Accuracy, Cost, and a Batch Job That Survives Interruptions
A firsthand account of automating wallpaper category classification with the Claude Vision API. Honest numbers on accuracy and cost, plus the batch design that stopped me re-paying for the same images.
⬡ API & SDK2026-04-17
Building a GitHub PR Review Bot with Claude API — From Webhook Signature Verification to Structured Reviews with Tool Use
Build a production PR review bot with Claude API and GitHub Webhooks. Enforce structured scoring and security scanning via Tool Use, then handle the parts that bite in production — signature checks that pass only locally, 202 responses that hide failures, debouncing, and cost tracking.
⬡ API & SDK2026-10-16
Sending 40 Rows From Make to the Claude API Stops at Row 14 — Avoiding the 429 With One Subtraction
Why a no-code automation that sends spreadsheet rows to the Claude API one by one hits a 429 partway through, and how to fix it. Seven spacing strategies tested on a mock bucket, a formula for the Sleep delay, and settings that stop failed rows from vanishing.