Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

303 lines
9.7 KiB
TypeScript

/**
* Shared pull-request reads for the Pi cloud modes. Review Code pins a snapshot
* before cloning and re-validates it before submitting; Babysit pins one per
* round. Keeping the coordinate validation,
* fetch, and parse here stops the two from drifting on checks that decide which
* repository a credential is pointed at and which commit a write lands on.
*/
import { executeTool } from '@/tools'
import { GITHUB_GRAPHQL_URL, githubGraphQlHeaders, readGraphQlData } from '@/tools/github/graphql'
import {
isRecord,
nullableBoolean,
nullableString,
requiredBoolean,
requiredNumber,
requiredRecord,
requiredTrimmedString,
} from '@/tools/github/response-parsers'
const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i
const PULL_REQUEST_RESPONSE_CONTEXT = 'GitHub pull request response'
/** Bound on untrusted pull-request-authored text folded into a Pi prompt. */
export const MAX_REVIEW_BODY_LENGTH = 8_000
/** Everything a pull-request read needs, independent of which mode asked for it. */
export interface PullRequestCoordinates {
owner: string
repo: string
pullNumber: number
githubToken: string
}
export interface PullRequestSnapshot {
headSha: string
headRef: string
headRepoFullName: string | null
baseSha: string
baseRef: string
baseRepoFullName: string | null
title: string
body: string
htmlUrl: string
state: string
merged: boolean
mergeable: boolean | null
}
export interface BranchPullRequest {
pullNumber: number
snapshot: PullRequestSnapshot
}
export type PullRequestDraftState = 'draft' | 'ready'
function requiredSha(record: Record<string, unknown>, field: string, context: string): string {
const value = requiredTrimmedString(record, field, context)
if (!COMMIT_SHA_PATTERN.test(value)) {
throw new Error(`${context}.${field} must be a full commit SHA`)
}
return value
}
export function parsePullRequestSnapshot(value: unknown): PullRequestSnapshot {
if (!isRecord(value)) throw new Error(`${PULL_REQUEST_RESPONSE_CONTEXT} must be an object`)
const head = requiredRecord(value, 'head', PULL_REQUEST_RESPONSE_CONTEXT)
const base = requiredRecord(value, 'base', PULL_REQUEST_RESPONSE_CONTEXT)
const headContext = `${PULL_REQUEST_RESPONSE_CONTEXT}.head`
const baseContext = `${PULL_REQUEST_RESPONSE_CONTEXT}.base`
return {
headSha: requiredSha(head, 'sha', headContext),
headRef: requiredTrimmedString(head, 'ref', headContext),
headRepoFullName: nullableString(head, 'repo_full_name', headContext),
baseSha: requiredSha(base, 'sha', baseContext),
baseRef: requiredTrimmedString(base, 'ref', baseContext),
baseRepoFullName: nullableString(base, 'repo_full_name', baseContext),
title: requiredTrimmedString(value, 'title', PULL_REQUEST_RESPONSE_CONTEXT),
body: nullableString(value, 'body', PULL_REQUEST_RESPONSE_CONTEXT) ?? '',
htmlUrl: requiredTrimmedString(value, 'html_url', PULL_REQUEST_RESPONSE_CONTEXT),
state: requiredTrimmedString(value, 'state', PULL_REQUEST_RESPONSE_CONTEXT),
merged: requiredBoolean(value, 'merged', PULL_REQUEST_RESPONSE_CONTEXT),
mergeable: nullableBoolean(value, 'mergeable', PULL_REQUEST_RESPONSE_CONTEXT),
}
}
/**
* Reads and validates the pull request without judging its state. A mode that
* must report a closed PR gracefully rather than throw builds on this form.
*/
export async function fetchPrSnapshot(
params: PullRequestCoordinates,
signal?: AbortSignal
): Promise<PullRequestSnapshot> {
const result = await executeTool(
'github_pr_v2',
{
owner: params.owner,
repo: params.repo,
pullNumber: params.pullNumber,
includeFiles: false,
apiKey: params.githubToken,
},
{ signal }
)
if (!result.success) {
throw new Error(`Failed to fetch PR #${params.pullNumber}: ${result.error ?? 'unknown error'}`)
}
return parsePullRequestSnapshot(result.output)
}
/** {@link fetchPrSnapshot} for callers that cannot proceed on a non-open PR. */
export async function fetchOpenPrSnapshot(
params: PullRequestCoordinates,
signal?: AbortSignal
): Promise<PullRequestSnapshot> {
const snapshot = await fetchPrSnapshot(params, signal)
if (snapshot.state !== 'open') {
throw new Error(`PR #${params.pullNumber} is ${snapshot.state}; only open PRs are supported`)
}
return snapshot
}
/**
* Verifies that an open pull request still belongs to the requested branch in
* the requested repository.
*/
export async function fetchOpenPrForBranch(
params: PullRequestCoordinates & { branch: string },
signal?: AbortSignal
): Promise<BranchPullRequest> {
const snapshot = await fetchOpenPrSnapshot(params, signal)
if (
snapshot.headRef !== params.branch ||
!snapshot.headRepoFullName ||
!snapshot.baseRepoFullName ||
snapshot.headRepoFullName.toLowerCase() !== snapshot.baseRepoFullName.toLowerCase()
) {
throw new Error(
`PR #${params.pullNumber} no longer points to ${params.owner}/${params.repo}:${params.branch}`
)
}
return { pullNumber: params.pullNumber, snapshot }
}
/**
* Finds the single open, same-repository pull request whose head is exactly the
* requested branch. No match is valid because Update PR can create the missing
* pull request after it has safely pushed the branch.
*/
export async function findOpenPrForBranch(
params: Omit<PullRequestCoordinates, 'pullNumber'> & { branch: string },
signal?: AbortSignal
): Promise<BranchPullRequest | undefined> {
validateRepositoryCoordinates({ ...params, pullNumber: 1 })
const result = await executeTool(
'github_list_prs_v2',
{
owner: params.owner,
repo: params.repo,
state: 'open',
head: `${params.owner}:${params.branch}`,
per_page: 2,
page: 1,
apiKey: params.githubToken,
},
{ signal }
)
if (!result.success) {
throw new Error(
`Failed to find an open PR for branch ${params.branch}: ${result.error ?? 'unknown error'}`
)
}
const output = result.output
if (!isRecord(output)) {
throw new Error('GitHub pull request list response.output must be an object')
}
const items = output.items
if (!Array.isArray(items)) {
throw new Error('GitHub pull request list response.output.items must be an array')
}
if (items.length === 0) {
return undefined
}
if (items.length > 1) {
throw new Error(`Update PR found multiple open pull requests for branch ${params.branch}`)
}
if (!isRecord(items[0])) {
throw new Error('GitHub pull request list response item must be an object')
}
const pullNumber = requiredNumber(items[0], 'number', 'GitHub pull request list response item')
if (pullNumber < 1) {
throw new Error('GitHub pull request list response item.number must be positive')
}
return fetchOpenPrForBranch(
{
owner: params.owner,
repo: params.repo,
pullNumber,
githubToken: params.githubToken,
branch: params.branch,
},
signal
)
}
/**
* Changes only the draft/ready state. GitHub's REST pull-request update API
* cannot perform this transition, so the host uses the corresponding GraphQL
* mutation while keeping the token out of Pi's environment.
*/
export async function setPullRequestDraftState(
params: PullRequestCoordinates & { state: PullRequestDraftState },
signal?: AbortSignal
): Promise<void> {
validateRepositoryCoordinates(params)
const variables = {
owner: params.owner,
repo: params.repo,
pullNumber: params.pullNumber,
}
const queryResponse = await fetch(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: githubGraphQlHeaders(params.githubToken),
body: JSON.stringify({
query: `query PiPullRequestDraftState($owner: String!, $repo: String!, $pullNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pullNumber) {
id
isDraft
}
}
}`,
variables,
}),
signal,
})
const queryData = await readGraphQlData(queryResponse, 'GitHub pull request draft-state query')
const repository = requiredRecord(
queryData,
'repository',
'GitHub pull request draft-state query.data'
)
const pullRequest = requiredRecord(
repository,
'pullRequest',
'GitHub pull request draft-state query.data.repository'
)
const pullRequestId = requiredTrimmedString(
pullRequest,
'id',
'GitHub pull request draft-state query.data.repository.pullRequest'
)
const isDraft = requiredBoolean(
pullRequest,
'isDraft',
'GitHub pull request draft-state query.data.repository.pullRequest'
)
const shouldBeDraft = params.state === 'draft'
if (isDraft === shouldBeDraft) return
const mutationName = shouldBeDraft ? 'convertPullRequestToDraft' : 'markPullRequestReadyForReview'
const mutationResponse = await fetch(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: githubGraphQlHeaders(params.githubToken),
body: JSON.stringify({
query: `mutation PiSetPullRequestDraftState($pullRequestId: ID!) {
${mutationName}(input: { pullRequestId: $pullRequestId }) {
pullRequest {
id
isDraft
}
}
}`,
variables: { pullRequestId },
}),
signal,
})
await readGraphQlData(mutationResponse, 'GitHub pull request draft-state mutation')
}
export function validateRepositoryCoordinates(params: PullRequestCoordinates): void {
if (
!GITHUB_OWNER_PATTERN.test(params.owner) ||
!GITHUB_REPO_PATTERN.test(params.repo) ||
params.repo === '.' ||
params.repo === '..' ||
!Number.isSafeInteger(params.pullNumber) ||
params.pullNumber < 1
) {
throw new Error('Invalid GitHub repository coordinates or pull request number')
}
}