Skip to content

Commit 77f440d

Browse files
committed
feat(sources): surface GitHub setup in dashboard
1 parent 5a301f1 commit 77f440d

2 files changed

Lines changed: 235 additions & 15 deletions

File tree

surfaces/dashboard/src/lib/api.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,6 +1211,20 @@ export interface AddDiscordSourceInput {
12111211
syncMode?: "rest" | "gateway-tail" | "desktop-cache";
12121212
}
12131213

1214+
export type GitHubSourceResourceType = "issues" | "pulls" | "discussions" | "docs";
1215+
1216+
export interface AddGitHubSourceInput {
1217+
repos: string[];
1218+
tokenRef?: string;
1219+
name?: string;
1220+
resourceTypes?: GitHubSourceResourceType[];
1221+
state?: "open" | "closed" | "all";
1222+
includeComments?: boolean;
1223+
labels?: string[];
1224+
docPaths?: string[];
1225+
maxItemsPerRepo?: number;
1226+
}
1227+
12141228
export async function addDiscordSource(input: AddDiscordSourceInput): Promise<AddSourceResponse> {
12151229
const response = await fetch(`${API_BASE}/api/sources/discord`, {
12161230
method: "POST",
@@ -1238,6 +1252,33 @@ export async function addDiscordSource(input: AddDiscordSourceInput): Promise<Ad
12381252
return body as AddSourceResponse;
12391253
}
12401254

1255+
export async function addGitHubSource(input: AddGitHubSourceInput): Promise<AddSourceResponse> {
1256+
const response = await fetch(`${API_BASE}/api/sources/github`, {
1257+
method: "POST",
1258+
headers: { "Content-Type": "application/json" },
1259+
body: JSON.stringify(input),
1260+
});
1261+
const body = (await response.json().catch(() => null)) as Partial<AddSourceResponse> | null;
1262+
if (!response.ok) {
1263+
return {
1264+
source: {
1265+
id: "",
1266+
kind: "github",
1267+
name: input.name ?? "GitHub",
1268+
root: `github://repos/${input.repos.join(",")}`,
1269+
enabled: false,
1270+
mode: "read-only",
1271+
createdAt: "",
1272+
updatedAt: "",
1273+
},
1274+
created: false,
1275+
indexed: 0,
1276+
error: typeof body?.error === "string" ? body.error : `Request failed with ${response.status}`,
1277+
};
1278+
}
1279+
return body as AddSourceResponse;
1280+
}
1281+
12411282
export async function removeSource(sourceId: string): Promise<RemoveSourceResponse> {
12421283
try {
12431284
const response = await fetch(`${API_BASE}/api/sources/${encodeURIComponent(sourceId)}`, {

surfaces/dashboard/src/lib/components/tabs/SourcesTab.svelte

Lines changed: 194 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<script lang="ts">
22
import {
3+
type GitHubSourceResourceType,
34
type SignetSourceEntry,
45
addDiscordSource,
6+
addGitHubSource,
57
addObsidianSource,
68
getSources,
79
pickSourceDirectory,
@@ -137,10 +139,32 @@ let discordIncludeEmbeds = $state(true);
137139
let discordIncludePolls = $state(true);
138140
// biome-ignore lint/style/useConst: Svelte bind:checked mutates this rune from markup.
139141
let discordIncludeThreadMembers = $state(true);
142+
// biome-ignore lint/style/useConst: Svelte bind:value mutates this rune from markup.
143+
let githubName = $state("GitHub");
144+
let githubReposText = $state("");
145+
let githubTokenRef = $state("");
146+
// biome-ignore lint/style/useConst: Svelte bind:value mutates this rune from markup.
147+
let githubState = $state<"open" | "closed" | "all">("all");
148+
// biome-ignore lint/style/useConst: Svelte bind:checked mutates this rune from markup.
149+
let githubIncludeIssues = $state(true);
150+
// biome-ignore lint/style/useConst: Svelte bind:checked mutates this rune from markup.
151+
let githubIncludePulls = $state(true);
152+
// biome-ignore lint/style/useConst: Svelte bind:checked mutates this rune from markup.
153+
let githubIncludeDiscussions = $state(false);
154+
// biome-ignore lint/style/useConst: Svelte bind:checked mutates this rune from markup.
155+
let githubIncludeDocs = $state(true);
156+
// biome-ignore lint/style/useConst: Svelte bind:checked mutates this rune from markup.
157+
let githubIncludeComments = $state(true);
158+
let githubLabelsText = $state("");
159+
// biome-ignore lint/style/useConst: Svelte bind:value mutates this rune from markup.
160+
let githubDocPathsText = $state("README.md\nCHANGELOG.md");
161+
// biome-ignore lint/style/useConst: Svelte bind:value mutates this rune from markup.
162+
let githubMaxItems = $state(500);
140163
let status = $state<string | null>(null);
141164
let error = $state<string | null>(null);
142165
let touchedPath = $state(false);
143166
let touchedDiscord = $state(false);
167+
let touchedGithub = $state(false);
144168
let expandedKind = $state<SourceKind | null>(null);
145169
let sourceRefreshTimer: ReturnType<typeof setInterval> | null = null;
146170
@@ -166,12 +190,13 @@ const connectors: SourceConnector[] = [
166190
kind: "github",
167191
name: "GitHub Repositories",
168192
detail: "Issues, PRs, code, docs",
169-
description: "Bring repository context, issues, pull requests, and project docs into recall.",
193+
description:
194+
"Connect public or private repositories so issues, pull requests, discussions, comments, and docs stay available as source-backed recall.",
170195
icon: "github",
171196
category: "code",
172-
tags: ["code", "cloud"],
173-
status: "planned",
174-
indexes: ["Repository files", "Issues and PRs", "Project documentation"],
197+
tags: ["code", "cloud", "issues", "pull-requests", "docs"],
198+
status: "available",
199+
indexes: ["Issues and pull requests", "Discussion and issue comments", "README and docs paths"],
175200
never: ["Push changes without an explicit action"],
176201
learnMore: [],
177202
},
@@ -475,26 +500,45 @@ const filters: Array<{ id: ActiveFilter; label: string }> = [
475500
const selectedConnector = $derived(connectors.find((connector) => connector.kind === selectedKind) ?? connectors[0]);
476501
const obsidianSources = $derived(sources.filter((source) => source.kind === "obsidian"));
477502
const discordSources = $derived(sources.filter((source) => source.kind === "discord"));
478-
const connectedCount = $derived(obsidianSources.length + discordSources.length);
503+
const githubSources = $derived(sources.filter((source) => source.kind === "github"));
504+
const connectedSourceList = $derived([...obsidianSources, ...discordSources, ...githubSources]);
505+
const connectedCount = $derived(connectedSourceList.length);
479506
const indexedCount = $derived(
480-
[...obsidianSources, ...discordSources].filter((source) => source.lastIndexedAt || (source.stats?.indexed ?? 0) > 0)
481-
.length,
507+
connectedSourceList.filter((source) => source.lastIndexedAt || (source.stats?.indexed ?? 0) > 0).length,
482508
);
483509
const hasActiveIndexJob = $derived(
484-
[...obsidianSources, ...discordSources].some(
485-
(source) => source.indexJob?.status === "queued" || source.indexJob?.status === "running",
486-
),
510+
connectedSourceList.some((source) => source.indexJob?.status === "queued" || source.indexJob?.status === "running"),
487511
);
488512
const pathIsMissing = $derived(vaultPath.trim().length === 0);
489513
const discordGuildIds = $derived(parseListInput(discordGuildIdsText));
490514
const discordChannelFilter = $derived(parseListInput(discordChannelFilterText));
491515
const discordUsesDesktopCache = $derived(discordSyncMode === "desktop-cache");
492516
const discordTokenMissing = $derived(discordTokenRef.trim().length === 0);
493517
const discordGuildsMissing = $derived(discordGuildIds.length === 0);
518+
const githubRepos = $derived(parseListInput(githubReposText));
519+
const githubLabels = $derived(parseListInput(githubLabelsText));
520+
const githubDocPaths = $derived(parseListInput(githubDocPathsText));
521+
const githubResourceTypes = $derived.by(() => {
522+
const types: GitHubSourceResourceType[] = [];
523+
if (githubIncludeIssues) types.push("issues");
524+
if (githubIncludePulls) types.push("pulls");
525+
if (githubIncludeDiscussions) types.push("discussions");
526+
if (githubIncludeDocs) types.push("docs");
527+
return types;
528+
});
529+
const githubReposMissing = $derived(githubRepos.length === 0);
530+
const githubResourceTypesMissing = $derived(githubResourceTypes.length === 0);
531+
const githubTokenMissingForDiscussions = $derived(githubIncludeDiscussions && githubTokenRef.trim().length === 0);
532+
const githubMaxItemsInvalid = $derived(!Number.isInteger(Number(githubMaxItems)) || Number(githubMaxItems) < 1);
494533
const canSubmit = $derived.by(() => {
495534
if (adding) return false;
496535
if (selectedKind === "obsidian") return !pathIsMissing;
497536
if (selectedKind === "discord") return discordUsesDesktopCache || (!discordGuildsMissing && !discordTokenMissing);
537+
if (selectedKind === "github") {
538+
return (
539+
!githubReposMissing && !githubResourceTypesMissing && !githubTokenMissingForDiscussions && !githubMaxItemsInvalid
540+
);
541+
}
498542
return false;
499543
});
500544
const filteredConnectors = $derived.by(() => {
@@ -540,6 +584,7 @@ function selectConnector(kind: SourceKind): void {
540584
error = null;
541585
touchedPath = false;
542586
touchedDiscord = false;
587+
touchedGithub = false;
543588
}
544589
545590
async function chooseFolder(): Promise<void> {
@@ -664,8 +709,43 @@ async function submitDiscordSource(): Promise<void> {
664709
}
665710
}
666711
712+
async function submitGitHubSource(): Promise<void> {
713+
touchedGithub = true;
714+
if (!canSubmit) return;
715+
adding = true;
716+
status = null;
717+
error = null;
718+
try {
719+
const result = await addGitHubSource({
720+
repos: githubRepos,
721+
tokenRef: githubTokenRef.trim() || undefined,
722+
name: githubName.trim() || undefined,
723+
resourceTypes: githubResourceTypes,
724+
state: githubState,
725+
includeComments: githubIncludeComments,
726+
labels: githubLabels.length > 0 ? githubLabels : undefined,
727+
docPaths: githubDocPaths,
728+
maxItemsPerRepo: Number(githubMaxItems),
729+
});
730+
if (result.error) {
731+
error = result.error;
732+
return;
733+
}
734+
status = `${result.created ? "Connected" : "Updated"} ${result.source.name}. GitHub indexing is running in the background.`;
735+
githubReposText = "";
736+
githubTokenRef = "";
737+
githubLabelsText = "";
738+
touchedGithub = false;
739+
connectMode = false;
740+
await refreshSources();
741+
} finally {
742+
adding = false;
743+
}
744+
}
745+
667746
async function disconnectSource(source: SignetSourceEntry): Promise<void> {
668-
const originalLabel = source.kind === "discord" ? "Discord data" : "vault files";
747+
const originalLabel =
748+
source.kind === "discord" ? "Discord data" : source.kind === "github" ? "GitHub data" : "vault files";
669749
const confirmed = window.confirm(
670750
`Remove ${source.name} from Signet?\n\nThis purges Signet's indexed source rows and chunks, but leaves the original ${originalLabel} untouched.`,
671751
);
@@ -718,7 +798,7 @@ function sourceScanLabel(source: SignetSourceEntry): string {
718798
return scanned > 0 ? `Indexing · ${scanned} scanned · ${indexed} changed` : "Indexing in background";
719799
}
720800
if (source.indexJob?.status === "error") return `Index failed: ${source.indexJob.error ?? "unknown error"}`;
721-
const itemLabel = source.kind === "obsidian" ? "notes" : "artifacts";
801+
const itemLabel = source.kind === "obsidian" ? "notes" : source.kind === "github" ? "items" : "artifacts";
722802
const indexed = source.stats?.indexed ?? 0;
723803
const chunks = source.stats?.chunks ?? 0;
724804
if (indexed > 0) return `${indexed} ${itemLabel} · ${chunks} chunks${source.lastIndexedAt ? "" : " · syncing"}`;
@@ -976,7 +1056,7 @@ function sourceIndexCurrentPath(source: SignetSourceEntry): string {
9761056
<div class="connector-grid" class:has-expanded={expandedKind !== null}>
9771057
{#each filteredConnectors as connector (connector.kind)}
9781058
{@const expanded = expandedKind === connector.kind}
979-
{@const connectedSources = connector.kind === "obsidian" ? obsidianSources : connector.kind === "discord" ? discordSources : []}
1059+
{@const connectedSources = connector.kind === "obsidian" ? obsidianSources : connector.kind === "discord" ? discordSources : connector.kind === "github" ? githubSources : []}
9801060
{@const isConnected = connectedSources.length > 0}
9811061
<article class="connector-card" class:expanded class:connected={isConnected} class:compressed={expandedKind !== null && !expanded}>
9821062
<button
@@ -1063,7 +1143,7 @@ function sourceIndexCurrentPath(source: SignetSourceEntry): string {
10631143
onclick={() => void disconnectSource(source)}
10641144
>
10651145
{#if removingSourceId === source.id}<span class="spin"><RefreshCw /></span>{:else}<X />{/if}
1066-
{removingSourceId === source.id ? "Removing" : source.kind === "discord" ? "Disconnect Discord" : "Disconnect vault"}
1146+
{removingSourceId === source.id ? "Removing" : source.kind === "discord" ? "Disconnect Discord" : source.kind === "github" ? "Disconnect GitHub" : "Disconnect vault"}
10671147
</button>
10681148
</article>
10691149
{/each}
@@ -1208,6 +1288,93 @@ function sourceIndexCurrentPath(source: SignetSourceEntry): string {
12081288
</button>
12091289
</form>
12101290
{/if}
1291+
{#if connectMode && expanded && connector.kind === "github"}
1292+
<form class="connect-form" onsubmit={(event) => { event.preventDefault(); void submitGitHubSource(); }}>
1293+
<label>
1294+
<span>Display name</span>
1295+
<input bind:value={githubName} placeholder="GitHub" />
1296+
</label>
1297+
<label>
1298+
<span>Repositories</span>
1299+
<textarea
1300+
bind:value={githubReposText}
1301+
rows="3"
1302+
placeholder="Signet-AI/signetai&#10;Signet-AI/*"
1303+
onblur={() => (touchedGithub = true)}
1304+
></textarea>
1305+
<small class="field-hint">One owner/repo or owner/* pattern per line. Public repositories work without a token.</small>
1306+
</label>
1307+
{#if touchedGithub && githubReposMissing}<p class="field-error">At least one GitHub repository is required.</p>{/if}
1308+
<label>
1309+
<span>Secret reference</span>
1310+
<input
1311+
bind:value={githubTokenRef}
1312+
placeholder="GITHUB_TOKEN"
1313+
onblur={() => (touchedGithub = true)}
1314+
/>
1315+
<small class="field-hint">Optional Signet secret name for private repositories, higher rate limits, and discussions. Do not paste a raw token.</small>
1316+
</label>
1317+
<div class="github-options-grid">
1318+
<label>
1319+
<span>State</span>
1320+
<select bind:value={githubState}>
1321+
<option value="all">Open and closed</option>
1322+
<option value="open">Open only</option>
1323+
<option value="closed">Closed only</option>
1324+
</select>
1325+
</label>
1326+
<label>
1327+
<span>Item cap</span>
1328+
<input
1329+
bind:value={githubMaxItems}
1330+
type="number"
1331+
min="1"
1332+
max="10000"
1333+
onblur={() => (touchedGithub = true)}
1334+
/>
1335+
</label>
1336+
</div>
1337+
{#if touchedGithub && githubMaxItemsInvalid}<p class="field-error">Item cap must be a whole number of at least 1.</p>{/if}
1338+
<div class="source-option-grid" aria-label="GitHub source resources">
1339+
<label class="source-option-row">
1340+
<input bind:checked={githubIncludeIssues} type="checkbox" />
1341+
<span>Issues</span>
1342+
</label>
1343+
<label class="source-option-row">
1344+
<input bind:checked={githubIncludePulls} type="checkbox" />
1345+
<span>Pull requests</span>
1346+
</label>
1347+
<label class="source-option-row">
1348+
<input bind:checked={githubIncludeDocs} type="checkbox" />
1349+
<span>Docs</span>
1350+
</label>
1351+
<label class="source-option-row">
1352+
<input bind:checked={githubIncludeDiscussions} type="checkbox" />
1353+
<span>Discussions</span>
1354+
</label>
1355+
</div>
1356+
{#if touchedGithub && githubResourceTypesMissing}<p class="field-error">Choose at least one GitHub resource type.</p>{/if}
1357+
{#if touchedGithub && githubTokenMissingForDiscussions}<p class="field-error">Discussions require a GitHub secret reference.</p>{/if}
1358+
<label class="source-option-row">
1359+
<input bind:checked={githubIncludeComments} type="checkbox" />
1360+
<span>Include comments</span>
1361+
</label>
1362+
<label>
1363+
<span>Labels</span>
1364+
<textarea bind:value={githubLabelsText} rows="3" placeholder="bug&#10;docs"></textarea>
1365+
<small class="field-hint">Optional labels for issue and PR indexing. Leave blank to include all labels.</small>
1366+
</label>
1367+
<label>
1368+
<span>Doc paths</span>
1369+
<textarea bind:value={githubDocPathsText} rows="4" placeholder="README.md&#10;docs/**/*.md"></textarea>
1370+
<small class="field-hint">Use exact files or globs. Defaults cover README and changelog without crawling the whole repository.</small>
1371+
</label>
1372+
<button class="connect-button" type="submit" disabled={!canSubmit}>
1373+
{#if adding}<span class="spin"><RefreshCw /></span>{:else}<CirclePlus />{/if}
1374+
{adding ? "Queueing" : "Add source"}
1375+
</button>
1376+
</form>
1377+
{/if}
12111378
{:else}
12121379
<p class="form-status form-status--info">Planned source. Not connectable yet.</p>
12131380
{/if}
@@ -1990,6 +2157,7 @@ function sourceIndexCurrentPath(source: SignetSourceEntry): string {
19902157
}
19912158
19922159
.connect-form input,
2160+
.connect-form select,
19932161
.connect-form textarea {
19942162
width: 100%;
19952163
border: 1px solid var(--sig-border-strong);
@@ -2001,10 +2169,15 @@ function sourceIndexCurrentPath(source: SignetSourceEntry): string {
20012169
outline: none;
20022170
}
20032171
2004-
.connect-form input {
2172+
.connect-form input,
2173+
.connect-form select {
20052174
height: 32px;
20062175
}
20072176
2177+
.connect-form select {
2178+
appearance: none;
2179+
}
2180+
20082181
.connect-form textarea {
20092182
min-height: 86px;
20102183
padding: 9px 10px;
@@ -2022,6 +2195,12 @@ function sourceIndexCurrentPath(source: SignetSourceEntry): string {
20222195
gap: 10px;
20232196
}
20242197
2198+
.github-options-grid {
2199+
display: grid;
2200+
grid-template-columns: minmax(0, 1fr) 110px;
2201+
gap: 10px;
2202+
}
2203+
20252204
.source-option-grid {
20262205
display: grid;
20272206
grid-template-columns: repeat(2, minmax(0, 1fr));

0 commit comments

Comments
 (0)