Skip to content
8 changes: 8 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ The package also includes a metadata-only ranker: `rankCandidateIssues` composes
(potential, feasibility, lane fit, freshness, dup risk) and returns fan-out candidates sorted by `rankScore`.
It never clones source and never writes to GitHub.

Discovery is per-tenant, not github.laiyagushi.com-specific (#4784): `lib/forge-config.js` (`resolveForgeConfig`) holds the
forge base URL, API version, request headers, repo path, search endpoint/qualifiers, user-agent, and credential env
var behind one resolver with gittensory's github.com values as the only defaults, so the fan-out targets another
forge unchanged. `gittensory-miner discover` surfaces `--api-base-url <url>` and `--token-env <VAR>` and forwards a
tenant goal spec to the ranker, printing `usedDefaultGoalSpec` so a fall-back to the built-in rubric is explicit
rather than silent. See [`docs/repo-agnostic-capability-audit.md`](docs/repo-agnostic-capability-audit.md) for the
#4780 audit this executes.

The package also includes an append-only governor decision ledger: `initGovernorLedger` / `appendGovernorEvent`
persist structured allow/deny/throttle/kill-switch outcomes in local SQLite for contributor audit. Insert-only —
no enforcement wiring yet. (#2328)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,45 @@ redo them:

## Prioritized checklist for #4784

- [ ] **High — forge abstraction (`opportunity-fanout.js`):** move the GitHub API version (7),
- [x] **High — forge abstraction (`opportunity-fanout.js`):** move the GitHub API version (7),
headers (69–71), repo path (83), search endpoint (241), and search-qualifier dialect (202) behind a
per-tenant forge adapter; keep GitHub as the default.
- [ ] **High — thread `apiBaseUrl` to the CLI (`discover-cli.js`):** surface the already-supported
- [x] **High — thread `apiBaseUrl` to the CLI (`discover-cli.js`):** surface the already-supported
`opportunity-fanout` `apiBaseUrl` override via config / a flag so a non-`github.com/ghapi` host is
reachable.
- [ ] **Medium — credential env var (`discover-cli.js:102`):** make the token env var name
- [x] **Medium — credential env var (`discover-cli.js:102`):** make the token env var name
configurable (default `GITHUB_TOKEN`).
- [ ] **Medium — pass tenant label taxonomy + goal spec through (`opportunity-ranker.js`, engine
- [x] **Medium — pass tenant label taxonomy + goal spec through (`opportunity-ranker.js`, engine
`DEFAULT_TYPE_LABELS`):** ensure the miner supplies the tenant's labels / goal spec instead of
silently falling back to the gittensory defaults.
- [ ] **Low — configurable user-agent (`opportunity-fanout.js:70`).**
- [x] **Low — configurable user-agent (`opportunity-fanout.js:70`).**

## Resolution (#4784)

All five checklist items are resolved (or, where noted, explicitly deferred with a reason); gittensory's
own github.com conventions survive only as defaults, and the existing gittensory discovery path is
unchanged (`resolveForgeConfig()` with no overrides is byte-identical to the pre-#4784 hardcoded
behavior).

- **Forge abstraction — resolved.** [`lib/forge-config.js`](../lib/forge-config.js) is the per-tenant
forge adapter: `DEFAULT_FORGE_CONFIG` holds every github.com value (base URL, API version + version
header name, `accept` header, user-agent, repo path prefix, search endpoint, search qualifiers,
token env var) and `resolveForgeConfig(overrides)` fills any missing field from that default.
`opportunity-fanout.js` now reads these from the resolved forge instead of module constants, so the
API version, request headers, repo path, search endpoint, and search-qualifier dialect are all
per-tenant.
- **`apiBaseUrl` reachable from the CLI — resolved.** `discover` accepts `--api-base-url <url>` (and
`runDiscover({ apiBaseUrl })`), threading the forge host that the fan-out already supported but that
the CLI never surfaced. A programmatic caller can also pass the rest of the forge knobs via
`runDiscover({ forge })`.
- **Credential env var — resolved.** `discover --token-env <VAR>` (and `runDiscover({ tokenEnv })`)
reads a non-`GITHUB_TOKEN` variable, defaulting to `GITHUB_TOKEN`.
- **Tenant goal spec through — resolved.** `runDiscover` forwards `goalSpecsByRepo` /
`goalSpecContentByRepo` to the ranker and surfaces `usedDefaultGoalSpec` in both the JSON and the
human-readable summary, so the fall-back to gittensory's built-in rubric is explicit rather than
silent. The **discovery** label taxonomy is the goal spec's generic `preferredLabels` /
`blockedLabels` (already per-tenant). The engine's `DEFAULT_TYPE_LABELS` (`gittensor:*`) is a
**review-stack** default (overridable per repo via the focus manifest) that the miner discovery
ranker never consults, so it is **deferred**: changing it belongs to the review path, not #4784's
discovery/claim scope.
- **Configurable user-agent — resolved.** `forge.userAgent` (default `loopover-miner`).
27 changes: 23 additions & 4 deletions packages/gittensory-miner/lib/discover-cli.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { ForgeConfig } from "./forge-config.js";
import type {
CandidateIssueWarning,
FanoutOptions,
FanoutTarget,
RawCandidateIssue,
} from "./opportunity-fanout.js";
import type { RankedCandidateIssue, RankedCandidateSummary } from "./opportunity-ranker.js";
import type {
RankCandidateIssuesOptions,
RankedCandidateIssue,
RankedCandidateSummary,
} from "./opportunity-ranker.js";
import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js";
import type { PortfolioQueueStore } from "./portfolio-queue.js";

Expand All @@ -12,6 +18,10 @@ export type ParsedDiscoverArgs =
targets: FanoutTarget[];
search: string | null;
json: boolean;
/** Present only when `--api-base-url` is supplied (#4784); threads the tenant's forge host to the fan-out. */
apiBaseUrl?: string;
/** Present only when `--token-env` is supplied (#4784); names the credential env var to read. */
tokenEnv?: string;
}
| { error: string };

Expand All @@ -33,27 +43,36 @@ export type DiscoverResult = {
rateLimitRemaining: number | null;
rateLimitResetAt: string | null;
ranked: DiscoverRankedEntry[];
/** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */
usedDefaultGoalSpec?: boolean;
enqueueSummary: EnqueueRankedDiscoverySummary;
};

export type RunDiscoverOptions = {
githubToken?: string;
apiBaseUrl?: string;
/** Per-tenant credential env var name (#4784); defaults to GITHUB_TOKEN. Overridden by a `--token-env` flag. */
tokenEnv?: string;
/** Per-tenant forge knobs beyond the host (#4784), forwarded to the fan-out. */
forge?: Partial<ForgeConfig>;
nowMs?: number;
/** Per-tenant goal specs threaded to the ranker so lane fit uses the tenant's conventions, not the defaults (#4784). */
goalSpecsByRepo?: RankCandidateIssuesOptions["goalSpecsByRepo"];
goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"];
initPortfolioQueue?: () => PortfolioQueueStore;
fetchCandidateIssuesWithSummary?: (
targets: FanoutTarget[],
githubToken: string,
options?: { apiBaseUrl?: string },
options?: FanoutOptions,
) => Promise<DiscoverFanOutSummary>;
searchCandidateIssuesWithSummary?: (
searchQuery: string,
githubToken: string,
options?: { apiBaseUrl?: string },
options?: FanoutOptions,
) => Promise<DiscoverFanOutSummary>;
rankCandidateIssuesWithSummary?: (
candidates: RawCandidateIssue[],
options?: { nowMs?: number },
options?: RankCandidateIssuesOptions,
) => RankedCandidateSummary;
enqueueRankedDiscovery?: (
rankedIssues: RankedCandidateIssue[],
Expand Down
64 changes: 56 additions & 8 deletions packages/gittensory-miner/lib/discover-cli.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** `discover` CLI command (#4247): wires the existing fanout -> rank -> enqueue pipeline together so a miner
* can actually run it. Every piece already exists and is independently tested; this module only composes them. */
import { resolveForgeConfig } from "./forge-config.js";
import {
fetchCandidateIssuesWithSummary,
searchCandidateIssuesWithSummary,
Expand All @@ -9,7 +10,7 @@ import { enqueueRankedDiscovery } from "./portfolio-discovery.js";
import { initPortfolioQueueStore } from "./portfolio-queue.js";

const DISCOVER_USAGE =
"Usage: gittensory-miner discover <owner/repo> [<owner/repo>...] | --search <query> [--json]";
"Usage: gittensory-miner discover <owner/repo> [<owner/repo>...] | --search <query> [--json] [--api-base-url <url>] [--token-env <VAR>]";

const MAX_DISCOVER_TITLE_DISPLAY_LENGTH = 240;
const OSC_SEQUENCE_PATTERN = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g;
Expand All @@ -36,7 +37,10 @@ function parseRepoTarget(value) {
}

export function parseDiscoverArgs(args) {
const options = { json: false, search: null };
// `--api-base-url` and `--token-env` (#4784) thread the tenant's forge host and credential env var into the
// fan-out; they are kept off the parsed result unless supplied, so callers that pass neither see the exact
// pre-#4784 `{ targets, search, json }` shape.
const options = { json: false, search: null, apiBaseUrl: null, tokenEnv: null };
const targets = [];

for (let index = 0; index < args.length; index += 1) {
Expand All @@ -52,6 +56,20 @@ export function parseDiscoverArgs(args) {
index += 1;
continue;
}
if (token === "--api-base-url") {
const value = args[index + 1];
if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE };
options.apiBaseUrl = value;
index += 1;
continue;
}
if (token === "--token-env") {
const value = args[index + 1];
if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE };
options.tokenEnv = value;
index += 1;
continue;
}
if (token.startsWith("-")) {
return { error: `Unknown option: ${token}` };
}
Expand All @@ -67,7 +85,13 @@ export function parseDiscoverArgs(args) {
return { error: "Pass either repository targets or --search, not both." };
}

return { targets, search: options.search, json: options.json };
return {
targets,
search: options.search,
json: options.json,
...(options.apiBaseUrl !== null ? { apiBaseUrl: options.apiBaseUrl } : {}),
...(options.tokenEnv !== null ? { tokenEnv: options.tokenEnv } : {}),
};
}

// The rate-limit line surfaces the telemetry the fanout already records (#4837) so an operator sees how close a
Expand All @@ -90,6 +114,13 @@ export function renderDiscoverSummary(result) {
if (result.enqueueSummary.skippedBelowMinRank > 0) {
lines.push(`skipped (below min rank): ${result.enqueueSummary.skippedBelowMinRank}`);
}
// Make the fall-back to gittensory's built-in rubric explicit instead of silent (#4784): when no per-tenant goal
// spec is supplied, lane fit reflects gittensory's defaults, not the target repo's own conventions.
if (result.usedDefaultGoalSpec) {
lines.push(
"note: ranked with the built-in default goal spec (no per-tenant .gittensory-miner.yml supplied)",
);
}
if (result.ranked.length === 0) {
lines.push("", "no candidates found.");
return lines.join("\n");
Expand All @@ -109,7 +140,16 @@ export async function runDiscover(args, options = {}) {
return 2;
}

const githubToken = options.githubToken ?? process.env.GITHUB_TOKEN ?? "";
// Credential env var is per-tenant (#4784): a `--token-env FORGE_PAT` flag (or `options.tokenEnv`) reads a
// non-`GITHUB_TOKEN` variable so a non-github.com forge's token is reachable. The default falls through to the
// forge adapter's own `tokenEnvVar` (github.com's `GITHUB_TOKEN`), so there's a single source of truth for the
// default credential env instead of a second hardcoded literal that could drift from `DEFAULT_FORGE_CONFIG`.
const tokenEnv = parsed.tokenEnv ?? options.tokenEnv ?? resolveForgeConfig(options.forge).tokenEnvVar;
const githubToken = options.githubToken ?? process.env[tokenEnv] ?? "";
// A `--api-base-url` flag (or `options.apiBaseUrl`) surfaces the fan-out's existing forge-host override at the CLI
// (#4784); `options.forge` carries any remaining per-tenant forge knobs for a programmatic caller.
const apiBaseUrl = parsed.apiBaseUrl ?? options.apiBaseUrl;
const fanOutOptions = { apiBaseUrl, forge: options.forge };
const fetchTargets = options.fetchCandidateIssuesWithSummary ?? fetchCandidateIssuesWithSummary;
const searchTargets = options.searchCandidateIssuesWithSummary ?? searchCandidateIssuesWithSummary;
const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary;
Expand All @@ -121,10 +161,17 @@ export async function runDiscover(args, options = {}) {
try {
const fanOut =
parsed.search !== null
? await searchTargets(parsed.search, githubToken, { apiBaseUrl: options.apiBaseUrl })
: await fetchTargets(parsed.targets, githubToken, { apiBaseUrl: options.apiBaseUrl });

const rankedSummary = rankIssues(fanOut.issues, { nowMs: options.nowMs });
? await searchTargets(parsed.search, githubToken, fanOutOptions)
: await fetchTargets(parsed.targets, githubToken, fanOutOptions);

// Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's
// conventions instead of silently falling back to gittensory's defaults (#4784); the fallback is surfaced via
// `usedDefaultGoalSpec` below rather than hidden.
const rankedSummary = rankIssues(fanOut.issues, {
nowMs: options.nowMs,
goalSpecsByRepo: options.goalSpecsByRepo,
goalSpecContentByRepo: options.goalSpecContentByRepo,
});
const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: portfolioQueue });

const result = {
Expand All @@ -133,6 +180,7 @@ export async function runDiscover(args, options = {}) {
rateLimitRemaining: fanOut.rateLimitRemaining,
rateLimitResetAt: fanOut.rateLimitResetAt,
ranked: rankedSummary.issues,
usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec,
enqueueSummary,
};

Expand Down
17 changes: 17 additions & 0 deletions packages/gittensory-miner/lib/forge-config.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** Per-tenant forge configuration (#4784). Every field is a string knob defaulting to the github.com value in
* `DEFAULT_FORGE_CONFIG`; a tenant overrides only what differs for their forge. */
export type ForgeConfig = {
apiBaseUrl: string;
apiVersion: string;
apiVersionHeader: string;
acceptHeader: string;
userAgent: string;
repoPathPrefix: string;
searchEndpoint: string;
searchQualifiers: string;
tokenEnvVar: string;
};

export const DEFAULT_FORGE_CONFIG: Readonly<ForgeConfig>;

export function resolveForgeConfig(overrides?: Partial<ForgeConfig>): ForgeConfig;
37 changes: 37 additions & 0 deletions packages/gittensory-miner/lib/forge-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** Per-tenant forge configuration (#4784): the GitHub-specific protocol details that discovery used to hardcode,
* gathered behind one resolver so a non-github.com tenant (GitHub Enterprise, or another GitHub-compatible forge)
* can override them. gittensory's own github.com conventions survive only as `DEFAULT_FORGE_CONFIG` — calling
* `resolveForgeConfig()` with no overrides is byte-identical to the pre-#4784 hardcoded fan-out behavior, which is
* what keeps the existing gittensory discovery path unchanged. Executes the #4780 repo-agnostic-capability-audit
* checklist (forge abstraction, configurable credential env var, configurable user-agent). */

/** The github.com defaults every forge field falls back to. Frozen so a caller can't mutate the shared baseline. */
export const DEFAULT_FORGE_CONFIG = Object.freeze({
apiBaseUrl: "https://github.com/ghapi",
apiVersion: "2022-11-28",
apiVersionHeader: "x-github-api-version",
acceptHeader: "application/vnd.github+json",
userAgent: "loopover-miner",
repoPathPrefix: "/repos",
searchEndpoint: "/search/issues",
searchQualifiers: "state:open type:issue",
tokenEnvVar: "GITHUB_TOKEN",
});

function trimmedStringOr(value, fallback) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}

/**
* Resolve a full forge config from partial per-tenant overrides. Every field is an independent string knob that
* falls back to its github.com default when the override is missing, non-string, or blank — so a partial override
* (say, only `apiBaseUrl` for a GitHub Enterprise host) still yields a complete, usable config.
*/
export function resolveForgeConfig(overrides = {}) {
const source = overrides && typeof overrides === "object" ? overrides : {};
const resolved = {};
for (const [key, fallback] of Object.entries(DEFAULT_FORGE_CONFIG)) {
resolved[key] = trimmedStringOr(source[key], fallback);
}
return resolved;
}
51 changes: 19 additions & 32 deletions packages/gittensory-miner/lib/opportunity-fanout.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import type { ForgeConfig } from "./forge-config.js";

export type FanoutTarget = {
owner: string;
repo: string;
};

/** Options shared by every fan-out entry point. `apiBaseUrl` is the legacy top-level forge-host override (it still
* wins over `forge.apiBaseUrl`); `forge` (#4784) carries the rest of the per-tenant forge knobs. */
export type FanoutOptions = {
apiBaseUrl?: string;
forge?: Partial<ForgeConfig>;
concurrency?: number;
rateLimitLowWaterMark?: number;
rateLimitHighWaterMark?: number;
perPage?: number;
maxPages?: number;
sleepFn?: (ms: number) => Promise<unknown>;
};

export type RawCandidateIssue = {
owner: string;
repo: string;
Expand Down Expand Up @@ -42,51 +57,23 @@ export function mapWithConcurrency<T, R>(
export function fetchCandidateIssuesWithSummary(
targets: FanoutTarget[],
githubToken: string,
options?: {
apiBaseUrl?: string;
concurrency?: number;
rateLimitLowWaterMark?: number;
rateLimitHighWaterMark?: number;
perPage?: number;
sleepFn?: (ms: number) => Promise<unknown>;
},
options?: FanoutOptions,
): Promise<CandidateIssueSummary>;

export function fetchCandidateIssues(
targets: FanoutTarget[],
githubToken: string,
options?: {
apiBaseUrl?: string;
concurrency?: number;
rateLimitLowWaterMark?: number;
rateLimitHighWaterMark?: number;
perPage?: number;
sleepFn?: (ms: number) => Promise<unknown>;
},
options?: FanoutOptions,
): Promise<RawCandidateIssue[]>;

export function searchCandidateIssuesWithSummary(
searchQuery: string,
githubToken: string,
options?: {
apiBaseUrl?: string;
concurrency?: number;
rateLimitLowWaterMark?: number;
rateLimitHighWaterMark?: number;
perPage?: number;
sleepFn?: (ms: number) => Promise<unknown>;
},
options?: FanoutOptions,
): Promise<CandidateIssueSummary>;

export function searchCandidateIssues(
searchQuery: string,
githubToken: string,
options?: {
apiBaseUrl?: string;
concurrency?: number;
rateLimitLowWaterMark?: number;
rateLimitHighWaterMark?: number;
perPage?: number;
sleepFn?: (ms: number) => Promise<unknown>;
},
options?: FanoutOptions,
): Promise<RawCandidateIssue[]>;
Loading