Files
deepseek-harness/packages/experimental/forge-github/src/gh.ts
T
Yichen Jiang dcbfd8079a feat(experimental): bind code host change requests to Sessions
Add the experimental forge capability seam:

- dsh-experimental-forge: provider-neutral repository and change request
  references, a provider registry, and shared status polling. Watchers of
  one change request share one loop; each forge has one batched fetch in
  flight, and the cadence follows check, mergeability, and lifecycle state.
- dsh-experimental-forge-github: GitHub provider that runs the GitHub CLI
  on the host with a scrubbed environment and reads every due pull request
  of a host in one GraphQL query.
- dsh-experimental-forge-session: a host-only projection derives Session
  bindings from gh pr create and git push shell results and from
  plugin:forge-session/binding records; push results trigger head-branch
  discovery of open pull requests, and Session watchers poll bound change
  requests through ctx.forge.
2026-10-20 11:49:25 +08:00

57 lines
2.4 KiB
TypeScript

/** Runs the GitHub CLI on the host with a scrubbed, non-interactive environment. */
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { execa } from 'execa'
/** Complete output of one `gh` invocation that ran to an exit code. */
export interface GhOutput {
/** Process exit code. */
readonly exitCode: number
/** Standard output without its final newline. */
readonly stdout: string
/** Standard error without its final newline. */
readonly stderr: string
}
/** Runs `gh` with literal arguments; rejects when the process cannot run to an exit code. */
export type GhRunner = (args: readonly string[], signal: AbortSignal) => Promise<GhOutput>
/** Resolved executable and limits for `gh`. */
export interface GhConfig {
/** Executable name or absolute host path. */
readonly ghCommand: string
/** Deadline in milliseconds for one invocation. */
readonly timeoutMs: number
/** Maximum captured bytes per output stream. */
readonly maxOutputBytes: number
}
/**
* Build a runner for `gh` on the host. The child inherits the scrubbed parent
* environment, so variables whose names contain KEY, PASSWORD, SECRET, or
* TOKEN never reach it and `gh` authenticates from its own credential store.
* Prompts, update notices, pagers, and colors are disabled.
* @param config - executable and limits.
* @returns the runner.
*/
export function ghRunner(config: GhConfig): GhRunner {
return async (args, signal) => {
const result = await execa(config.ghCommand, args, {
env: { ...scrubbedParentEnv(), GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', GH_PAGER: '', NO_COLOR: '1', CLICOLOR: '0' },
extendEnv: false,
stdin: 'ignore',
reject: false,
timeout: config.timeoutMs,
cancelSignal: signal,
maxBuffer: config.maxOutputBytes,
})
const command = ['gh', ...args.slice(0, 1)].join(' ')
if (result.isCanceled) throw signal.reason
if (result.timedOut) throw new Error(`${command} timed out after ${config.timeoutMs}ms`)
if (result.isMaxBuffer) throw new Error(`${command} output exceeds maxOutputBytes`)
// A result without an exit code failed before the process ran; execa then always sets shortMessage.
if (result.exitCode === undefined) throw new Error(`gh could not start: ${String(result.shortMessage)}`)
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }
}
}