Skip to content

Commit 0354553

Browse files
committed
feat: fetch releases via Releases API with full body
Add fetch-releases.ts to collect releases per repository using GET /repos/{owner}/{repo}/releases with date range filtering. Release body is truncated to 500 chars and passed to LLM context so highlights can reference actual changelog content. Events in github-data.yaml now only contain review events. Release events from Events API (tag+name only) are replaced by the richer Releases API data (tag+name+body+url).
1 parent 686eeaf commit 0354553

12 files changed

Lines changed: 310 additions & 34 deletions

src/cli/commands/fetch.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { fetchEvents, dedupeEvents } from "../../collector/fetch-events.js";
1010
import { fetchContributions } from "../../collector/fetch-contributions.js";
1111
import { fetchPRsByRefs, type PRRef } from "../../collector/fetch-repo-prs.js";
1212
import { fetchCommitMessages } from "../../collector/fetch-commits.js";
13+
import { fetchReleases } from "../../collector/fetch-releases.js";
1314
import { aggregateRepositories } from "../../collector/aggregate.js";
1415
import { getWeekId, getCurrentWeekId } from "../../deployer/week.js";
1516
import type { GitHubEvent } from "../../types.js";
@@ -238,6 +239,11 @@ const runWeeklyFetch = async (options: BaseOptions): Promise<void> => {
238239
const totalMsgs = commitMessages.reduce((sum, r) => sum + r.messages.length, 0);
239240
console.log(`Collected ${totalMsgs} commit messages from ${commitMessages.length} repositories.`);
240241

242+
// Fetch releases per repository
243+
console.log(`Fetching releases for ${repoNames.length} repositories...`);
244+
const releases = await fetchReleases(options.token, repoNames, plan.range);
245+
console.log(`Collected ${releases.length} releases.`);
246+
241247
const githubData = {
242248
username: contributions.username,
243249
avatarUrl: contributions.avatarUrl,
@@ -257,8 +263,9 @@ const runWeeklyFetch = async (options: BaseOptions): Promise<void> => {
257263
repositories,
258264
pullRequests,
259265
issues: [],
260-
events: events.filter((e) => e.payload.kind === "review" || e.payload.kind === "release"),
266+
events: events.filter((e) => e.payload.kind === "review"),
261267
commitMessages,
268+
releases,
262269
externalContributions: [],
263270
};
264271
const dataPath = join(plan.reportDir, "github-data.yaml");

src/cli/commands/generate.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ pullRequests: []
178178
issues: []
179179
events: []
180180
commitMessages: []
181+
releases: []
181182
externalContributions: []
182183
`;
183184

src/cli/commands/render.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ pullRequests: []
7373
issues: []
7474
events: []
7575
commitMessages: []
76+
releases: []
7677
externalContributions: []
7778
`;
7879

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { fetchReleases } from "./fetch-releases.js";
3+
import type { DateRange } from "./date-range.js";
4+
5+
const range: DateRange = {
6+
from: new Date("2026-03-30T00:00:00Z"),
7+
to: new Date("2026-04-05T23:59:59Z"),
8+
};
9+
10+
const makeRawRelease = (tag: string, publishedAt: string, body?: string) => ({
11+
tag_name: tag,
12+
name: tag,
13+
body: body ?? `Release ${tag}`,
14+
html_url: `https://github.com/org/repo/releases/tag/${tag}`,
15+
published_at: publishedAt,
16+
});
17+
18+
describe("fetchReleases", () => {
19+
beforeEach(() => {
20+
vi.restoreAllMocks();
21+
});
22+
23+
it("fetches releases within date range", async () => {
24+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
25+
new Response(JSON.stringify([
26+
makeRawRelease("v1.0.0", "2026-04-01T12:00:00Z"),
27+
makeRawRelease("v0.9.0", "2026-03-20T12:00:00Z"), // out of range
28+
]), { status: 200 }),
29+
);
30+
31+
const result = await fetchReleases("token", ["org/repo"], range);
32+
33+
expect(result).toHaveLength(1);
34+
expect(result[0].tag).toBe("v1.0.0");
35+
expect(result[0].repo).toBe("org/repo");
36+
expect(result[0].body).toBe("Release v1.0.0");
37+
});
38+
39+
it("fetches releases from multiple repos", async () => {
40+
vi.spyOn(globalThis, "fetch")
41+
.mockResolvedValueOnce(
42+
new Response(JSON.stringify([
43+
makeRawRelease("v2.0.0", "2026-04-02T12:00:00Z"),
44+
]), { status: 200 }),
45+
)
46+
.mockResolvedValueOnce(
47+
new Response(JSON.stringify([
48+
makeRawRelease("v3.0.0", "2026-04-03T12:00:00Z"),
49+
]), { status: 200 }),
50+
);
51+
52+
const result = await fetchReleases("token", ["org/repo-a", "org/repo-b"], range);
53+
54+
expect(result).toHaveLength(2);
55+
});
56+
57+
it("truncates long release bodies to 500 chars", async () => {
58+
const longBody = "a".repeat(600);
59+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
60+
new Response(JSON.stringify([
61+
makeRawRelease("v1.0.0", "2026-04-01T12:00:00Z", longBody),
62+
]), { status: 200 }),
63+
);
64+
65+
const result = await fetchReleases("token", ["org/repo"], range);
66+
67+
expect(result[0].body).toBe("a".repeat(500) + "...");
68+
});
69+
70+
it("skips repos returning 404", async () => {
71+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
72+
new Response("", { status: 404 }),
73+
);
74+
75+
const result = await fetchReleases("token", ["org/deleted"], range);
76+
77+
expect(result).toHaveLength(0);
78+
});
79+
80+
it("returns empty array for repos with no releases in range", async () => {
81+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
82+
new Response(JSON.stringify([
83+
makeRawRelease("v0.1.0", "2026-01-01T12:00:00Z"), // out of range
84+
]), { status: 200 }),
85+
);
86+
87+
const result = await fetchReleases("token", ["org/repo"], range);
88+
89+
expect(result).toHaveLength(0);
90+
});
91+
92+
it("returns empty for empty repos list", async () => {
93+
const result = await fetchReleases("token", [], range);
94+
95+
expect(result).toEqual([]);
96+
});
97+
98+
it("retries on 429 rate limit", async () => {
99+
vi.spyOn(globalThis, "fetch")
100+
.mockResolvedValueOnce(
101+
new Response("", { status: 429, headers: { "retry-after": "0" } }),
102+
)
103+
.mockResolvedValueOnce(
104+
new Response(JSON.stringify([
105+
makeRawRelease("v1.0.0", "2026-04-01T12:00:00Z"),
106+
]), { status: 200 }),
107+
);
108+
109+
const result = await fetchReleases("token", ["org/repo"], range);
110+
111+
expect(result).toHaveLength(1);
112+
});
113+
114+
it("handles null body", async () => {
115+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
116+
new Response(JSON.stringify([
117+
{ tag_name: "v1.0.0", name: "v1.0.0", body: null, html_url: "https://example.com", published_at: "2026-04-01T12:00:00Z" },
118+
]), { status: 200 }),
119+
);
120+
121+
const result = await fetchReleases("token", ["org/repo"], range);
122+
123+
expect(result[0].body).toBeNull();
124+
});
125+
});

src/collector/fetch-releases.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Fetch releases per repository via GitHub REST API
2+
// GET /repos/{owner}/{repo}/releases
3+
4+
import type { DateRange } from "./date-range.js";
5+
import type { Release } from "../types.js";
6+
7+
type RawRelease = {
8+
tag_name: string;
9+
name: string | null;
10+
body: string | null;
11+
html_url: string;
12+
published_at: string | null;
13+
};
14+
15+
const MAX_BODY_LENGTH = 500;
16+
const MAX_RETRIES = 3;
17+
const DEFAULT_RETRY_DELAY_MS = 5_000;
18+
const CONCURRENCY = 5;
19+
const REQUEST_DELAY_MS = 100;
20+
21+
const GITHUB_HEADERS = (token: string) => ({
22+
Authorization: `Bearer ${token}`,
23+
Accept: "application/vnd.github+json",
24+
"X-GitHub-Api-Version": "2022-11-28",
25+
"User-Agent": "github-weekly-reporter",
26+
});
27+
28+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
29+
30+
const parseRetryDelay = (response: Response): number => {
31+
const retryAfter = response.headers.get("retry-after");
32+
if (retryAfter) {
33+
const seconds = Number(retryAfter);
34+
if (!Number.isNaN(seconds)) return seconds * 1000;
35+
}
36+
return DEFAULT_RETRY_DELAY_MS;
37+
};
38+
39+
const truncateBody = (body: string | null): string | null => {
40+
if (!body) return null;
41+
return body.length > MAX_BODY_LENGTH
42+
? `${body.slice(0, MAX_BODY_LENGTH)}...`
43+
: body;
44+
};
45+
46+
const isInRange = (publishedAt: string | null, range: DateRange): boolean => {
47+
if (!publishedAt) return false;
48+
const t = new Date(publishedAt).getTime();
49+
return t >= range.from.getTime() && t <= range.to.getTime();
50+
};
51+
52+
const fetchRepoReleases = async (
53+
token: string,
54+
repo: string,
55+
range: DateRange,
56+
): Promise<Release[]> => {
57+
// Fetch recent releases (per_page=10 is enough for a single week)
58+
const url = `https://api.github.com/repos/${repo}/releases?per_page=10`;
59+
60+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
61+
const response = await fetch(url, { headers: GITHUB_HEADERS(token) });
62+
63+
if (response.ok) {
64+
const raw = (await response.json()) as RawRelease[];
65+
return raw
66+
.filter((r) => isInRange(r.published_at, range))
67+
.map((r) => ({
68+
repo,
69+
tag: r.tag_name,
70+
name: r.name ?? r.tag_name,
71+
body: truncateBody(r.body),
72+
url: r.html_url,
73+
publishedAt: r.published_at ?? "",
74+
}));
75+
}
76+
77+
if (response.status === 409 || response.status === 403 || response.status === 404) {
78+
return [];
79+
}
80+
81+
if (response.status === 429 && attempt < MAX_RETRIES) {
82+
const delay = parseRetryDelay(response);
83+
console.warn(` ${repo}: 429, retrying in ${Math.round(delay / 1000)}s (attempt ${attempt + 1}/${MAX_RETRIES})`);
84+
await sleep(delay);
85+
continue;
86+
}
87+
88+
console.warn(` Failed to fetch releases for ${repo}: ${response.status} ${response.statusText}`);
89+
return [];
90+
}
91+
92+
return [];
93+
};
94+
95+
const runWithConcurrency = async <T>(
96+
items: T[],
97+
fn: (item: T) => Promise<void>,
98+
): Promise<void> => {
99+
const queue = [...items];
100+
const workers = Array.from({ length: CONCURRENCY }, async () => {
101+
while (queue.length > 0) {
102+
const item = queue.shift();
103+
if (item) {
104+
await fn(item);
105+
await sleep(REQUEST_DELAY_MS);
106+
}
107+
}
108+
});
109+
await Promise.all(workers);
110+
};
111+
112+
export const fetchReleases = async (
113+
token: string,
114+
repos: string[],
115+
range: DateRange,
116+
): Promise<Release[]> => {
117+
const results: Release[] = [];
118+
119+
await runWithConcurrency(repos, async (repo) => {
120+
const releases = await fetchRepoReleases(token, repo, range);
121+
results.push(...releases);
122+
});
123+
124+
return results;
125+
};

src/llm/generate-content.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const MOCK_INPUT: NarrativeInput = {
7777
},
7878
],
7979
commitMessages: [],
80+
releases: [],
8081
externalContributions: [],
8182
};
8283

src/llm/llm.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const MOCK_INPUT: NarrativeInput = {
1414
issues: [],
1515
events: [],
1616
commitMessages: [],
17+
releases: [],
1718
externalContributions: [],
1819
};
1920

src/llm/preprocess.test.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const MOCK_INPUT: NarrativeInput = {
4242
issues: [],
4343
events: [],
4444
commitMessages: [],
45+
releases: [],
4546
externalContributions: [],
4647
};
4748

@@ -168,19 +169,11 @@ describe("buildLLMContext", () => {
168169
createdAt: "2026-04-01T10:00:00Z",
169170
payload: { kind: "push", ref: "refs/heads/main", commits: [] },
170171
},
171-
{
172-
id: "w1",
173-
type: "WatchEvent",
174-
repo: "org/repo-a",
175-
createdAt: "2026-04-01T10:00:00Z",
176-
payload: { kind: "generic", action: "started" },
177-
},
178172
],
179173
};
180174
const context = buildLLMContext(input);
181-
expect(context).not.toContain("reviews_and_releases:");
175+
expect(context).not.toContain("\nreviews:");
182176
expect(context).not.toContain("PushEvent");
183-
expect(context).not.toContain("WatchEvent");
184177
});
185178

186179
it("includes review events in LLM context", () => {
@@ -197,27 +190,34 @@ describe("buildLLMContext", () => {
197190
],
198191
};
199192
const context = buildLLMContext(input);
200-
expect(context).toContain("reviews_and_releases:");
193+
expect(context).toContain("reviews:");
201194
expect(context).toContain("Add feature");
202195
expect(context).toContain("approved");
203196
});
204197

205-
it("includes release events in LLM context", () => {
198+
it("includes releases with body in LLM context", () => {
206199
const input: NarrativeInput = {
207200
...MOCK_INPUT,
208-
events: [
201+
releases: [
209202
{
210-
id: "rel1",
211-
type: "ReleaseEvent",
212203
repo: "org/repo-a",
213-
createdAt: "2026-04-01T10:00:00Z",
214-
payload: { kind: "release", action: "published", tag: "v2.0.0", name: "Major Release" },
204+
tag: "v2.0.0",
205+
name: "Major Release",
206+
body: "## What's Changed\n- Added OAuth2 flow\n- Fixed rate limiting",
207+
url: "https://github.com/org/repo-a/releases/tag/v2.0.0",
208+
publishedAt: "2026-04-01T10:00:00Z",
215209
},
216210
],
217211
};
218212
const context = buildLLMContext(input);
219-
expect(context).toContain("reviews_and_releases:");
213+
expect(context).toContain("releases:");
220214
expect(context).toContain("v2.0.0");
221215
expect(context).toContain("Major Release");
216+
expect(context).toContain("OAuth2 flow");
217+
});
218+
219+
it("omits releases when empty", () => {
220+
const context = buildLLMContext(MOCK_INPUT);
221+
expect(context).not.toContain("releases:");
222222
});
223223
});

0 commit comments

Comments
 (0)