Skip to content
Closed
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
b7cfc31
feat(sources): add GitHub project source with API fetch, embeddings, …
May 17, 2026
f5356bc
feat(sources): add GitHub source API route and CLI command
May 17, 2026
0d24898
test(sources): add tests for GitHub source config, embeddings, graph …
May 17, 2026
b697bb4
fix(sources): remove unused imports flagged by code quality bot
May 17, 2026
4dd287d
Merge branch 'main' into alex/github-source
aaf2tbz May 18, 2026
a4224c1
fix(sources): address PR review — readonly mutation, missing imports,…
May 18, 2026
214a320
fix(sources): use secrets module for token resolve, wire labels into …
May 18, 2026
5532e03
fix(sources): raw text fetch for wildcard docs, include sourceId in c…
May 18, 2026
1b87c6b
fix(sources): dynamic source loader for polling bridge, recursive git…
May 18, 2026
ed074e8
fix(sources): scope repo/label/ref canonicals by sourceId, store trim…
May 18, 2026
7c63f51
fix(sources): fix reconciliation query to match exact source_id, appl…
May 18, 2026
bdfb530
fix(sources): add resource-scoped graph purge, validate maxItemsPerRe…
May 18, 2026
0916f4b
fix(sources): gate reconciliation on complete enumeration — skip capp…
May 18, 2026
e83140a
fix(sources): normalize discussion comment shape, abort sync on sourc…
May 18, 2026
11be593
fix(sources): match doc resource key to source_path format, preserve …
May 18, 2026
991124d
fix(sources): remove invalid state field from discussion GraphQL quer…
May 18, 2026
c41ae3e
fix(sources): map docs: prefix to doc resource type in reconciliation
May 18, 2026
f748ec5
fix(sources): per-resource source-active check to prevent writes afte…
May 18, 2026
1ea529b
fix(sources): return purge count from purgeGitHubSource, report in de…
May 18, 2026
d0336ef
fix(cli): reject empty/invalid --types values for github source add
May 18, 2026
d115e76
fix(sources): default empty resourceTypes to defaults, add post-fetch…
May 18, 2026
ea9e426
fix(sources): client-side label filtering for PRs, admin permission o…
May 18, 2026
e456442
fix(sources): fetch PR review comments, fail closed on token resoluti…
May 18, 2026
388b249
fix(sources): use comma-separated labels param for issues API
May 18, 2026
0987337
refactor(sources): shared key normalization helpers for reconcile/ind…
May 18, 2026
814e57d
fix(sources): allow reconciliation when fetch returns zero resources
May 18, 2026
10f1273
fix(sources): validate resourceTypes on persist, use search API for l…
May 18, 2026
276a7e9
fix(sources): normalize search PR resource shape to match GitHubResou…
May 18, 2026
411d3b0
fix(sources): docs reconciliation plural, API docs, remove unused code
May 18, 2026
d8faf24
fix(sources): normalize issue comments, add container logs to smoke w…
May 18, 2026
43efb2a
fix(sources): missing requirePermission import, canonical name alignm…
May 18, 2026
a2fa35c
fix(sources): org-aware repo glob expansion, admin permission on dele…
May 18, 2026
dcb177c
fix(sources): warn and skip sources with empty repos from malformed s…
May 18, 2026
c946c49
fix(sources): CLI rejects invalid types, cap comments per resource at…
May 18, 2026
8cf2fc9
fix(sources): paginate repo glob expansion, register GitHub sync with…
May 18, 2026
eec39ff
fix(sources): validate --state in CLI before persisting
May 18, 2026
7a55625
fix(sources): import GitHubSourceBridgeOptions type, narrow CLI resou…
May 18, 2026
1f44767
fix(sources): track sync errors, don't mark indexed on partial failure
May 18, 2026
3b0cd06
fix(sources): use result.indexed in polling bridge sync accumulator
May 18, 2026
a21445f
fix(sources): register polling bridge syncs in source-index-progress …
May 18, 2026
31df729
fix(sources): gate reconciliation on comment fetch success, track sub…
May 18, 2026
be2afa8
fix(sources): propagate comment errors to hadErrors, default CLI type…
May 18, 2026
37ae4b8
fix(sources): skip discussions without token instead of changing defa…
May 18, 2026
d902542
fix(sources): widen fetchPullRequestsBySearch labels param to readonl…
May 18, 2026
09f6986
fix(sources): reconcile label/ref dependencies on each sync by deleti…
May 18, 2026
2b80916
fix(sources): use dependency_type column name in reconciliation delete
May 18, 2026
eab0fc3
fix(sources): agent-scoped source ownership for multi-agent isolation
May 18, 2026
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
5 changes: 4 additions & 1 deletion .github/workflows/docker-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ jobs:

- name: Build and start stack
run: |
SIGNET_HTTP_PORT=8080 SIGNET_HTTPS_PORT=8443 docker compose -f deploy/docker/compose.yml up -d --build
SIGNET_HTTP_PORT=8080 SIGNET_HTTPS_PORT=8443 docker compose -f deploy/docker/compose.yml up -d --build || {
docker compose -f deploy/docker/compose.yml logs signet 2>&1 || true
exit 1
}
- name: Wait for proxy readiness
run: |
Expand Down
46 changes: 46 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,51 @@ and source chunk embeddings. Source files are not modified.
}
```

### POST /api/sources/github

Add or update a GitHub source. Queues an async sync that indexes issues, pull
requests, discussions, and/or docs from one or more repos into the knowledge
graph and embedding store. Requires `admin` permission.

**Request body**

```json
{
"name": "Signet Issues",
"tokenRef": "GITHUB_TOKEN",
"repos": ["Signet-AI/signetai"],
"resourceTypes": ["issues", "pulls", "discussions", "docs"],
"state": "all",
"includeComments": true,
"labels": ["bug", "feature"],
"maxItemsPerRepo": 500,
"docPaths": ["README.md", "CHANGELOG.md"]
}
```

**Response**

```json
{
"source": { "id": "github:abc123def456", "kind": "github" },
"created": true
}
```

### DELETE /api/sources/:sourceId

Remove a source config and purge Signet-owned source artifacts, graph rows,
and source chunk embeddings. Source files are not modified.

**Response**

```json
{
"source": { "id": "obsidian:abc123", "kind": "obsidian" },
"purged": 150
}
```

### POST /api/sources/pick-directory

Best-effort local directory picker used by dashboard/browser flows. It returns
Expand Down Expand Up @@ -4385,6 +4430,7 @@ silently disappear from the API reference.
| GET | `/api/sources` | platform/daemon/src/routes/sources-routes.ts |
| POST | `/api/sources/pick-directory` | platform/daemon/src/routes/sources-routes.ts |
| POST | `/api/sources/obsidian` | platform/daemon/src/routes/sources-routes.ts |
| POST | `/api/sources/github` | platform/daemon/src/routes/sources-routes.ts |
| DELETE | `/api/sources/:sourceId` | platform/daemon/src/routes/sources-routes.ts |
| GET | `/api/knowledge/entities` | platform/daemon/src/routes/knowledge-routes.ts |
| POST | `/api/knowledge/entities/:id/pin` | platform/daemon/src/routes/knowledge-routes.ts |
Expand Down
6 changes: 6 additions & 0 deletions platform/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,18 +221,24 @@ export type {
WorkspaceSourceRepoSyncResult,
} from "./workspace-source-repo";
export {
addGitHubSource,
addObsidianSource,
DEFAULT_GITHUB_DOC_PATHS,
DEFAULT_GITHUB_RESOURCE_TYPES,
DEFAULT_OBSIDIAN_EXCLUDE_GLOBS,
getAgentsDir,
getSourcesConfigPath,
loadSourcesConfig,
markSourceIndexed,
parseGitHubSettings,
removeSource,
saveSourcesConfig,
} from "./sources-config";
export type {
AddGitHubSourceInput,
AddObsidianSourceInput,
AddSourceResult,
GitHubSourceSettings,
RemoveSourceResult,
SignetSourceEntry,
SignetSourceKind,
Expand Down
73 changes: 73 additions & 0 deletions platform/core/src/sources-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DEFAULT_OBSIDIAN_EXCLUDE_GLOBS,
addGitHubSource,
addObsidianSource,
getSourcesConfigPath,
loadSourcesConfig,
markSourceIndexed,
parseGitHubSettings,
removeSource,
} from "./sources-config";

Expand Down Expand Up @@ -152,4 +154,75 @@ describe("sources-config", () => {
if (removed.ok === true) throw new Error("expected removeSource to fail");
expect(removed.error).toContain("not found");
});

describe("GitHub source", () => {
it("adds a GitHub source with repos and token ref", () => {
const agentsDir = tmp();
const result = addGitHubSource(
{ repos: ["Signet-AI/signetai", "Signet-AI/sqmd"], name: "Signet Repos", tokenRef: "github-pat", now: "2026-01-01T00:00:00.000Z" },
agentsDir,
);
expect(result.ok).toBe(true);
if (result.ok === false) throw new Error(result.error);
expect(result.created).toBe(true);
expect(result.source.kind).toBe("github");
expect(result.source.mode).toBe("read-only");
expect(result.source.enabled).toBe(true);
expect(result.source.name).toBe("Signet Repos");

const config = loadSourcesConfig(agentsDir);
expect(config.sources).toHaveLength(1);
const settings = parseGitHubSettings(config.sources[0]!.settings);
expect(settings.repos).toEqual(["Signet-AI/signetai", "Signet-AI/sqmd"]);
expect(settings.tokenRef).toBe("github-pat");
});

it("updates an existing GitHub source instead of duplicating", () => {
const agentsDir = tmp();
const first = addGitHubSource(
{ repos: ["owner/repo"], name: "Repo A", now: "2026-01-01T00:00:00.000Z" },
agentsDir,
);
const second = addGitHubSource(
{ repos: ["owner/repo"], name: "Repo B", now: "2026-01-02T00:00:00.000Z" },
agentsDir,
);
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
if (second.ok === false) throw new Error(second.error);
expect(second.created).toBe(false);
expect(second.source.name).toBe("Repo B");
expect(loadSourcesConfig(agentsDir).sources).toHaveLength(1);
});

it("requires at least one repo", () => {
const agentsDir = tmp();
const result = addGitHubSource({ repos: [] }, agentsDir);
expect(result.ok).toBe(false);
if (result.ok === true) throw new Error("expected failure");
expect(result.error).toContain("repo");
});

it("coexists with Obsidian sources", () => {
const agentsDir = tmp();
const vault = join(agentsDir, "vault");
mkdirSync(vault, { recursive: true });

addObsidianSource({ root: vault, name: "My Vault" }, agentsDir);
addGitHubSource({ repos: ["owner/repo"], name: "GitHub" }, agentsDir);

const config = loadSourcesConfig(agentsDir);
expect(config.sources).toHaveLength(2);
expect(config.sources.map((s) => s.kind)).toEqual(["obsidian", "github"]);
});

it("defaults resource types to all four", () => {
const agentsDir = tmp();
const result = addGitHubSource({ repos: ["owner/repo"] }, agentsDir);
expect(result.ok).toBe(true);
if (result.ok === false) throw new Error(result.error);
const settings = parseGitHubSettings(loadSourcesConfig(agentsDir).sources[0]!.settings);
expect(settings.resourceTypes).toEqual(["issues", "pulls", "discussions", "docs"]);
});
});
});
156 changes: 153 additions & 3 deletions platform/core/src/sources-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,24 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writ
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";

export type SignetSourceKind = "obsidian";
export type SignetSourceKind = "obsidian" | "github";
export type SignetSourceMode = "read-only";

export interface GitHubSourceSettings {
readonly repos: readonly string[];
readonly tokenRef?: string;
readonly resourceTypes: readonly ("issues" | "pulls" | "discussions" | "docs")[];
readonly state?: "open" | "closed" | "all";
readonly includeComments?: boolean;
readonly labels?: readonly string[];
readonly docPaths?: readonly string[];
readonly maxItemsPerRepo?: number;
}

export const DEFAULT_GITHUB_RESOURCE_TYPES = ["issues", "pulls", "discussions", "docs"] as const;
const VALID_GITHUB_RESOURCE_TYPES = new Set<string>(DEFAULT_GITHUB_RESOURCE_TYPES);
export const DEFAULT_GITHUB_DOC_PATHS = ["README.md", "CHANGELOG.md"] as const;

export interface SignetSourceEntry {
readonly id: string;
readonly kind: SignetSourceKind;
Expand All @@ -17,6 +32,7 @@ export interface SignetSourceEntry {
readonly updatedAt: string;
readonly lastIndexedAt?: string;
readonly excludeGlobs?: readonly string[];
readonly settings?: Readonly<Record<string, unknown>>;
}

export const DEFAULT_OBSIDIAN_EXCLUDE_GLOBS = [
Expand All @@ -39,6 +55,19 @@ export interface AddObsidianSourceInput {
readonly now?: string;
}

export interface AddGitHubSourceInput {
readonly repos: readonly string[];
readonly name?: string;
readonly tokenRef?: string;
readonly resourceTypes?: readonly ("issues" | "pulls" | "discussions" | "docs")[];
readonly state?: "open" | "closed" | "all";
readonly includeComments?: boolean;
readonly labels?: readonly string[];
readonly docPaths?: readonly string[];
readonly maxItemsPerRepo?: number;
readonly now?: string;
}

export type AddSourceResult =
| { readonly ok: true; readonly source: SignetSourceEntry; readonly created: boolean }
| { readonly ok: false; readonly error: string };
Expand Down Expand Up @@ -163,6 +192,126 @@ function addObsidianSourceChecked(input: AddObsidianSourceInput, agentsDir = get
return { ok: true, source, created: true };
}

export function addGitHubSource(input: AddGitHubSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
return withSourcesConfigLock(agentsDir, () => addGitHubSourceUnlocked(input, agentsDir));
}

function addGitHubSourceUnlocked(input: AddGitHubSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
try {
return addGitHubSourceChecked(input, agentsDir);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return { ok: false, error: detail };
}
}

function addGitHubSourceChecked(input: AddGitHubSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
const repos = input.repos.map((r) => r.trim()).filter(Boolean);
if (repos.length === 0) return { ok: false, error: "At least one repo (owner/repo or owner/*) is required" };
for (const repo of repos) {
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_*.-]+$/.test(repo)) {
return { ok: false, error: `Invalid repo pattern: ${repo}. Expected owner/repo or owner/*` };
}
}

if (input.maxItemsPerRepo !== undefined) {
if (!Number.isFinite(input.maxItemsPerRepo) || input.maxItemsPerRepo < 1 || input.maxItemsPerRepo > 10000) {
return { ok: false, error: "maxItemsPerRepo must be between 1 and 10000" };
}
}

if (input.resourceTypes && input.resourceTypes.length > 0) {
const invalid = input.resourceTypes.filter((t) => !VALID_GITHUB_RESOURCE_TYPES.has(t));
if (invalid.length > 0) {
return { ok: false, error: `Invalid resource types: ${invalid.join(", ")}. Must be one of: ${[...DEFAULT_GITHUB_RESOURCE_TYPES].join(", ")}` };
}
}

const now = input.now ?? new Date().toISOString();
const cfg = loadSourcesConfigForWrite(agentsDir);
const settingsKey = repos.sort().join(",");
const existing = cfg.sources.find(
(source) => source.kind === "github" && (source.settings?.repos as string[])?.sort().join(",") === settingsKey,
);

if (existing) {
const updated: SignetSourceEntry = {
...existing,
name: cleanName(input.name) ?? existing.name,
enabled: true,
updatedAt: now,
settings: buildGitHubSettings(input, repos),
};
saveSourcesConfig(
{
version: SOURCES_CONFIG_VERSION,
sources: cfg.sources.map((source) => (source.id === existing.id ? updated : source)),
},
agentsDir,
);
return { ok: true, source: updated, created: false };
}
Comment thread
aaf2tbz marked this conversation as resolved.

const source: SignetSourceEntry = {
id: `github:${createHash("sha256").update(settingsKey).digest("hex").slice(0, 16)}`,
kind: "github",
Comment thread
aaf2tbz marked this conversation as resolved.
name: cleanName(input.name) ?? repos[0],
root: "",
enabled: true,
mode: "read-only",
createdAt: now,
updatedAt: now,
settings: buildGitHubSettings(input, repos),
};
saveSourcesConfig({ version: SOURCES_CONFIG_VERSION, sources: [...cfg.sources, source] }, agentsDir);
return { ok: true, source, created: true };
}

function buildGitHubSettings(input: AddGitHubSourceInput, repos: readonly string[]): Readonly<Record<string, unknown>> {
Comment thread
aaf2tbz marked this conversation as resolved.
const resourceTypes = input.resourceTypes ?? [...DEFAULT_GITHUB_RESOURCE_TYPES];
return {
repos: repos,
tokenRef: input.tokenRef,
Comment thread
aaf2tbz marked this conversation as resolved.
resourceTypes,
state: input.state ?? "all",
includeComments: input.includeComments ?? true,
labels: input.labels,
docPaths: input.docPaths ?? [...DEFAULT_GITHUB_DOC_PATHS],
maxItemsPerRepo: input.maxItemsPerRepo ?? 500,
};
}

export function parseGitHubSettings(raw: Readonly<Record<string, unknown>> | undefined): GitHubSourceSettings {
if (!raw) {
return { repos: [], resourceTypes: [...DEFAULT_GITHUB_RESOURCE_TYPES] };
}
const repos =
Array.isArray(raw.repos) && raw.repos.every((r) => typeof r === "string") ? (raw.repos as string[]) : [];
let resourceTypes =
Array.isArray(raw.resourceTypes) && raw.resourceTypes.every((t) => typeof t === "string")
? (raw.resourceTypes as string[]).filter((t): t is "issues" | "pulls" | "discussions" | "docs" =>
["issues", "pulls", "discussions", "docs"].includes(t),
)
: [...DEFAULT_GITHUB_RESOURCE_TYPES];
if (resourceTypes.length === 0) resourceTypes = [...DEFAULT_GITHUB_RESOURCE_TYPES];
return {
repos,
tokenRef: typeof raw.tokenRef === "string" ? raw.tokenRef : undefined,
Comment thread
aaf2tbz marked this conversation as resolved.
Comment thread
aaf2tbz marked this conversation as resolved.
resourceTypes,
state: raw.state === "open" || raw.state === "closed" || raw.state === "all" ? raw.state : "all",
includeComments: typeof raw.includeComments === "boolean" ? raw.includeComments : true,
labels:
Array.isArray(raw.labels) && raw.labels.every((l) => typeof l === "string")
? (raw.labels as string[])
: undefined,
docPaths:
Array.isArray(raw.docPaths) && raw.docPaths.every((p) => typeof p === "string")
? (raw.docPaths as string[])
: [...DEFAULT_GITHUB_DOC_PATHS],
maxItemsPerRepo: typeof raw.maxItemsPerRepo === "number" && raw.maxItemsPerRepo > 0 ? raw.maxItemsPerRepo : 500,
};
}

export function markSourceIndexed(
sourceId: string,
indexedAt = new Date().toISOString(),
Expand Down Expand Up @@ -272,7 +421,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function isSourceEntry(value: unknown): value is SignetSourceEntry {
return (
isRecord(value) &&
value.kind === "obsidian" &&
(value.kind === "obsidian" || value.kind === "github") &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.root === "string" &&
Expand All @@ -282,6 +431,7 @@ function isSourceEntry(value: unknown): value is SignetSourceEntry {
typeof value.updatedAt === "string" &&
(value.lastIndexedAt === undefined || typeof value.lastIndexedAt === "string") &&
(value.excludeGlobs === undefined ||
(Array.isArray(value.excludeGlobs) && value.excludeGlobs.every((entry) => typeof entry === "string")))
(Array.isArray(value.excludeGlobs) && value.excludeGlobs.every((entry) => typeof entry === "string"))) &&
(value.settings === undefined || isRecord(value.settings))
);
}
Loading
Loading