An expanded card now marks a draft ready for review and merges a change request that the host would merge now: - The forge seam gains provider actions `markReady` and `merge`, which return a typed `ForgeActionFailure`; the registry polls a watched change request at once after either settles. Snapshots carry `mergeReady` and the `mergeMethods` the repository allows, the user's usual one first. - GitHub marks ready with `gh pr ready` and merges through the REST merge endpoint with the head SHA the card showed, so new commits refuse the merge; it never enables auto-merge, joins a merge queue, or deletes the head branch. - The panel Remote acts only on visible bindings of the Session, and the card offers 标记为可评审 for drafts and a 合并 menu with the allowed methods and a confirmation, reporting each outcome in a toast. - The forge panel e2e replaces the worktree branch before the ARIA capture normalizes durations, so a random suffix no longer breaks it.
76 lines
3.3 KiB
TypeScript
76 lines
3.3 KiB
TypeScript
/** GitHub remote URL and pull request URL parsing, and validation of the parts inlined into requests. */
|
|
|
|
import type { ChangeRequestRef, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge'
|
|
|
|
/** Characters GitHub permits in owner and repository names. */
|
|
const SEGMENT = /^[A-Za-z0-9_.-]+$/
|
|
|
|
/** Host, owner, and repository name addressed by a URL. */
|
|
export interface GitHubLocation {
|
|
/** Lowercase host name. */
|
|
readonly host: string
|
|
/** Owner as the URL spells it. */
|
|
readonly owner: string
|
|
/** Repository name without a `.git` suffix. */
|
|
readonly name: string
|
|
}
|
|
|
|
/**
|
|
* Check one owner or repository name segment.
|
|
* @param value - candidate segment.
|
|
* @returns whether GitHub permits the segment and it is not a dot path.
|
|
*/
|
|
export function isSegment(value: string): boolean {
|
|
return SEGMENT.test(value) && value !== '.' && value !== '..'
|
|
}
|
|
|
|
/**
|
|
* Validate a pull request before its owner, name, and number are inlined into
|
|
* a GraphQL document, a `gh` argument, or an API path.
|
|
* @param ref - pull request on a GitHub host.
|
|
* @returns its repository.
|
|
* @throws when the owner or name is not a permitted segment or the number is not a positive integer.
|
|
*/
|
|
export function checkedRepository(ref: ChangeRequestRef): ForgeRepository {
|
|
const { owner, name } = ref.repository
|
|
if (!isSegment(owner) || !isSegment(name)) throw new Error(`forge-github: invalid repository ${JSON.stringify(`${owner}/${name}`)}`)
|
|
if (!Number.isSafeInteger(ref.number) || ref.number <= 0) throw new Error(`forge-github: invalid pull request number ${ref.number}`)
|
|
return ref.repository
|
|
}
|
|
|
|
/**
|
|
* Parse a Git remote URL. Accepts `http(s)://[user@]host[:port]/owner/name`,
|
|
* `ssh://[user@]host[:port]/owner/name`, `git://host/owner/name`, and the
|
|
* scp-like `[user@]host:owner/name`, each with an optional `.git` suffix or
|
|
* trailing slash.
|
|
* @param url - fetch or push URL as Git reports it.
|
|
* @returns the location, or undefined for other URL shapes.
|
|
*/
|
|
export function parseRemoteUrl(url: string): GitHubLocation | undefined {
|
|
const trimmed = url.trim()
|
|
const scheme = /^(?:https?|ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed)
|
|
const scp = scheme === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(trimmed) : null
|
|
const match = scheme ?? scp
|
|
if (match === null) return undefined
|
|
const [, host = '', path = ''] = match
|
|
const parts = path.replace(/\/+$/, '').replace(/\.git$/i, '').split('/')
|
|
if (parts.length !== 2) return undefined
|
|
const [owner = '', name = ''] = parts
|
|
if (!isSegment(owner) || !isSegment(name)) return undefined
|
|
return { host: host.toLowerCase(), owner, name }
|
|
}
|
|
|
|
/**
|
|
* Parse a pull request web URL such as `https://github.com/owner/name/pull/12/files`.
|
|
* @param url - web URL of a pull request.
|
|
* @returns the location and number, or undefined for other URLs.
|
|
*/
|
|
export function parsePullRequestUrl(url: string): (GitHubLocation & { readonly number: number }) | undefined {
|
|
const match = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(url.trim())
|
|
if (match === null) return undefined
|
|
const [, host = '', owner = '', name = '', digits = ''] = match
|
|
const number = Number(digits)
|
|
if (!isSegment(owner) || !isSegment(name) || !Number.isSafeInteger(number) || number <= 0) return undefined
|
|
return { host: host.toLowerCase(), owner, name, number }
|
|
}
|