Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ function usage(devMode = false): string {
' init [--reconfigure]',
' doctor',
' version',
' sync <owner/repo> [--since <iso|duration>] [--limit <count>] [--include-comments] [--full-reconcile]',
' refresh <owner/repo> [--no-sync] [--no-embed] [--no-cluster]',
' sync <owner/repo> [--since <iso|duration>] [--limit <count>] [--include-comments] [--include-discussions] [--full-reconcile]',
' refresh <owner/repo> [--no-sync] [--no-embed] [--no-cluster] [--include-discussions]',
' threads <owner/repo> [--numbers <n,n,...>] [--kind issue|pull_request] [--include-closed]',
' author <owner/repo> --login <user> [--include-closed]',
' close-thread <owner/repo> --number <thread>',
Expand Down Expand Up @@ -100,6 +100,7 @@ export function parseRepoFlags(args: string[]): { owner: string; repo: string; v
since: { type: 'string' },
limit: { type: 'string' },
'include-comments': { type: 'boolean' },
'include-discussions': { type: 'boolean' },
'full-reconcile': { type: 'boolean' },
'include-closed': { type: 'boolean' },
kind: { type: 'string' },
Expand Down Expand Up @@ -339,6 +340,7 @@ export async function run(argv: string[], stdout: NodeJS.WritableStream = proces
since: typeof values.since === 'string' ? resolveSinceValue(values.since) : undefined,
limit: typeof values.limit === 'string' ? Number(values.limit) : undefined,
includeComments: values['include-comments'] === true,
includeDiscussions: values['include-discussions'] === true,
fullReconcile: values['full-reconcile'] === true,
onProgress: writeProgress,
});
Expand All @@ -353,6 +355,7 @@ export async function run(argv: string[], stdout: NodeJS.WritableStream = proces
sync: values['no-sync'] === true ? false : undefined,
embed: values['no-embed'] === true ? false : undefined,
cluster: values['no-cluster'] === true ? false : undefined,
includeDiscussions: values['include-discussions'] === true,
onProgress: writeProgress,
});
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
Expand Down
3 changes: 2 additions & 1 deletion packages/api-contract/src/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';

export const threadKindSchema = z.enum(['issue', 'pull_request']);
export const threadKindSchema = z.enum(['issue', 'pull_request', 'discussion']);
export type ThreadKind = z.infer<typeof threadKindSchema>;

export const searchModeSchema = z.enum(['keyword', 'semantic', 'hybrid']);
Expand Down Expand Up @@ -217,6 +217,7 @@ export const refreshRequestSchema = z.object({
sync: z.boolean().optional(),
embed: z.boolean().optional(),
cluster: z.boolean().optional(),
includeDiscussions: z.boolean().optional(),
});
export type RefreshRequest = z.infer<typeof refreshRequestSchema>;

Expand Down
2 changes: 1 addition & 1 deletion packages/api-core/src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function createApiServer(service: GHCrawlService): http.Server {
if (req.method === 'GET' && url.pathname === '/threads') {
const params = parseRepoParams(url);
const kindParam = url.searchParams.get('kind');
const kind = kindParam === 'issue' || kindParam === 'pull_request' ? kindParam : undefined;
const kind = kindParam === 'issue' || kindParam === 'pull_request' || kindParam === 'discussion' ? kindParam : undefined;
const numbersValue = url.searchParams.get('numbers');
const numbers =
numbersValue && numbersValue.trim().length > 0
Expand Down
65 changes: 65 additions & 0 deletions packages/api-core/src/github/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { mapDiscussionToRecord } from './client.js';

function makeDiscussionNode(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
number: 42,
title: 'Discussion title',
body: 'Discussion body',
author: { login: 'alice' },
labels: { nodes: [{ name: 'help wanted' }, { name: 'good first discussion' }] },
createdAt: '2026-03-09T00:00:00Z',
updatedAt: '2026-03-10T00:00:00Z',
closed: false,
url: 'https://github.com/openclaw/openclaw/discussions/42',
category: { name: 'Ideas' },
...overrides,
};
}

test('mapDiscussionToRecord maps a normal discussion node correctly', () => {
const mapped = mapDiscussionToRecord(makeDiscussionNode());
assert.equal(mapped.number, 42);
assert.equal(mapped.title, 'Discussion title');
assert.equal(mapped.body, 'Discussion body');
assert.deepEqual(mapped.user, { login: 'alice', type: 'User' });
assert.equal(mapped.html_url, 'https://github.com/openclaw/openclaw/discussions/42');
assert.equal(mapped.state, 'open');
assert.equal(mapped.created_at, '2026-03-09T00:00:00Z');
assert.equal(mapped.updated_at, '2026-03-10T00:00:00Z');
assert.equal(mapped._ghcrawl_kind, 'discussion');
assert.deepEqual(mapped.labels, [{ name: 'Ideas' }, { name: 'help wanted' }, { name: 'good first discussion' }]);
});

test('mapDiscussionToRecord handles null author', () => {
const mapped = mapDiscussionToRecord(makeDiscussionNode({ author: null }));
assert.deepEqual(mapped.user, { login: null, type: 'User' });
});

test('mapDiscussionToRecord handles null body', () => {
const mapped = mapDiscussionToRecord(makeDiscussionNode({ body: null }));
assert.equal(mapped.body, '');
});

test('mapDiscussionToRecord handles null category', () => {
const mapped = mapDiscussionToRecord(makeDiscussionNode({ category: null }));
assert.deepEqual(mapped.labels, [{ name: 'discussion' }, { name: 'help wanted' }, { name: 'good first discussion' }]);
});

test("mapDiscussionToRecord maps closed discussions to state 'closed'", () => {
const mapped = mapDiscussionToRecord(makeDiscussionNode({ closed: true }));
assert.equal(mapped.state, 'closed');
});

test("mapDiscussionToRecord maps open discussions to state 'open'", () => {
const mapped = mapDiscussionToRecord(makeDiscussionNode({ closed: false }));
assert.equal(mapped.state, 'open');
});

test('mapDiscussionToRecord includes category as first label', () => {
const mapped = mapDiscussionToRecord(makeDiscussionNode({ category: { name: 'Q&A' } }));
const labels = mapped.labels as Array<{ name: string }>;
assert.equal(labels[0]?.name, 'Q&A');
});
134 changes: 134 additions & 0 deletions packages/api-core/src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export type GitHubClient = {
reporter?: GitHubReporter,
state?: 'open' | 'closed',
) => Promise<Array<Record<string, unknown>>>;
listRepositoryDiscussions?: (
owner: string,
repo: string,
since?: string,
limit?: number,
reporter?: GitHubReporter,
) => Promise<Array<Record<string, unknown>>>;
getIssue: (owner: string, repo: string, number: number, reporter?: GitHubReporter) => Promise<Record<string, unknown>>;
getPull: (owner: string, repo: string, number: number, reporter?: GitHubReporter) => Promise<Record<string, unknown>>;
listIssueComments: (owner: string, repo: string, number: number, reporter?: GitHubReporter) => Promise<Array<Record<string, unknown>>>;
Expand Down Expand Up @@ -48,6 +55,19 @@ type OctokitPage<T> = {
data: T[];
};

type DiscussionNode = {
number: number;
title: string;
body: string | null;
author: { login: string } | null;
labels: { nodes: Array<{ name: string }> };
createdAt: string;
updatedAt: string;
closed: boolean;
url: string;
category: { name: string } | null;
};

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Expand All @@ -71,6 +91,38 @@ function formatResetTime(resetSeconds: string | null | undefined): string | null
return new Date(value * 1000).toISOString();
}

function isDiscussionFeatureDisabledError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /discussion/i.test(message) && /disabled|not enabled|enable/i.test(message);
}

export function mapDiscussionToRecord(node: Record<string, unknown>): Record<string, unknown> {
const labelNodes = ((node.labels as { nodes?: unknown } | undefined)?.nodes ?? []) as unknown[];
const mappedLabels = labelNodes
.map((label) => (label && typeof label === 'object' && typeof (label as { name?: unknown }).name === 'string' ? { name: String((label as { name: unknown }).name) } : null))
.filter((label): label is { name: string } => label !== null);
const categoryName =
node.category && typeof node.category === 'object' && typeof (node.category as { name?: unknown }).name === 'string'
? String((node.category as { name: unknown }).name)
: 'discussion';
const authorLogin =
node.author && typeof node.author === 'object' && typeof (node.author as { login?: unknown }).login === 'string'
? String((node.author as { login: unknown }).login)
: null;
return {
number: Number(node.number),
title: String(node.title ?? ''),
body: typeof node.body === 'string' ? node.body : '',
user: { login: authorLogin, type: 'User' },
html_url: String(node.url ?? ''),
state: node.closed ? 'closed' : 'open',
labels: [{ name: categoryName }, ...mappedLabels],
created_at: typeof node.createdAt === 'string' ? node.createdAt : null,
updated_at: typeof node.updatedAt === 'string' ? node.updatedAt : null,
_ghcrawl_kind: 'discussion',
};
}

export function makeGitHubClient(options: RequestOptions): GitHubClient {
const userAgent = options.userAgent ?? 'ghcrawl';
const timeoutMs = options.timeoutMs ?? 30_000;
Expand Down Expand Up @@ -184,6 +236,88 @@ export function makeGitHubClient(options: RequestOptions): GitHubClient {
}) as AsyncIterable<OctokitPage<Record<string, unknown>>>,
);
},
async listRepositoryDiscussions(owner, repo, since, limit, reporter) {
reporter?.(`[github] request GRAPHQL repository discussions for ${owner}/${repo}`);
const octokit = createOctokit(reporter);
const out: Array<Record<string, unknown>> = [];
const sinceMs = since ? Date.parse(since) : Number.NEGATIVE_INFINITY;
let hasNextPage = true;
let cursor: string | null = null;
let pageIndex = 0;
try {
while (hasNextPage) {
const response = (await octokit.graphql(
`query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
discussions(first: 100, after: $cursor, orderBy: {field: UPDATED_AT, direction: DESC}) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
title
body
author { login }
labels(first: 10) { nodes { name } }
createdAt
updatedAt
closed
url
category { name }
}
}
}
}`,
{ owner, name: repo, cursor },
)) as {
repository: {
discussions: {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: DiscussionNode[];
};
} | null;
};
pageIndex += 1;
const discussions = response.repository?.discussions;
if (!discussions) {
return out;
}
let stopForSince = false;
for (const node of discussions.nodes) {
const updatedAtMs = Date.parse(node.updatedAt);
if (Number.isFinite(sinceMs) && Number.isFinite(updatedAtMs) && updatedAtMs < sinceMs) {
stopForSince = true;
break;
}
out.push(mapDiscussionToRecord(node as unknown as Record<string, unknown>));
if (typeof limit === 'number' && out.length >= limit) {
break;
}
}

reporter?.(`[github] page ${pageIndex} fetched discussions=${discussions.nodes.length} accumulated=${out.length}`);
if ((typeof limit === 'number' && out.length >= limit) || stopForSince) {
break;
}
hasNextPage = discussions.pageInfo.hasNextPage;
cursor = discussions.pageInfo.endCursor;
if (hasNextPage) {
await delay(pageDelayMs);
}
}
return out;
} catch (error) {
if (isDiscussionFeatureDisabledError(error)) {
const message = error instanceof Error ? error.message : String(error);
reporter?.(`[github] warning discussions unavailable for ${owner}/${repo}; skipping: ${message}`);
return [];
}
const message = error instanceof Error ? error.message : String(error);
const status = typeof (error as { status?: unknown })?.status === 'number' ? Number((error as { status?: unknown }).status) : undefined;
throw new GitHubRequestError(`GitHub request failed for GRAPHQL discussions ${owner}/${repo}: ${message}`, status);
}
},
async getIssue(owner, repo, number, reporter) {
return request(`GET /repos/${owner}/${repo}/issues/${number}`, reporter, async (octokit) => {
const response = await octokit.rest.issues.get({ owner, repo, issue_number: number });
Expand Down
Loading
Loading