Skip to content

Commit 260d60c

Browse files
freddiecolemandanieljperryclaude
committed
feat(chips): drive list_chips from the chips repo README index
list_chips now surfaces every CHIP across all statuses (Living, Draft, Review, Final, Stagnant, Withdrawn, Obsolete, Grandfathered, Under Consideration) by parsing the canonical index in Chia-Network/chips/README.md. Draft and Review entries link to open PRs and are enriched from the PR head; Final entries are enriched from main. Status comes from the README (authoritative) rather than per-file frontmatter so unmerged proposals like CHIP-57 surface correctly when asked for the most recent CHIP. Co-Authored-By: Dan Perry <d.perry@chia.net> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5ebe7d6 commit 260d60c

7 files changed

Lines changed: 406 additions & 81 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ Skills are read-only and idempotent, persist state between runs, and accept stru
220220
| `get_prefarm_status` | Live per-wallet balances of the 21M XCH strategic reserve, plus total spent |
221221
| `get_prefarm_spends` | Outflows from the reserve, with destinations labelled when known (partners / market makers / exchanges). Filter by wallet, height, or count |
222222
| `list_prefarm_addresses` | The hardcoded registry: custody wallets and known destination addresses. No network call |
223-
| `list_chips` | Merged Chia Improvement Proposals on `main`, parsed front matter + abstract. Filter by status or category |
223+
| `list_chips` | All Chia Improvement Proposals from the canonical README index in `Chia-Network/chips`, across every status (Living, Draft, Review, Final, Stagnant, Withdrawn, Obsolete, Grandfathered). Draft and Review entries link to open PRs; Final entries link to merged files. Status comes from the README; front matter is enriched in. Filter by status or category |
224224
| `get_chip` | One CHIP by number. Returns the merged version (if any), any open PR drafts proposing the same number, and optionally the full markdown |
225225
| `list_chip_drafts` | Open PRs against `Chia-Network/chips` that add or modify a CHIP, with parsed front matter and PR context (author, reviewers, draft flag) |
226226
| `search_chips` | Keyword search across merged CHIPs and open PR drafts (title, description, abstract, authors) |

src/chips/index.ts

Lines changed: 125 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,13 @@ import {
88
type AuthorRef,
99
type ChipFrontMatter,
1010
} from './parser.js';
11+
import { getChipReadmeIndex, type ChipReadmeEntry } from './readme-index.js';
1112

1213
const REPO = 'Chia-Network/chips';
1314
const DEFAULT_REF = 'main';
14-
const CHIPS_DIR = 'CHIPs';
15-
const LISTING_TTL_MS = 5 * 60_000;
1615
const CONTENT_TTL_MS = 10 * 60_000;
1716
const PR_LIST_TTL_MS = 2 * 60_000;
1817

19-
interface ContentsEntry {
20-
name: string;
21-
path: string;
22-
type: 'file' | 'dir' | 'symlink' | 'submodule';
23-
}
24-
2518
interface PullsEntry {
2619
number: number;
2720
title: string;
@@ -60,7 +53,7 @@ export interface ChipSummary {
6053
replaces: string | null;
6154
superseded_by: string | null;
6255
source_url: string;
63-
filename: string;
56+
filename: string | null;
6457
}
6558

6659
export interface MergedChip extends ChipSummary {
@@ -77,6 +70,12 @@ export interface DraftChip extends ChipSummary {
7770
body?: string;
7871
}
7972

73+
export interface ChipIndexEntry extends ChipSummary {
74+
kind: 'file' | 'pr' | 'external';
75+
pr: PrContext | null;
76+
ref: string | null;
77+
}
78+
8079
export interface PrContext {
8180
number: number;
8281
url: string;
@@ -115,44 +114,15 @@ function summarize(fm: ChipFrontMatter, body: string, ref: string, filename: str
115114
};
116115
}
117116

118-
async function listChipFilenames(): Promise<string[]> {
119-
return getCached(
120-
`chips:contents:${DEFAULT_REF}`,
121-
async () => {
122-
const entries = await githubApi<ContentsEntry[]>(
123-
`/repos/${REPO}/contents/${CHIPS_DIR}?ref=${DEFAULT_REF}`
124-
);
125-
return entries
126-
.filter((e) => e.type === 'file' && /^chip-\d{1,5}\.md$/i.test(e.name))
127-
.map((e) => e.path)
128-
.sort();
129-
},
130-
LISTING_TTL_MS
131-
);
132-
}
133-
134117
async function fetchChipFile(ref: string, path: string): Promise<string> {
135118
return getCached(`chips:raw:${ref}:${path}`, () => fetchRawFile(REPO, ref, path), CONTENT_TTL_MS);
136119
}
137120

138-
export async function listMergedChips(): Promise<ChipSummary[]> {
139-
const paths = await listChipFilenames();
140-
const summaries = await Promise.all(
141-
paths.map(async (path) => {
142-
const body = await fetchChipFile(DEFAULT_REF, path);
143-
const fm = parseChipFrontMatter(body);
144-
return summarize(fm, body, DEFAULT_REF, path);
145-
})
146-
);
147-
summaries.sort((a, b) => (a.number ?? 0) - (b.number ?? 0));
148-
return summaries;
149-
}
150-
151121
export async function getMergedChip(
152122
number: number,
153123
options: { includeBody?: boolean } = {}
154124
): Promise<MergedChip | null> {
155-
const filename = `${CHIPS_DIR}/${buildChipFileName(number)}`;
125+
const filename = `CHIPs/${buildChipFileName(number)}`;
156126
try {
157127
const body = await fetchChipFile(DEFAULT_REF, filename);
158128
const fm = parseChipFrontMatter(body);
@@ -285,6 +255,105 @@ export async function findChipByNumber(
285255
return { merged, drafts };
286256
}
287257

258+
function sparseEntry(idx: ChipReadmeEntry): ChipIndexEntry {
259+
return {
260+
number: idx.number,
261+
title: idx.title,
262+
status: idx.status,
263+
category: null,
264+
sub_category: null,
265+
type: null,
266+
authors: [],
267+
editors: [],
268+
description: null,
269+
abstract: null,
270+
created: null,
271+
updated: null,
272+
comments_uri: null,
273+
requires: null,
274+
replaces: null,
275+
superseded_by: null,
276+
source_url: idx.url,
277+
filename: idx.filename,
278+
kind: idx.kind,
279+
pr: null,
280+
ref: null,
281+
};
282+
}
283+
284+
async function buildIndexEntry(
285+
idx: ChipReadmeEntry,
286+
openPrs: Map<number, PullsEntry>
287+
): Promise<ChipIndexEntry> {
288+
if (idx.kind === 'file' && idx.filename) {
289+
try {
290+
const body = await fetchChipFile(DEFAULT_REF, idx.filename);
291+
const fm = parseChipFrontMatter(body);
292+
const summary = summarize(fm, body, DEFAULT_REF, idx.filename);
293+
return {
294+
...summary,
295+
number: idx.number ?? summary.number,
296+
title: summary.title ?? idx.title,
297+
status: idx.status,
298+
source_url: idx.url,
299+
kind: 'file',
300+
pr: null,
301+
ref: DEFAULT_REF,
302+
};
303+
} catch (err) {
304+
if (err instanceof FileNotFoundError) return sparseEntry(idx);
305+
throw err;
306+
}
307+
}
308+
if (idx.kind === 'pr' && idx.prNumber != null) {
309+
const pr = openPrs.get(idx.prNumber);
310+
if (pr) {
311+
const files = await listPrChipFiles(pr.number).catch(() => [] as PullFileEntry[]);
312+
const chipFiles = files.filter(
313+
(f) => f.status !== 'removed' && extractChipPathInfo(f.filename)
314+
);
315+
let chipFile: PullFileEntry | undefined;
316+
if (idx.number != null) {
317+
const target = buildChipFileName(idx.number);
318+
chipFile = chipFiles.find((f) => f.filename.toLowerCase().endsWith(target.toLowerCase()));
319+
}
320+
if (!chipFile) chipFile = chipFiles[0];
321+
if (chipFile) {
322+
const body = await fetchChipFile(pr.head.sha, chipFile.filename).catch(() => null);
323+
if (body) {
324+
const fm = parseChipFrontMatter(body);
325+
const summary = summarize(fm, body, pr.head.sha, chipFile.filename);
326+
return {
327+
...summary,
328+
number: idx.number ?? summary.number,
329+
title: summary.title ?? idx.title,
330+
status: idx.status,
331+
source_url: idx.url,
332+
kind: 'pr',
333+
pr: prContext(pr),
334+
ref: pr.head.sha,
335+
};
336+
}
337+
}
338+
}
339+
return sparseEntry(idx);
340+
}
341+
return sparseEntry(idx);
342+
}
343+
344+
export async function listChipsFromReadme(): Promise<ChipIndexEntry[]> {
345+
const [index, openPrsList] = await Promise.all([getChipReadmeIndex(), listOpenChipPrs()]);
346+
const openPrs = new Map(openPrsList.map((pr) => [pr.number, pr]));
347+
const entries = await Promise.all(index.map((idx) => buildIndexEntry(idx, openPrs)));
348+
entries.sort((a, b) => {
349+
if (a.number == null && b.number == null) return 0;
350+
if (a.number == null) return 1;
351+
if (b.number == null) return -1;
352+
return a.number - b.number;
353+
});
354+
return entries;
355+
}
356+
288357
export interface SearchOptions {
289358
status?: string;
290359
source?: 'merged' | 'draft' | 'both';
@@ -302,30 +371,30 @@ function matchesQuery(s: ChipSummary, q: string): boolean {
302371
return haystacks.some((h) => h.toLowerCase().includes(needle));
303372
}
304373

374+
function kindToSource(kind: ChipIndexEntry['kind']): 'merged' | 'draft' {
375+
return kind === 'pr' ? 'draft' : 'merged';
376+
}
377+
378+
export interface SearchMatch extends ChipIndexEntry {
379+
source: 'merged' | 'draft';
380+
}
381+
305382
export async function searchChips(
306383
query: string,
307384
options: SearchOptions = {}
308-
): Promise<Array<MergedChip | DraftChip>> {
385+
): Promise<SearchMatch[]> {
309386
const source = options.source ?? 'both';
310387
const limit = options.limit ?? 20;
311388
const statusFilter = options.status?.toLowerCase();
312389

313-
const pool: Array<MergedChip | DraftChip> = [];
314-
if (source === 'merged' || source === 'both') {
315-
const merged = await listMergedChips();
316-
for (const m of merged) {
317-
pool.push({ ...m, source: 'merged' as const, ref: 'main' as const });
318-
}
319-
}
320-
if (source === 'draft' || source === 'both') {
321-
const drafts = await listChipDrafts();
322-
pool.push(...drafts);
323-
}
324-
325-
const filtered = pool.filter((c) => {
326-
if (statusFilter && (c.status?.toLowerCase() ?? '') !== statusFilter) return false;
327-
return matchesQuery(c, query);
328-
});
390+
const entries = await listChipsFromReadme();
391+
const filtered = entries
392+
.map((e): SearchMatch => ({ ...e, source: kindToSource(e.kind) }))
393+
.filter((e) => {
394+
if (source !== 'both' && e.source !== source) return false;
395+
if (statusFilter && (e.status?.toLowerCase() ?? '') !== statusFilter) return false;
396+
return matchesQuery(e, query);
397+
});
329398

330399
return filtered.slice(0, limit);
331400
}

src/chips/readme-index.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { fetchRawFile } from '../github/client.js';
2+
import { getCached } from '../github/cache.js';
3+
4+
const REPO = 'Chia-Network/chips';
5+
const DEFAULT_REF = 'main';
6+
const README_PATH = 'README.md';
7+
const README_TTL_MS = 5 * 60_000;
8+
9+
export type ChipReadmeEntryKind = 'file' | 'pr' | 'external';
10+
11+
export interface ChipReadmeEntry {
12+
number: number | null;
13+
title: string;
14+
status: string;
15+
url: string;
16+
kind: ChipReadmeEntryKind;
17+
filename: string | null;
18+
prNumber: number | null;
19+
}
20+
21+
const BULLET_PATTERN = /^\*\s+\[(.+?)\]\((.+?)\)/;
22+
const FILE_LINK_PATTERN = /^\/?CHIPs\/(chip-\d{1,5}\.md)$/i;
23+
const PR_LINK_PATTERN = /^https?:\/\/github\.com\/Chia-Network\/chips\/pull\/(\d+)\/?$/i;
24+
25+
function parseLabel(label: string): { number: number | null; title: string } {
26+
const m = label.match(/^\s*(\d+)\s*[-:]\s*(.+?)\s*$/);
27+
if (m) {
28+
return { number: Number(m[1]), title: m[2]!.trim() };
29+
}
30+
return { number: null, title: label.trim() };
31+
}
32+
33+
function classify(url: string): {
34+
kind: ChipReadmeEntryKind;
35+
filename: string | null;
36+
prNumber: number | null;
37+
absoluteUrl: string;
38+
} {
39+
const fileMatch = url.match(FILE_LINK_PATTERN);
40+
if (fileMatch) {
41+
const filename = `CHIPs/${fileMatch[1]!}`;
42+
return {
43+
kind: 'file',
44+
filename,
45+
prNumber: null,
46+
absoluteUrl: `https://github.com/${REPO}/blob/${DEFAULT_REF}/${filename}`,
47+
};
48+
}
49+
const prMatch = url.match(PR_LINK_PATTERN);
50+
if (prMatch) {
51+
return {
52+
kind: 'pr',
53+
filename: null,
54+
prNumber: Number(prMatch[1]),
55+
absoluteUrl: url,
56+
};
57+
}
58+
return { kind: 'external', filename: null, prNumber: null, absoluteUrl: url };
59+
}
60+
61+
export function parseChipReadmeIndex(markdown: string): ChipReadmeEntry[] {
62+
const lines = markdown.split(/\r?\n/);
63+
const entries: ChipReadmeEntry[] = [];
64+
let currentStatus: string | null = null;
65+
66+
for (const raw of lines) {
67+
const line = raw.trim();
68+
const heading = line.match(/^###\s+(.+?)\s*$/);
69+
if (heading) {
70+
currentStatus = heading[1]!.trim();
71+
continue;
72+
}
73+
if (line.startsWith('## ')) {
74+
currentStatus = null;
75+
continue;
76+
}
77+
if (!currentStatus) continue;
78+
const bullet = line.match(BULLET_PATTERN);
79+
if (!bullet) continue;
80+
const [, label, url] = bullet;
81+
if (!label || !url) continue;
82+
const { number, title } = parseLabel(label);
83+
const { kind, filename, prNumber, absoluteUrl } = classify(url);
84+
entries.push({
85+
number,
86+
title,
87+
status: currentStatus,
88+
url: absoluteUrl,
89+
kind,
90+
filename,
91+
prNumber,
92+
});
93+
}
94+
95+
return entries;
96+
}
97+
98+
export async function getChipReadmeIndex(): Promise<ChipReadmeEntry[]> {
99+
return getCached(
100+
`chips:readme:${DEFAULT_REF}`,
101+
async () => {
102+
const body = await fetchRawFile(REPO, DEFAULT_REF, README_PATH);
103+
return parseChipReadmeIndex(body);
104+
},
105+
README_TTL_MS
106+
);
107+
}

src/tools/chips/list-chip-drafts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { errorText, jsonText } from '../shared/response.js';
55
export function register(server: McpServer): void {
66
server.tool(
77
'list_chip_drafts',
8-
'List open pull requests against Chia-Network/chips that add or modify a CHIP file. Each entry includes parsed front matter from the proposed file plus PR context (number, url, author, requested reviewers, draft flag, updated_at) and a modifies_existing flag for amendments. PRs that do not touch a CHIPs/chip-*.md file are filtered out.',
8+
'List the live state of every open pull request against Chia-Network/chips that adds or modifies a CHIP file. Use this for PR review context (author, requested reviewers, draft flag, updated_at) and the modifies_existing flag for amendments. PRs that do not touch a CHIPs/chip-*.md file are filtered out. For the canonical CHIP index across all statuses (including Draft proposals), use list_chips.',
99
{},
1010
async () => {
1111
try {

0 commit comments

Comments
 (0)