Skip to content

Commit 8bb6bae

Browse files
authored
fix: fix-drift.ts path truncation and missing label (#186)
## Summary - Change exec() .trim() to .trimEnd() — preserves leading whitespace in git porcelain output that encodes index/worktree status - Create `drift` label on repo so gh issue create --label drift succeeds ## Test plan - [x] Verified porcelain parsing logic handles ` M src/file.ts` correctly with trimEnd
2 parents cf656a3 + cacf3cb commit 8bb6bae

4 files changed

Lines changed: 310 additions & 1 deletion

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Changelog Radar
2+
3+
on:
4+
schedule:
5+
- cron: "0 8 * * *" # Daily 8am UTC (1am PT)
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: changelog-radar
10+
cancel-in-progress: true
11+
12+
jobs:
13+
scan:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 5
16+
permissions:
17+
issues: write
18+
steps:
19+
- uses: actions/checkout@v4
20+
- uses: pnpm/action-setup@v4
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: 24
24+
cache: pnpm
25+
- run: pnpm install --frozen-lockfile
26+
27+
- name: Run changelog radar
28+
id: radar
29+
run: |
30+
OUTPUT=$(npx tsx scripts/changelog-radar.ts 2>&1)
31+
echo "$OUTPUT"
32+
33+
# Check if output is valid JSON (means new entries found)
34+
if echo "$OUTPUT" | jq -e '.newEntries | length > 0' >/dev/null 2>&1; then
35+
echo "found=true" >> $GITHUB_OUTPUT
36+
37+
COUNT=$(echo "$OUTPUT" | jq -r '.newEntries | length')
38+
SINCE=$(echo "$OUTPUT" | jq -r '.since')
39+
echo "count=$COUNT" >> $GITHUB_OUTPUT
40+
echo "since=$SINCE" >> $GITHUB_OUTPUT
41+
42+
# Build issue body
43+
BODY="The changelog radar detected **${COUNT}** new API changelog entries since ${SINCE} that may affect aimock's provider surface.\n\n"
44+
BODY+="| Date | Title | Keywords |\n|------|-------|----------|\n"
45+
46+
while IFS= read -r line; do
47+
DATE=$(echo "$line" | jq -r '.date')
48+
TITLE=$(echo "$line" | jq -r '.title')
49+
URL=$(echo "$line" | jq -r '.url')
50+
KW=$(echo "$line" | jq -r '.keywords | join(", ")')
51+
BODY+="| ${DATE} | [${TITLE}](${URL}) | ${KW} |\n"
52+
done < <(echo "$OUTPUT" | jq -c '.newEntries[]')
53+
54+
BODY+="\n\n**Action required:** Review these entries for new models, endpoints, deprecations, or breaking changes that may need aimock updates.\n"
55+
BODY+="\nSource: ${CHANGELOG_URL:-https://platform.openai.com/docs/changelog}"
56+
57+
# Write body to file for gh issue create
58+
echo -e "$BODY" > /tmp/radar-issue-body.md
59+
else
60+
echo "found=false" >> $GITHUB_OUTPUT
61+
fi
62+
63+
- name: Create GitHub issue
64+
if: steps.radar.outputs.found == 'true'
65+
run: |
66+
gh issue create \
67+
--title "API changelog: ${{ steps.radar.outputs.count }} new entries since ${{ steps.radar.outputs.since }}" \
68+
--body-file /tmp/radar-issue-body.md \
69+
--label drift
70+
env:
71+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ coverage/
77
**/__pycache__/
88
packages/.remember/
99
packages/.claude/
10+
# Changelog radar cursor (local state)
11+
.changelog-radar-cursor
12+
1013
# Internal planning docs — NEVER commit these
1114
docs/superpowers/
1215
docs/plans/

scripts/changelog-radar.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/// <reference types="node" />
2+
3+
/**
4+
* Changelog Radar
5+
*
6+
* Fetches the OpenAI API changelog, filters for entries relevant to aimock's
7+
* provider surface, and outputs a JSON report of new entries since the last run.
8+
*
9+
* On first run (no cursor file), sets the cursor to today and reports nothing.
10+
* If parsing fails, logs a warning and exits 0 (never fails the workflow).
11+
*
12+
* Usage:
13+
* npx tsx scripts/changelog-radar.ts
14+
*
15+
* Output (stdout): JSON report when new entries found, empty otherwise.
16+
* Side effect: updates .changelog-radar-cursor with the latest entry date.
17+
*/
18+
19+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
20+
import { resolve } from "node:path";
21+
22+
// ---------------------------------------------------------------------------
23+
// Config
24+
// ---------------------------------------------------------------------------
25+
26+
const CHANGELOG_URL = "https://platform.openai.com/docs/changelog";
27+
const CURSOR_FILE = resolve(import.meta.dirname ?? ".", "../.changelog-radar-cursor");
28+
29+
const SURFACE_KEYWORDS = [
30+
"realtime",
31+
"chat",
32+
"completions",
33+
"embeddings",
34+
"responses",
35+
"audio",
36+
"speech",
37+
"transcription",
38+
"images",
39+
"moderation",
40+
"models",
41+
"deprecat",
42+
"breaking",
43+
"websocket",
44+
];
45+
46+
// ---------------------------------------------------------------------------
47+
// Types
48+
// ---------------------------------------------------------------------------
49+
50+
interface ChangelogEntry {
51+
date: string;
52+
title: string;
53+
url: string;
54+
keywords: string[];
55+
}
56+
57+
interface RadarReport {
58+
newEntries: ChangelogEntry[];
59+
since: string;
60+
}
61+
62+
// ---------------------------------------------------------------------------
63+
// Helpers
64+
// ---------------------------------------------------------------------------
65+
66+
function readCursor(): string | null {
67+
if (!existsSync(CURSOR_FILE)) return null;
68+
const raw = readFileSync(CURSOR_FILE, "utf-8").trim();
69+
// Validate it looks like a date
70+
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
71+
return null;
72+
}
73+
74+
function writeCursor(date: string): void {
75+
writeFileSync(CURSOR_FILE, date + "\n", "utf-8");
76+
}
77+
78+
function matchKeywords(text: string): string[] {
79+
const lower = text.toLowerCase();
80+
return SURFACE_KEYWORDS.filter((kw) => lower.includes(kw));
81+
}
82+
83+
/**
84+
* Parse changelog entries from the HTML page.
85+
*
86+
* The OpenAI changelog page uses a structured format with date headings and
87+
* entry titles. We look for common patterns:
88+
* - Date strings like "January 15, 2025" or "2025-01-15"
89+
* - Heading-like elements following dates
90+
*
91+
* This is intentionally loose — we'd rather over-match than miss entries.
92+
*/
93+
function parseEntries(html: string): ChangelogEntry[] {
94+
const entries: ChangelogEntry[] = [];
95+
96+
// Strategy 1: Look for date patterns followed by content blocks.
97+
// OpenAI's changelog typically has entries with dates in heading elements.
98+
// Match patterns like: <h2>January 15, 2025</h2> or date attributes
99+
const dateContentPattern =
100+
/(?:<h[23][^>]*>|<time[^>]*>|<div[^>]*date[^>]*>)\s*([A-Z][a-z]+ \d{1,2},?\s*\d{4}|\d{4}-\d{2}-\d{2})\s*(?:<\/h[23]>|<\/time>|<\/div>)/gi;
101+
102+
// Also try: entries as list items or article elements with dates
103+
const entryPattern =
104+
/(\d{4}-\d{2}-\d{2}|(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s*\d{4})[^<]*<[^>]*>([^<]{5,200})/gi;
105+
106+
// Strategy 2: Broader pattern — grab anything that looks like a dated entry
107+
const broadPattern =
108+
/((?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s*\d{4}|\d{4}-\d{2}-\d{2})[\s\S]{0,500}?(?:<[hH][1-6][^>]*>|<a[^>]*>|<strong>|<b>)\s*([^<]{5,200})/g;
109+
110+
const seen = new Set<string>();
111+
112+
for (const pattern of [dateContentPattern, entryPattern, broadPattern]) {
113+
let match;
114+
while ((match = pattern.exec(html)) !== null) {
115+
const rawDate = match[1]!.trim();
116+
const title = (match[2] ?? "").replace(/<[^>]+>/g, "").trim();
117+
118+
// Normalize date to YYYY-MM-DD
119+
const normalizedDate = normalizeDate(rawDate);
120+
if (!normalizedDate) continue;
121+
122+
const key = `${normalizedDate}:${title.slice(0, 80)}`;
123+
if (seen.has(key) || !title) continue;
124+
seen.add(key);
125+
126+
entries.push({
127+
date: normalizedDate,
128+
title,
129+
url: `${CHANGELOG_URL}#${normalizedDate}`,
130+
keywords: [],
131+
});
132+
}
133+
}
134+
135+
// Sort newest first
136+
entries.sort((a, b) => b.date.localeCompare(a.date));
137+
return entries;
138+
}
139+
140+
function normalizeDate(raw: string): string | null {
141+
// Already YYYY-MM-DD
142+
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
143+
144+
// "Month DD, YYYY" or "Month DD YYYY"
145+
const parsed = new Date(raw);
146+
if (isNaN(parsed.getTime())) return null;
147+
148+
const y = parsed.getFullYear();
149+
const m = String(parsed.getMonth() + 1).padStart(2, "0");
150+
const d = String(parsed.getDate()).padStart(2, "0");
151+
return `${y}-${m}-${d}`;
152+
}
153+
154+
// ---------------------------------------------------------------------------
155+
// Main
156+
// ---------------------------------------------------------------------------
157+
158+
async function main(): Promise<void> {
159+
// Fetch the changelog page
160+
let html: string;
161+
try {
162+
const resp = await fetch(CHANGELOG_URL, {
163+
headers: { "User-Agent": "aimock-changelog-radar/1.0" },
164+
});
165+
if (!resp.ok) {
166+
console.warn(`Changelog fetch failed: ${resp.status} ${resp.statusText}`);
167+
process.exit(0);
168+
}
169+
html = await resp.text();
170+
} catch (err) {
171+
console.warn(`Changelog fetch error: ${err}`);
172+
process.exit(0);
173+
}
174+
175+
// Parse entries
176+
const allEntries = parseEntries(html);
177+
if (allEntries.length === 0) {
178+
console.warn("No changelog entries parsed — format may have changed");
179+
process.exit(0);
180+
}
181+
182+
// Read cursor
183+
const cursor = readCursor();
184+
const today = new Date().toISOString().slice(0, 10);
185+
186+
// First run: set cursor and exit
187+
if (!cursor) {
188+
const newestDate = allEntries[0]?.date ?? today;
189+
writeCursor(newestDate);
190+
console.log(`First run — cursor set to ${newestDate}. No entries to report.`);
191+
process.exit(0);
192+
}
193+
194+
// Filter to entries newer than cursor
195+
const newEntries = allEntries.filter((e) => e.date > cursor);
196+
197+
if (newEntries.length === 0) {
198+
console.log(`No new entries since ${cursor}.`);
199+
process.exit(0);
200+
}
201+
202+
// Filter for surface-relevant entries
203+
const relevant: ChangelogEntry[] = [];
204+
for (const entry of newEntries) {
205+
const kw = matchKeywords(`${entry.title} ${entry.url}`);
206+
if (kw.length > 0) {
207+
entry.keywords = kw;
208+
relevant.push(entry);
209+
}
210+
}
211+
212+
// Update cursor to newest entry
213+
const newestDate = newEntries[0]?.date ?? today;
214+
writeCursor(newestDate);
215+
216+
if (relevant.length === 0) {
217+
console.log(
218+
`${newEntries.length} new entries since ${cursor}, but none matched surface keywords.`,
219+
);
220+
process.exit(0);
221+
}
222+
223+
// Output report
224+
const report: RadarReport = {
225+
newEntries: relevant,
226+
since: cursor,
227+
};
228+
229+
console.log(JSON.stringify(report, null, 2));
230+
}
231+
232+
main().catch((err) => {
233+
console.warn(`Unhandled error: ${err}`);
234+
process.exit(0);
235+
});

scripts/fix-drift.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function formatExecError(cmd: string, err: unknown): Error {
101101
*/
102102
function exec(cmd: string): string {
103103
try {
104-
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
104+
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trimEnd();
105105
} catch (err: unknown) {
106106
throw formatExecError(cmd, err);
107107
}

0 commit comments

Comments
 (0)