Initial commit: git-forge-plugin DSH plugin with docs
This commit is contained in:
commit
e303c51b23
11 files changed
+2351
No files matched your search
@@ -0,0 +1,554 @@
|
||||
// @local/git-forge — Host side.
|
||||
// HTTP endpoints for the "Clone repository" workspace dialog plus agent tools for
|
||||
// GitHub (gh) and Gitea/Forgejo (tea-cli) accounts. Tokens never leave the Host.
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
ForgeError, api, apiList, cloneRepo, deriveName, gitIn, listAccounts, listRepos,
|
||||
normRepo, publicAccount, pushRepo, resolveRepo,
|
||||
} from './forge.mjs'
|
||||
import {
|
||||
accountsWithStatus, cliStatus, giteaLogin, githubDeviceCancel, githubDeviceStart, githubDeviceStatus,
|
||||
githubTokenLogin, logout,
|
||||
} from './auth.mjs'
|
||||
|
||||
export const name = 'git-forge'
|
||||
export const inject = ['webServer', 'connection', 'tools']
|
||||
|
||||
const MAX_BODY = 64 * 1024
|
||||
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
const CLONE_ROOT = os.homedir()
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.statusCode = status
|
||||
res.setHeader('content-type', 'application/json; charset=utf-8')
|
||||
res.setHeader('cache-control', 'no-store')
|
||||
res.end(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
const chunks = []
|
||||
let size = 0
|
||||
for await (const chunk of req) {
|
||||
size += chunk.byteLength
|
||||
if (size > MAX_BODY) throw new ForgeError('request body too large', 413)
|
||||
chunks.push(chunk)
|
||||
}
|
||||
if (size === 0) return {}
|
||||
const body = JSON.parse(Buffer.concat(chunks, size).toString('utf8'))
|
||||
if (!isObj(body)) throw new ForgeError('body must be a JSON object', 400)
|
||||
return body
|
||||
}
|
||||
|
||||
// ---- shared operations (UI + tools) ----------------------------------------
|
||||
|
||||
let repoCache = null
|
||||
async function allRepos({ accountId, refresh = false } = {}) {
|
||||
if (!refresh && !accountId && repoCache && Date.now() - repoCache.at < 120000) return repoCache.value
|
||||
const accounts = (await listAccounts({ refresh })).filter((a) => !accountId || a.id === accountId)
|
||||
const results = await Promise.allSettled(accounts.map((a) => listRepos(a, { max: 500 })))
|
||||
const repos = []
|
||||
const errors = []
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled') repos.push(...r.value)
|
||||
else errors.push({ account: accounts[i].id, message: String(r.reason?.message ?? r.reason) })
|
||||
})
|
||||
repos.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))
|
||||
const value = { repos, errors }
|
||||
if (!accountId) repoCache = { at: Date.now(), value }
|
||||
return value
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent
|
||||
const repoPath = (r) => `/repos/${r.owner.split('/').map(enc).join('/')}/${enc(r.name)}`
|
||||
|
||||
function slimIssue(i) {
|
||||
return {
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
state: i.state,
|
||||
pull: !!i.pull_request,
|
||||
user: i.user?.login,
|
||||
labels: (i.labels ?? []).map((l) => l.name),
|
||||
assignees: (i.assignees ?? []).map((a) => a.login),
|
||||
comments: i.comments,
|
||||
createdAt: i.created_at,
|
||||
updatedAt: i.updated_at,
|
||||
url: i.html_url,
|
||||
}
|
||||
}
|
||||
|
||||
function slimPull(p) {
|
||||
return {
|
||||
number: p.number,
|
||||
title: p.title,
|
||||
state: p.state,
|
||||
draft: !!p.draft,
|
||||
merged: !!(p.merged || p.merged_at),
|
||||
user: p.user?.login,
|
||||
head: p.head?.label ?? p.head?.ref,
|
||||
base: p.base?.ref,
|
||||
mergeable: p.mergeable,
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
url: p.html_url,
|
||||
}
|
||||
}
|
||||
|
||||
const slimComment = (c) => ({ id: c.id, user: c.user?.login, body: c.body, createdAt: c.created_at, url: c.html_url })
|
||||
|
||||
function sessionCwd(exec) {
|
||||
return exec?.agent?.session?.header?.cwd || process.cwd()
|
||||
}
|
||||
|
||||
function need(v, nameOf) {
|
||||
if (v === undefined || v === null || v === '') throw new ForgeError(`${nameOf} is required for this action`, 400)
|
||||
return v
|
||||
}
|
||||
|
||||
// ---- tool implementations ---------------------------------------------------
|
||||
|
||||
async function toolAccounts() {
|
||||
const accounts = await listAccounts({ refresh: true })
|
||||
return {
|
||||
accounts: accounts.map(publicAccount),
|
||||
...(accounts.length ? {} : { hint: 'No authorized accounts. Ask the user to run `gh auth login` (GitHub) or `tea-cli login add` (Gitea/Forgejo) on the host.' }),
|
||||
}
|
||||
}
|
||||
|
||||
async function toolRepos(args, exec) {
|
||||
const action = args.action ?? 'list'
|
||||
if (action === 'list' || action === 'search') {
|
||||
const accounts = await listAccounts()
|
||||
const scoped = args.account ? accounts.filter((a) => a.id === args.account || a.name === args.account) : accounts
|
||||
if (!scoped.length) throw new ForgeError(args.account ? `unknown account ${args.account}` : 'no authorized accounts', 400)
|
||||
const limit = Math.min(Math.max(args.limit ?? 50, 1), 500)
|
||||
const results = await Promise.allSettled(scoped.map((a) => listRepos(a, { query: args.query, owner: args.owner, max: limit, signal: exec?.signal })))
|
||||
const repos = []
|
||||
const errors = []
|
||||
results.forEach((r, i) => r.status === 'fulfilled' ? repos.push(...r.value) : errors.push({ account: scoped[i].id, message: String(r.reason?.message) }))
|
||||
repos.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))
|
||||
return {
|
||||
repos: repos.slice(0, limit).map((r) => ({ account: r.account, fullName: r.fullName, description: r.description || '', private: !!r.private, fork: !!r.fork, archived: !!r.archived, defaultBranch: r.defaultBranch ?? '', updatedAt: r.updatedAt ?? '', cloneUrl: r.cloneUrl ?? '' })),
|
||||
...(errors.length ? { errors } : {}),
|
||||
}
|
||||
}
|
||||
if (action === 'create') {
|
||||
const accounts = await listAccounts()
|
||||
const account = args.account ? accounts.find((a) => a.id === args.account || a.name === args.account) : accounts[0]
|
||||
if (!account) throw new ForgeError('no matching account; pass account', 400)
|
||||
const body = { name: need(args.name, 'name'), description: args.description, private: args.private ?? true, auto_init: args.autoInit ?? false }
|
||||
if (account.kind === 'gitea' && args.defaultBranch) body.default_branch = args.defaultBranch
|
||||
const p = args.owner && args.owner.toLowerCase() !== account.user.toLowerCase() ? `/orgs/${enc(args.owner)}/repos` : '/user/repos'
|
||||
const r = await api(account, 'POST', p, { body, signal: exec?.signal })
|
||||
return { created: normRepo(account, r) }
|
||||
}
|
||||
const target = await resolveRepo({ repo: args.repo, account: args.account, cwd: sessionCwd(exec) })
|
||||
const { account } = target
|
||||
const rp = repoPath(target)
|
||||
if (action === 'get') return { repo: normRepo(account, await api(account, 'GET', rp, { signal: exec?.signal })) }
|
||||
if (action === 'edit') {
|
||||
const body = {}
|
||||
for (const [k, v] of [['description', args.description], ['private', args.private], ['default_branch', args.defaultBranch], ['archived', args.archived], ['name', args.newName], ['website', args.website]]) if (v !== undefined) body[k] = v
|
||||
if (!Object.keys(body).length) throw new ForgeError('nothing to edit: pass description/private/defaultBranch/archived/newName/website', 400)
|
||||
return { repo: normRepo(account, await api(account, 'PATCH', rp, { body, signal: exec?.signal })) }
|
||||
}
|
||||
if (action === 'fork') {
|
||||
const body = args.owner ? (account.kind === 'github' ? { organization: args.owner } : { organization: args.owner }) : {}
|
||||
if (args.newName) body.name = args.newName
|
||||
return { fork: normRepo(account, await api(account, 'POST', `${rp}/forks`, { body, signal: exec?.signal })) }
|
||||
}
|
||||
if (action === 'delete') {
|
||||
if (args.confirm !== `${target.owner}/${target.name}`) throw new ForgeError(`refusing to delete: set confirm to exactly "${target.owner}/${target.name}" after the user explicitly asked for deletion`, 400)
|
||||
await api(account, 'DELETE', rp, { signal: exec?.signal })
|
||||
return { deleted: `${target.owner}/${target.name}` }
|
||||
}
|
||||
if (action === 'branches') {
|
||||
const list = await apiList(account, `${rp}/branches`, { max: args.limit ?? 100, signal: exec?.signal })
|
||||
return { branches: list.map((b) => ({ name: b.name, commit: (b.commit?.sha ?? b.commit?.id ?? '').slice(0, 12), protected: b.protected })) }
|
||||
}
|
||||
if (action === 'releases') {
|
||||
const list = await apiList(account, `${rp}/releases`, { max: args.limit ?? 20, signal: exec?.signal })
|
||||
return { releases: list.map((r) => ({ tag: r.tag_name, name: r.name, draft: r.draft, prerelease: r.prerelease, publishedAt: r.published_at, url: r.html_url })) }
|
||||
}
|
||||
throw new ForgeError(`unknown action ${action}`, 400)
|
||||
}
|
||||
|
||||
async function toolIssues(args, exec) {
|
||||
const action = args.action ?? 'list'
|
||||
const target = await resolveRepo({ repo: args.repo, account: args.account, cwd: sessionCwd(exec) })
|
||||
const { account } = target
|
||||
const rp = repoPath(target)
|
||||
const signal = exec?.signal
|
||||
switch (action) {
|
||||
case 'list': {
|
||||
const query = { state: args.state ?? 'open', labels: args.labels?.join(','), ...(account.kind === 'gitea' ? { type: 'issues', q: args.query } : {}) }
|
||||
if (args.assignee) query[account.kind === 'gitea' ? 'assigned_by' : 'assignee'] = args.assignee
|
||||
const raw = await apiList(account, `${rp}/issues`, { query, max: args.limit ?? 30, signal })
|
||||
let issues = raw.filter((i) => !i.pull_request).map(slimIssue)
|
||||
if (args.query && account.kind === 'github') issues = issues.filter((i) => i.title.toLowerCase().includes(args.query.toLowerCase()))
|
||||
return { repo: `${target.owner}/${target.name}`, account: account.id, issues }
|
||||
}
|
||||
case 'get': {
|
||||
const n = need(args.number, 'number')
|
||||
const i = await api(account, 'GET', `${rp}/issues/${n}`, { signal })
|
||||
const comments = args.withComments === false ? [] : await apiList(account, `${rp}/issues/${n}/comments`, { max: 100, signal })
|
||||
return { issue: { ...slimIssue(i), body: i.body }, comments: comments.map(slimComment) }
|
||||
}
|
||||
case 'create': {
|
||||
const body = { title: need(args.title, 'title'), body: args.body ?? '' }
|
||||
if (args.assignees) body.assignees = args.assignees
|
||||
if (args.labels && account.kind === 'github') body.labels = args.labels
|
||||
const i = await api(account, 'POST', `${rp}/issues`, { body, signal })
|
||||
if (args.labels?.length && account.kind === 'gitea') await setGiteaLabels(account, rp, i.number, args.labels, signal)
|
||||
return { created: slimIssue(i) }
|
||||
}
|
||||
case 'comment': {
|
||||
const c = await api(account, 'POST', `${rp}/issues/${need(args.number, 'number')}/comments`, { body: { body: need(args.body, 'body') }, signal })
|
||||
return { comment: slimComment(c) }
|
||||
}
|
||||
case 'close':
|
||||
case 'reopen': {
|
||||
const n = need(args.number, 'number')
|
||||
if (args.body) await api(account, 'POST', `${rp}/issues/${n}/comments`, { body: { body: args.body }, signal })
|
||||
const i = await api(account, 'PATCH', `${rp}/issues/${n}`, { body: { state: action === 'close' ? 'closed' : 'open' }, signal })
|
||||
return { issue: slimIssue(i) }
|
||||
}
|
||||
case 'edit': {
|
||||
const n = need(args.number, 'number')
|
||||
const body = {}
|
||||
if (args.title !== undefined) body.title = args.title
|
||||
if (args.body !== undefined) body.body = args.body
|
||||
if (args.assignees) body.assignees = args.assignees
|
||||
if (args.labels && account.kind === 'github') body.labels = args.labels
|
||||
const i = Object.keys(body).length ? await api(account, 'PATCH', `${rp}/issues/${n}`, { body, signal }) : await api(account, 'GET', `${rp}/issues/${n}`, { signal })
|
||||
if (args.labels && account.kind === 'gitea') await setGiteaLabels(account, rp, n, args.labels, signal)
|
||||
return { issue: slimIssue(i) }
|
||||
}
|
||||
default:
|
||||
throw new ForgeError(`unknown action ${action}`, 400)
|
||||
}
|
||||
}
|
||||
|
||||
/** Gitea takes label ids, not names. */
|
||||
async function setGiteaLabels(account, rp, number, names, signal) {
|
||||
const all = await apiList(account, `${rp}/labels`, { max: 200, signal })
|
||||
const ids = names.map((n) => all.find((l) => l.name.toLowerCase() === String(n).toLowerCase())?.id).filter(Boolean)
|
||||
await api(account, 'PUT', `${rp}/issues/${number}/labels`, { body: { labels: ids }, signal })
|
||||
}
|
||||
|
||||
async function toolPulls(args, exec) {
|
||||
const action = args.action ?? 'list'
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await resolveRepo({ repo: args.repo, account: args.account, cwd })
|
||||
const { account } = target
|
||||
const rp = repoPath(target)
|
||||
const signal = exec?.signal
|
||||
switch (action) {
|
||||
case 'list': {
|
||||
const raw = await apiList(account, `${rp}/pulls`, { query: { state: args.state ?? 'open' }, max: args.limit ?? 30, signal })
|
||||
return { repo: `${target.owner}/${target.name}`, account: account.id, pulls: raw.map(slimPull) }
|
||||
}
|
||||
case 'get': {
|
||||
const n = need(args.number, 'number')
|
||||
const p = await api(account, 'GET', `${rp}/pulls/${n}`, { signal })
|
||||
const comments = args.withComments === false ? [] : await apiList(account, `${rp}/issues/${n}/comments`, { max: 100, signal })
|
||||
let files
|
||||
try {
|
||||
const f = await apiList(account, `${rp}/pulls/${n}/files`, { max: 300, signal })
|
||||
files = f.map((x) => ({ file: x.filename, status: x.status, additions: x.additions, deletions: x.deletions }))
|
||||
} catch { files = undefined }
|
||||
return { pull: { ...slimPull(p), body: p.body, additions: p.additions, deletions: p.deletions, changedFiles: p.changed_files }, files, comments: comments.map(slimComment) }
|
||||
}
|
||||
case 'create': {
|
||||
let head = args.head
|
||||
if (!head) {
|
||||
const b = await gitIn(cwd, ['symbolic-ref', '--short', 'HEAD'], { timeoutMs: 10000 })
|
||||
head = b.code === 0 ? b.stdout.trim() : undefined
|
||||
}
|
||||
need(head, 'head')
|
||||
let base = args.base
|
||||
if (!base) base = (await api(account, 'GET', rp, { signal })).default_branch
|
||||
const body = { title: need(args.title, 'title'), body: args.body ?? '', head, base }
|
||||
if (account.kind === 'github' && args.draft) body.draft = true
|
||||
const p = await api(account, 'POST', `${rp}/pulls`, { body, signal })
|
||||
return { created: slimPull(p) }
|
||||
}
|
||||
case 'comment': {
|
||||
const c = await api(account, 'POST', `${rp}/issues/${need(args.number, 'number')}/comments`, { body: { body: need(args.body, 'body') }, signal })
|
||||
return { comment: slimComment(c) }
|
||||
}
|
||||
case 'merge': {
|
||||
const n = need(args.number, 'number')
|
||||
const method = args.method ?? 'merge'
|
||||
if (account.kind === 'github') {
|
||||
const r = await api(account, 'PUT', `${rp}/pulls/${n}/merge`, { body: { merge_method: method, commit_title: args.title, commit_message: args.body }, signal })
|
||||
return { merged: !!r?.merged, sha: r?.sha, message: r?.message }
|
||||
}
|
||||
await api(account, 'POST', `${rp}/pulls/${n}/merge`, { body: { Do: method, MergeTitleField: args.title, MergeMessageField: args.body, delete_branch_after_merge: !!args.deleteBranch }, signal })
|
||||
return { merged: true }
|
||||
}
|
||||
case 'close':
|
||||
case 'reopen': {
|
||||
const p = await api(account, 'PATCH', `${rp}/pulls/${need(args.number, 'number')}`, { body: { state: action === 'close' ? 'closed' : 'open' }, signal })
|
||||
return { pull: slimPull(p) }
|
||||
}
|
||||
case 'diff': {
|
||||
const n = need(args.number, 'number')
|
||||
const url = account.kind === 'github' ? `${account.apiBase}${rp}/pulls/${n}` : `${account.apiBase}${rp}/pulls/${n}.diff`
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
authorization: account.kind === 'github' ? `Bearer ${account.token}` : `token ${account.token}`,
|
||||
accept: account.kind === 'github' ? 'application/vnd.github.diff' : 'text/plain',
|
||||
'user-agent': 'dsh-git-forge',
|
||||
},
|
||||
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000),
|
||||
})
|
||||
if (!res.ok) throw new ForgeError(`diff → ${res.status}`, res.status)
|
||||
const text = await res.text()
|
||||
const max = 60000
|
||||
return { diff: text.length > max ? `${text.slice(0, max)}\n… [truncated ${text.length - max} chars]` : text }
|
||||
}
|
||||
default:
|
||||
throw new ForgeError(`unknown action ${action}`, 400)
|
||||
}
|
||||
}
|
||||
|
||||
async function toolApi(args, exec) {
|
||||
const accounts = await listAccounts()
|
||||
let account
|
||||
if (args.account) account = accounts.find((a) => a.id === args.account || a.name === args.account)
|
||||
else if (args.repo || !args.account) {
|
||||
if (accounts.length === 1) account = accounts[0]
|
||||
else if (args.repo) account = (await resolveRepo({ repo: args.repo, cwd: sessionCwd(exec) })).account
|
||||
}
|
||||
if (!account) throw new ForgeError(`pass account (one of ${accounts.map((a) => a.id).join(', ') || 'none'})`, 400)
|
||||
const method = String(args.method ?? 'GET').toUpperCase()
|
||||
const r = await api(account, method, need(args.path, 'path'), { query: args.query, body: args.body, signal: exec?.signal, raw: true })
|
||||
let text = JSON.stringify(r.data)
|
||||
const max = 60000
|
||||
if (text && text.length > max) return { status: r.status, truncated: true, data: text.slice(0, max) }
|
||||
return { status: r.status, data: r.data }
|
||||
}
|
||||
|
||||
async function toolClone(args, exec) {
|
||||
const cwd = sessionCwd(exec)
|
||||
let url = args.url
|
||||
if (!url) throw new ForgeError('url is required (any https/ssh git URL, or owner/name with account)', 400)
|
||||
if (!/[:@]|\/\//.test(url)) {
|
||||
const t = await resolveRepo({ repo: url, account: args.account, cwd })
|
||||
url = `${t.account.url}/${t.owner}/${t.name}.git`
|
||||
}
|
||||
const parent = args.parent ? path.resolve(cwd, args.parent.replace(/^~(?=\/|$)/, os.homedir())) : cwd
|
||||
return cloneRepo({ url, parent, name: args.name || deriveName(url), branch: args.branch, depth: args.depth, signal: exec?.signal })
|
||||
}
|
||||
|
||||
async function toolPush(args, exec) {
|
||||
const cwd = args.path ? path.resolve(sessionCwd(exec), args.path) : sessionCwd(exec)
|
||||
return pushRepo({ cwd, remote: args.remote, branch: args.branch, force: !!args.force, tags: !!args.tags, signal: exec?.signal })
|
||||
}
|
||||
|
||||
// ---- tool definitions ---------------------------------------------------------
|
||||
|
||||
const S = (type, description, extra = {}) => ({ type, description, ...extra })
|
||||
const REPO = S('string', 'Repository as "owner/name" or any git/web URL. Omit to use the "origin" remote of the session working directory.')
|
||||
const ACCOUNT = S('string', 'Account id from forge_accounts (e.g. "github:octocat", "gitea:Agent"; Gitea and Forgejo accounts both use the gitea: prefix). Needed only when several accounts could match.')
|
||||
const NUMBER = S('integer', 'Issue or pull request number.')
|
||||
const LIMIT = S('integer', 'Maximum number of results (default 30).')
|
||||
|
||||
function defineTool(def, fn) {
|
||||
return {
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
parameters: { type: 'object', properties: def.properties, required: def.required ?? [], additionalProperties: false },
|
||||
output: {
|
||||
schema: {},
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 1) }],
|
||||
},
|
||||
timeoutMs: def.timeoutMs,
|
||||
async execute(args, exec) {
|
||||
try {
|
||||
// Round-trip drops undefined fields: tool output must be lossless JSON.
|
||||
return JSON.parse(JSON.stringify((await fn(args ?? {}, exec)) ?? null))
|
||||
} catch (error) {
|
||||
return { error: String(error?.message ?? error) }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
defineTool({
|
||||
name: 'forge_accounts',
|
||||
description: 'List the GitHub (gh) and Gitea/Forgejo (tea-cli) accounts authorized on this host. Their tokens are used by the other forge_* tools and by git clone/push.',
|
||||
properties: {},
|
||||
}, toolAccounts),
|
||||
defineTool({
|
||||
name: 'forge_repos',
|
||||
description: 'Repositories on authorized forge accounts. Actions: list (repos of all accounts, or of owner), search (query), get, create (name, private defaults true), edit, fork, delete (needs confirm = "owner/name" and an explicit user request), branches, releases.',
|
||||
properties: {
|
||||
action: S('string', 'Operation (default list).', { enum: ['list', 'search', 'get', 'create', 'edit', 'fork', 'delete', 'branches', 'releases'] }),
|
||||
repo: REPO,
|
||||
account: ACCOUNT,
|
||||
owner: S('string', 'list/search: user or org to list. create: org to create in (default: the account user). fork: target org.'),
|
||||
query: S('string', 'search: text to match repository names.'),
|
||||
name: S('string', 'create: new repository name.'),
|
||||
newName: S('string', 'edit/fork: new name.'),
|
||||
description: S('string', 'create/edit: description.'),
|
||||
private: S('boolean', 'create/edit: visibility.'),
|
||||
autoInit: S('boolean', 'create: initialize with a README.'),
|
||||
defaultBranch: S('string', 'create (Gitea) / edit: default branch.'),
|
||||
archived: S('boolean', 'edit: archive or unarchive.'),
|
||||
website: S('string', 'edit: website URL.'),
|
||||
confirm: S('string', 'delete: must equal "owner/name".'),
|
||||
limit: LIMIT,
|
||||
},
|
||||
}, toolRepos),
|
||||
defineTool({
|
||||
name: 'forge_issues',
|
||||
description: 'Issues of a GitHub/Gitea/Forgejo repository. Actions: list, get (with comments), create, comment, edit, close, reopen.',
|
||||
properties: {
|
||||
action: S('string', 'Operation (default list).', { enum: ['list', 'get', 'create', 'comment', 'edit', 'close', 'reopen'] }),
|
||||
repo: REPO,
|
||||
account: ACCOUNT,
|
||||
number: NUMBER,
|
||||
title: S('string', 'create/edit: title.'),
|
||||
body: S('string', 'create/edit: description; comment/close/reopen: comment text.'),
|
||||
state: S('string', 'list: filter (default open).', { enum: ['open', 'closed', 'all'] }),
|
||||
labels: S('array', 'list: filter; create/edit: label names to set.', { items: { type: 'string' } }),
|
||||
assignees: S('array', 'create/edit: usernames.', { items: { type: 'string' } }),
|
||||
assignee: S('string', 'list: filter by assignee.'),
|
||||
query: S('string', 'list: text filter.'),
|
||||
withComments: S('boolean', 'get: include comments (default true).'),
|
||||
limit: LIMIT,
|
||||
},
|
||||
}, toolIssues),
|
||||
defineTool({
|
||||
name: 'forge_pulls',
|
||||
description: 'Pull requests of a GitHub/Gitea/Forgejo repository. Actions: list, get (with files and comments), diff, create (head defaults to the current branch, base to the default branch; push first with forge_push), comment, merge, close, reopen.',
|
||||
properties: {
|
||||
action: S('string', 'Operation (default list).', { enum: ['list', 'get', 'diff', 'create', 'comment', 'merge', 'close', 'reopen'] }),
|
||||
repo: REPO,
|
||||
account: ACCOUNT,
|
||||
number: NUMBER,
|
||||
title: S('string', 'create: title; merge: commit title.'),
|
||||
body: S('string', 'create: description; comment: text; merge: commit message.'),
|
||||
head: S('string', 'create: source branch ("user:branch" for forks). Default: current branch.'),
|
||||
base: S('string', 'create: target branch. Default: repository default branch.'),
|
||||
draft: S('boolean', 'create: draft PR (GitHub).'),
|
||||
method: S('string', 'merge: strategy (default merge).', { enum: ['merge', 'squash', 'rebase'] }),
|
||||
deleteBranch: S('boolean', 'merge (Gitea): delete head branch after merge.'),
|
||||
state: S('string', 'list: filter (default open).', { enum: ['open', 'closed', 'all'] }),
|
||||
withComments: S('boolean', 'get: include comments (default true).'),
|
||||
limit: LIMIT,
|
||||
},
|
||||
}, toolPulls),
|
||||
defineTool({
|
||||
name: 'forge_clone',
|
||||
description: 'Clone a repository with the authorized account credentials (works for private repos; SSH URLs are switched to HTTPS+token when no SSH key is available). Returns the absolute path.',
|
||||
properties: {
|
||||
url: S('string', 'Any git URL (https or ssh, GitHub/Gitea/Forgejo) or "owner/name".'),
|
||||
account: ACCOUNT,
|
||||
parent: S('string', 'Directory to clone into (default: session working directory).'),
|
||||
name: S('string', 'Folder name (default: repository name).'),
|
||||
branch: S('string', 'Branch to check out.'),
|
||||
depth: S('integer', 'Shallow clone depth.'),
|
||||
},
|
||||
required: ['url'],
|
||||
timeoutMs: 30 * 60000,
|
||||
}, toolClone),
|
||||
defineTool({
|
||||
name: 'forge_push',
|
||||
description: 'Push local commits of a git repository to its remote using the authorized account token (no SSH keys or password prompts needed). Sets upstream. Use force only when the user asked for it (uses --force-with-lease).',
|
||||
properties: {
|
||||
path: S('string', 'Repository directory (default: session working directory).'),
|
||||
remote: S('string', 'Remote name (default origin).'),
|
||||
branch: S('string', 'Branch to push (default: current branch).'),
|
||||
force: S('boolean', 'Force push with lease.'),
|
||||
tags: S('boolean', 'Also push annotated tags (--follow-tags).'),
|
||||
},
|
||||
timeoutMs: 10 * 60000,
|
||||
}, toolPush),
|
||||
defineTool({
|
||||
name: 'forge_api',
|
||||
description: 'Raw authenticated REST call for anything the other forge_* tools do not cover (labels, milestones, releases, workflows, org members, notifications, …). GitHub: https://api.github.com paths; Gitea/Forgejo: /api/v1 paths (e.g. "/repos/{owner}/{repo}/labels").',
|
||||
properties: {
|
||||
account: ACCOUNT,
|
||||
repo: S('string', 'Optional "owner/name" or URL used only to pick the account.'),
|
||||
method: S('string', 'HTTP method (default GET).', { enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }),
|
||||
path: S('string', 'Path relative to the API base, starting with "/".'),
|
||||
query: S('object', 'Query parameters.', { additionalProperties: true }),
|
||||
body: S('object', 'JSON request body.', { additionalProperties: true }),
|
||||
},
|
||||
required: ['path'],
|
||||
}, toolApi),
|
||||
]
|
||||
|
||||
// ---- plugin -----------------------------------------------------------------
|
||||
|
||||
export function apply(ctx) {
|
||||
const route = (pathname, methods, handler) => ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: pathname,
|
||||
handler: async (req, res) => {
|
||||
const rejection = ctx.connection.requestRejection(req)
|
||||
if (rejection !== undefined) { sendJson(res, rejection, { code: 'rejected' }); return }
|
||||
if (!methods.includes(req.method)) {
|
||||
res.statusCode = 405
|
||||
res.setHeader('allow', methods.join(', '))
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://local')
|
||||
sendJson(res, 200, await handler(req, url))
|
||||
} catch (error) {
|
||||
const status = error instanceof ForgeError && error.status >= 400 && error.status < 500 ? error.status : 500
|
||||
sendJson(res, status, { code: 'error', message: String(error?.message ?? error) })
|
||||
}
|
||||
},
|
||||
}), `git-forge: ${pathname}`)
|
||||
|
||||
route('/git-forge/defaults', ['GET'], async () => ({ cloneRoot: CLONE_ROOT }))
|
||||
route('/git-forge/accounts', ['GET'], async (_req, url) => ({
|
||||
accounts: (await listAccounts({ refresh: url.searchParams.get('refresh') === '1' })).map(publicAccount),
|
||||
}))
|
||||
route('/git-forge/repos', ['GET'], async (_req, url) => allRepos({
|
||||
accountId: url.searchParams.get('account') || undefined,
|
||||
refresh: url.searchParams.get('refresh') === '1',
|
||||
}))
|
||||
const invalidate = (r) => { repoCache = null; return r }
|
||||
route('/git-forge/auth/status', ['GET'], async (_req, url) => ({
|
||||
cli: await cliStatus(),
|
||||
accounts: await accountsWithStatus({ verify: url.searchParams.get('verify') === '1' }),
|
||||
}))
|
||||
route('/git-forge/auth/gitea', ['POST'], async (req) => invalidate(await giteaLogin(await readJsonBody(req))))
|
||||
route('/git-forge/auth/github/token', ['POST'], async (req) => invalidate(await githubTokenLogin(await readJsonBody(req))))
|
||||
route('/git-forge/auth/github/device', ['POST'], async (req) => githubDeviceStart(await readJsonBody(req)))
|
||||
route('/git-forge/auth/github/device/status', ['GET'], async (_req, url) => {
|
||||
const s = githubDeviceStatus(url.searchParams.get('id') || '')
|
||||
if (s.state === 'success') repoCache = null
|
||||
return s
|
||||
})
|
||||
route('/git-forge/auth/github/device/cancel', ['POST'], async (req) => githubDeviceCancel((await readJsonBody(req)).id))
|
||||
route('/git-forge/auth/logout', ['POST'], async (req) => invalidate(await logout((await readJsonBody(req)).account)))
|
||||
// Minimal directory listing for the fallback "Open local folder" dialog.
|
||||
route('/git-forge/ls', ['GET'], async (_req, url) => {
|
||||
const { promises: fsp } = await import('node:fs')
|
||||
const raw = url.searchParams.get('path') || CLONE_ROOT
|
||||
const dir = path.resolve(raw.replace(/^~(?=$|\/)/, os.homedir()))
|
||||
let entries
|
||||
try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch (e) { throw new ForgeError(`cannot list ${dir}: ${e.code || e.message}`, 400) }
|
||||
const hidden = url.searchParams.get('hidden') === '1'
|
||||
const dirs = entries.filter((e) => e.isDirectory() && (hidden || !e.name.startsWith('.'))).map((e) => e.name).sort((a, b) => a.localeCompare(b))
|
||||
return { path: dir, parent: path.dirname(dir) === dir ? '' : path.dirname(dir), home: os.homedir(), dirs }
|
||||
})
|
||||
route('/git-forge/clone', ['POST'], async (req) => {
|
||||
const body = await readJsonBody(req)
|
||||
return cloneRepo({ url: body.url, parent: body.parent || CLONE_ROOT, name: body.name, branch: body.branch || undefined })
|
||||
})
|
||||
|
||||
ctx.effect(function* () {
|
||||
for (const tool of TOOLS) yield ctx.tools.register(tool)
|
||||
}, 'git-forge: agent tools')
|
||||
}
|
||||
Reference in new issue
Block a user