Skip to content

Commit cf3c2cd

Browse files
committed
fix: improve error handling, UX, and i18n across CLI and collectors
- Throw on 401/403 in Events API instead of silently returning empty results - Add retry with backoff for 429 rate limits in Events API - Fix model validation false-positive (match 'not found' instead of 'model') - Fix DST cron offset bug by using standard time (Jan 1) for calculation - Add spinner for token and model validation during setup - Add permission hint on 403 errors in addFileToRepo - Throw on 401/403 in Search API, warn on other errors - Add YAML parse failure advice in LLM error messages - Warn when Events API 300-event limit is reached - Add PR fetch failure summary (N of M failed) - Add urlEncode Handlebars helper for share links - Fix render --language help text to list all 10 languages - Display site title without raw \n in CLI summary and README - Document both fine-grained and classic PAT options in README - Fix i18n diacritics for French, German, Portuguese, and Spanish - Add temperature to Anthropic, add maxOutputTokens/temperature to Gemini
1 parent f4e53a4 commit cf3c2cd

18 files changed

Lines changed: 190 additions & 47 deletions

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ Every week, this tool looks at everything you did on GitHub (commits, pull reque
1616

1717
Have these two things ready before running setup:
1818

19-
1. **GitHub fine-grained PAT** with `All repositories` access and these permissions (all Read & Write):
20-
`Actions`, `Administration`, `Contents`, `Pages`, `Secrets`, `Workflows`
21-
([Create one](https://github.com/settings/personal-access-tokens/new))
19+
1. **GitHub personal access token (PAT)**, either type works:
20+
21+
- **Fine-grained PAT** (recommended): `All repositories` access with permissions: `Actions`, `Administration`, `Contents`, `Pages`, `Secrets`, `Workflows` (all Read & Write).
22+
([Create one](https://github.com/settings/personal-access-tokens/new))
23+
- **Classic PAT**: scopes `repo` and `workflow`.
24+
([Create one](https://github.com/settings/tokens/new?scopes=repo,workflow))
25+
Use this if you hit 403 errors with fine-grained tokens (e.g. org policy restrictions).
2226

2327
2. **LLM API key** from any supported provider:
2428

src/cli/commands/fetch.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,15 @@ const searchWeeklyPRs = async (
161161
"User-Agent": "github-weekly-reporter",
162162
},
163163
});
164-
if (!res.ok) break;
164+
if (!res.ok) {
165+
if (res.status === 401 || res.status === 403) {
166+
throw new Error(
167+
`GitHub Search API returned ${res.status}. Check that your token (GH_PAT) is valid.`,
168+
);
169+
}
170+
console.warn(` Search API error (${res.status}), some PRs may be missing.`);
171+
break;
172+
}
165173
const data = (await res.json()) as {
166174
items: { number: number; pull_request?: { url: string }; repository_url: string }[];
167175
total_count: number;

src/cli/commands/render.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export const registerRender = (program: Command): void => {
228228
.option("-o, --output-dir <dir>", "Output directory for HTML (env: OUTPUT_DIR, default: ./output)")
229229
.option("--base-url <url>", "Base URL for absolute links in OG tags, sitemap, canonical (env: BASE_URL)")
230230
.option("--site-title <title>", "Site title for nav header (env: SITE_TITLE, default: {username}'s Weekly Reports)")
231-
.option("--language <lang>", "Report language: en, ja (env: LANGUAGE, default: en)")
231+
.option("--language <lang>", "Report language: en, ja, zh-CN, zh-TW, ko, es, fr, de, pt, ru (env: LANGUAGE, default: en)")
232232
.option("--timezone <tz>", "IANA timezone (env: TIMEZONE, default: UTC)")
233233
.option("--date <date>", "Date within the target week (YYYY-MM-DD, default: today)")
234234
.action(async (opts) => {

src/cli/commands/setup.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { midnightCronUTC, buildDailyWorkflow, buildWeeklyWorkflow, buildReadme,
88
import type { WorkflowOpts } from "./setup/workflows.js";
99
import { TIMEZONE_CHOICES, LANGUAGE_CHOICES, MODEL_LIST_URLS } from "./setup/constants.js";
1010
import { validateModel } from "./setup/validate-model.js";
11+
import { withSpinner } from "../spinner.js";
1112

1213
// Re-export for tests and external consumers
1314
export { midnightCronUTC, buildDailyWorkflow, buildWeeklyWorkflow } from "./setup/workflows.js";
@@ -46,8 +47,7 @@ const collectInputs = async (cliRepo?: string): Promise<SetupConfig> => {
4647
validate: (v) => (v.length > 0 ? true : "Token is required"),
4748
});
4849

49-
console.log("\n Validating token...");
50-
const { login } = await validateToken(token);
50+
const { login } = await withSpinner("Validating token...", () => validateToken(token));
5151
console.log(` Authenticated as ${login}\n`);
5252

5353
// 2. Username
@@ -149,10 +149,9 @@ const collectInputs = async (cliRepo?: string): Promise<SetupConfig> => {
149149
validate: (v) => (v.length > 0 ? true : "Model name is required"),
150150
});
151151

152-
console.log(" Validating model...");
153-
const result = await validateModel(llmProvider, llmApiKey, llmModel);
152+
const result = await withSpinner("Validating model...", () => validateModel(llmProvider, llmApiKey!, llmModel!));
154153
if (result.valid) {
155-
console.log(" Model OK.\n");
154+
console.log("");
156155
modelValidated = true;
157156
} else {
158157
console.log(` ${result.error}`);
@@ -195,7 +194,7 @@ const run = async (cliRepo?: string): Promise<void> => {
195194
console.log("\n ── Setup Summary ─────────────────────────────");
196195
console.log(` Repository: ${fullRepo}`);
197196
console.log(` Username: ${config.username}`);
198-
console.log(` Site title: ${config.siteTitle}`);
197+
console.log(` Site title: ${config.siteTitle.replace(/\\n/g, " ")}`);
199198
console.log(` Language: ${config.language}`);
200199
console.log(` Timezone: ${config.timezone}`);
201200
console.log(` Schedule: Daily at midnight (cron: ${cron})`);

src/cli/commands/setup/github-api.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,12 @@ export const addFileToRepo = async (
192192
...(sha ? { sha } : {}),
193193
});
194194
if (!res.ok) {
195-
throw new Error(`Failed to add ${path}: ${res.status}`);
195+
const hint = res.status === 403
196+
? "\n\n Possible causes:" +
197+
"\n - Fine-grained PAT: ensure 'Contents: Read and write' and 'Workflows: Read and write' permissions" +
198+
"\n - Classic PAT: ensure 'repo' and 'workflow' scopes are granted"
199+
: "";
200+
throw new Error(`Failed to add ${path}: ${res.status}${hint}`);
196201
}
197202
};
198203

src/cli/commands/setup/validate-model.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,14 @@ describe("validateModel", () => {
109109
expect(result.valid).toBe(false);
110110
expect(result.error).toContain("API error: 500");
111111
});
112+
113+
it("does not false-positive on error body that mentions 'model' without 'not found'", async () => {
114+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
115+
new Response('{"error": "The model is currently overloaded"}', { status: 503 }),
116+
);
117+
const result = await validateModel("openai", "key", "gpt-4");
118+
expect(result.valid).toBe(false);
119+
expect(result.error).toContain("API error: 503");
120+
expect(result.error).not.toContain("not found");
121+
});
112122
});

src/cli/commands/setup/validate-model.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ export const validateModel = async (
7171
if (res.ok) return { valid: true };
7272

7373
const body = await res.text();
74-
// 404 or "model not found" means wrong model name
75-
if (res.status === 404 || body.toLowerCase().includes("model")) {
74+
const lower = body.toLowerCase();
75+
// 404 or explicit "not found" in body means wrong model name
76+
if (res.status === 404 || lower.includes("not_found") || lower.includes("not found")) {
7677
return { valid: false, error: `Model "${model}" not found (${res.status})` };
7778
}
7879
// Rate limit or other non-model errors are fine (model exists)

src/cli/commands/setup/workflows.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,17 @@ const LLM_API_KEY_INPUT_NAMES: Record<string, string> = {
2727
// GitHub Actions cron is always UTC, so we calculate the offset.
2828

2929
export const midnightCronUTC = (timezone: string): string => {
30-
const now = new Date();
30+
// Use Jan 1 (standard time) to avoid DST-dependent offset.
31+
// During DST the cron fires ~1h late (e.g. 01:00 local), which is safe
32+
// because daily-fetch resolves "yesterday" from the local date.
33+
// The opposite (firing 1h early = 23:00 the previous day) would shift
34+
// the local date backward and collect the wrong day.
35+
const jan1 = new Date(new Date().getFullYear(), 0, 1, 0, 0, 0);
3136
const utcMidnight = new Date(
32-
now.toLocaleString("en-US", { timeZone: "UTC" }),
37+
jan1.toLocaleString("en-US", { timeZone: "UTC" }),
3338
);
3439
const localMidnight = new Date(
35-
now.toLocaleString("en-US", { timeZone: timezone }),
40+
jan1.toLocaleString("en-US", { timeZone: timezone }),
3641
);
3742
const offsetMinutes = Math.round(
3843
(utcMidnight.getTime() - localMidnight.getTime()) / 60000,
@@ -151,7 +156,9 @@ export const buildReadme = (opts: {
151156
timezone: string;
152157
llmProvider?: LLMProvider;
153158
llmModel?: string;
154-
}): string => `# ${opts.siteTitle}
159+
}): string => {
160+
const displayTitle = opts.siteTitle.replace(/\\n/g, " ");
161+
return `# ${displayTitle}
155162
156163
Weekly GitHub activity reports for [@${opts.username}](https://github.com/${opts.username}), powered by [github-weekly-reporter](https://github.com/deariary/github-weekly-reporter).
157164
@@ -174,7 +181,7 @@ Edit \`.github/workflows/weekly-report.yml\` to change:
174181
| \`username\` | \`${opts.username}\` | GitHub user to report on |
175182
| \`language\` | \`${opts.language}\` | Report language (en, ja, zh-CN, zh-TW, ko, es, fr, de, pt, ru) |
176183
| \`timezone\` | \`${opts.timezone}\` | IANA timezone for date calculations |
177-
| \`SITE_TITLE\` | \`${opts.siteTitle}\` | Site title in the header and hero |
184+
| \`SITE_TITLE\` | \`${displayTitle}\` | Site title in the header and hero |
178185
${opts.llmProvider ? `| \`llm-provider\` | \`${opts.llmProvider}\` | LLM provider for AI narrative |\n| \`llm-model\` | \`${opts.llmModel}\` | Model name |\n` : ""}
179186
## Base URL
180187
@@ -208,3 +215,4 @@ Go to [Actions](https://github.com/${opts.repo}/actions), click **Weekly Report*
208215
209216
Supported by [deariary](https://deariary.com)
210217
`;
218+
};

src/cli/spinner.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Minimal CLI spinner (no external dependencies)
2+
3+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4+
const INTERVAL = 80;
5+
6+
export const withSpinner = async <T>(message: string, task: () => Promise<T>): Promise<T> => {
7+
// Skip animation in non-TTY environments (CI, piped output, tests)
8+
if (!process.stderr.isTTY) {
9+
process.stderr.write(` ${message}\n`);
10+
return task();
11+
}
12+
13+
let i = 0;
14+
const timer = setInterval(() => {
15+
const frame = FRAMES[i++ % FRAMES.length];
16+
process.stderr.write(`\r ${frame} ${message}`);
17+
}, INTERVAL);
18+
19+
try {
20+
const result = await task();
21+
clearInterval(timer);
22+
process.stderr.write(`\r ✔ ${message}\n`);
23+
return result;
24+
} catch (error) {
25+
clearInterval(timer);
26+
process.stderr.write(`\r ✖ ${message}\n`);
27+
throw error;
28+
}
29+
};

src/collector/fetch-events.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,31 @@ describe("fetchEvents", () => {
247247
expect(fetchSpy).toHaveBeenCalledTimes(1);
248248
});
249249

250-
it("handles API error gracefully", async () => {
250+
it("throws on 401 authentication error", async () => {
251+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
252+
new Response("", { status: 401, statusText: "Unauthorized" }),
253+
);
254+
255+
await expect(fetchEvents("token", "testuser", range)).rejects.toThrow(
256+
/GitHub API returned 401/,
257+
);
258+
});
259+
260+
it("throws on 403 forbidden error", async () => {
251261
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
252262
new Response("", { status: 403, statusText: "Forbidden" }),
253263
);
254264

265+
await expect(fetchEvents("token", "testuser", range)).rejects.toThrow(
266+
/GitHub API returned 403/,
267+
);
268+
});
269+
270+
it("handles other API errors gracefully", async () => {
271+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
272+
new Response("", { status: 500, statusText: "Internal Server Error" }),
273+
);
274+
255275
const result = await fetchEvents("token", "testuser", range);
256276
expect(result).toEqual([]);
257277
});

0 commit comments

Comments
 (0)